Memory profiling tool

The Lyft memory profiling tool is based on tracemalloc. To capture the memory trace for a given gunicorn process, we registered the worker process to listen to USR2 signal during the application initialization phase.

# app/__init__.py

MemoryProfiler().register_handlers()

# mem_profiler.py pseudo code

class MemoryProfiler:
def __init__(self) -> None:
self._state_machine = self._profiling_state_machine()

def register_handlers(self) -> None:
# Register gunicorn worker to listen to USR2 to dump traces
signal.signal(signal.SIGUSR2, self.handle_signal)

def handle_signal(self, signum: signal.Signals, frame: FrameType) -> None:
next(self._state_machine)

def _profiling_state_machine(self) -> Generator[None, None, None]:
while True:
try:
self.start_tracing() # tracemalloc.start()
self.memory_dump() # Create snapshot1
yield
self.memory_dump() # Create snaphot2,compare with snapshot1, and dump the difference in a file
finally:
if tracemalloc.is_tracing():
tracemalloc.stop()

Let’s start the tracing!

Ok, now that we had the memory profiler setup, we are ready for some tracing to find the source of the leak. To start the tracing, we send USR2 signal to the gunicorn process in the K8s pod to start tracing and send the signal again after some time interval to capture the stack trace with highest memory usage.

ps aux
Press enter or click to view image in full size
Initial process list before sending USR2 signal

Now, we will send a USR2 signal to worker with pid 12

kill -USR2 12

Upon checking the process list again….

ps aux
Press enter or click to view image in full size
Tracing killing the gunicorn worker with PID=12

we observed that the gunicorn process we planned to trace got killed 🙁

It took several hours of debugging and a journey back to one of my favorite class to find the root of the issue — preload. To understand why preload caused the process to be killed, we first need to understand how gunicorn works.

Gunicorn

Gunicorn works on the pre-fork model. There is a leader process which forks a bunch of workers. There are two ways to fork the workers:

Get Jay Patel’s stories in your inbox

Join Medium for free to get updates from this writer.

Remember me for faster sign in

No Preload

Press enter or click to view image in full size
Gunicorn forked workers with no preload

When the leader process forks a worker, the worker has its own application code. This results in the worker process having a larger memory footprint than the leader.

smem -a - sort=pid -k
Press enter or click to view image in full size
Service with no preload: Worker PSS mem = ~203MB

With Preload

Press enter or click to view image in full size
Gunicorn forked workers with preload

Preload is a memory optimization based on the concept of copy-on-write. Essentially, the workers share the imports and application code with the leader and only modified pages are written to the worker’s memory.

smem -a - sort=pid -k
Press enter or click to view image in full size
Service with preload: Worker PSS mem reduced to ~41MB!!

So how does preload play a role with USR2 signal killing the process?

If you remember, we registered the signal during the app initialization by calling register_handlers().

# app/__init__.py

MemoryProfiler().register_handlers()


# mem_profiler.py

class MemoryProfiler:

def register_handlers(self) -> None:
# Register gunicorn worker to listen to USR2 to dump traces
signal.signal(signal.SIGUSR2, self.handle_signal)

Since the app had preload=True, only the leader process was registering the USR2 signal to handle the tracing. The worker process did not register due to copy-on-write and that causes any kill -USR2 to actually kill the process!