10 min read 5 sections

py-spy Flame Graphs for asyncio Spatial Pipelines

When a geofence evaluator’s P99 quietly climbs from 15ms to 41ms, the metrics tell you which phase is slow but not which frame is burning the CPU. Attaching a conventional profiler means redeploying with instrumentation, which perturbs the very timing you are chasing and often cannot be done on a production node under load. py-spy solves this: it reads another process’s memory from the outside via process_vm_readv, samples its call stacks at a fixed rate, and renders a flame graph — all without pausing the interpreter, importing anything into the target, or requiring a restart. This page sits under Production Monitoring & Observability for Geofence Pipelines and the event routing and backpressure architecture, and it addresses one narrow skill: reading an async flame graph correctly, where the widest frame is usually a red herring.

Concept and specification

A flame graph is an aggregation of stack samples. Each horizontal bar is a stack frame; its width is proportional to the number of samples in which that frame was on the stack — i.e. wall-clock time, not invocation count. Frames stack vertically by call depth. For a sampler running at rate $r$ over a window of $T$ seconds, a frame occupying fraction $w$ of the graph consumed approximately

so at the default over a 60s capture, a frame at 30% width represents ~18s of wall time and ~1800 samples — enough resolution to trust. Below ~1% width you are in sampling noise.

The async subtlety: in an asyncio pipeline the widest frame is almost always the event loop’s select/epoll_wait — the loop waiting for I/O — which is idle time, not a bottleneck. Worse, CPU-bound geofence geometry is deliberately pushed off the loop with asyncio.to_thread, so it shows up under a thread stack, and the loop’s view of it is a wide to_thread/run_in_executor wait frame. Reading the graph correctly means distinguishing three things:

Frame pattern Meaning Action
Wide epoll_wait / select under the loop Loop idle, waiting for events Ignore — this is healthy
Wide to_thread / _worker wait on the loop Coroutines blocked on the thread pool Pool exhaustion — add workers or shed
Wide real frame in a worker thread (e.g. point_in_polygon) Genuine CPU cost The actual bottleneck — optimize this
Frame pattern at a glance: Meaning, Action A row per frame pattern, a column per option, so a single axis can be compared across options in one sweep. Frame pattern at a glance: Meaning, Action the same trade-offs, read across instead of down Meaning Action Wide epoll_wait / select under the loop Loop idle, waiting for events Ignore — this is healthy Wide to_thread / _worker wait on the loop Coroutines blocked on the thread pool Pool exhaustion — add workers or shed Wide real frame in a worker thread (e.g. point_in_polygo… Genuine CPU cost The actual bottleneck — optimize this
The frame pattern table read across rather than down — each row is one axis of the decision, colour-keyed so a single trade-off can be followed across Meaning, Action.
Reading an async flame graph: the widest frame is the idle event loop, not the bottleneck Left column, event loop thread: wide loop base, a very wide epoll_wait idle-wait frame, and a run_in_executor pool-wait frame — no real CPU. Right column, worker thread: thread base, evaluate, and a highlighted point_in_polygon frame that is the true CPU cost to optimize. Event loop thread Worker thread (to_thread) BaseEventLoop._run_once epoll_wait — idle wait, NOT a bottleneck run_in_executor pool wait Thread.run → _worker evaluate → _containment point_in_polygon — real CPU optimize THIS frame widest frame (epoll_wait) = idle · optimize the widest real-work frame on a worker thread

py-spy exposes three subcommands. dump prints the current stack of every thread once — the fastest triage, no capture window. top shows a live, htop-style rolling view of the hottest functions. record captures over a window and writes an SVG flame graph or speedscope JSON. Two flags matter for spatial pipelines: --native, which unwinds C/Cython/native extension frames (essential when point-in-polygon is a compiled routine), and --gil, which counts only samples holding the GIL, separating true Python CPU time from native/thread wait.

Because py-spy reads the target from the outside, it never adds import overhead, never holds a lock the target needs, and — unlike a signal-based profiler such as cProfile — does not require the process to cooperate or restart. That property is what makes it safe to run against a production geofence node carrying live traffic: the target keeps evaluating events at full rate while py-spy walks its stacks from a neighbouring process, and the only cost is a small, bounded slice of a separate core spent unwinding and aggregating samples. The trade-off is statistical rather than exact — you get a sampled distribution of where wall-clock time went, not a deterministic call count — but for latency triage the distribution is precisely what you want, since it weights frames by the time they actually cost rather than by how often they were called.

Step-by-step implementation

Prerequisites: py-spy>=0.3.14 installed on the host (or a sidecar sharing the process namespace), the target’s PID, and permission to read its memory. On Linux, py-spy needs CAP_SYS_PTRACE — either run as root, add the capability, or set --cap-add=SYS_PTRACE on the container.

1. Triage with a dump — no window needed. This is the first thing to run when P99 alerts fire.

bash
# One-shot stack of every thread; --locals adds argument values for context.
py-spy dump --pid "$(pgrep -f geofence_evaluator)" --locals

Read the worker-thread stacks: if they sit in point_in_polygon or route, geometry is hot; if the loop thread sits in run_in_executor while workers idle, the pool is the constraint, not the math.

2. Capture a flame graph under live load. Sample the running process for 60s at 100Hz.

bash
# --native unwinds the Cython/C point-in-polygon frames that pure-Python
# sampling would collapse into an opaque "<built-in>" bar.
py-spy record --pid "$(pgrep -f geofence_evaluator)" \
  --native --rate 100 --duration 60 --output flame.svg

Gotcha: without --native, a Cython or NumPy point-in-polygon routine appears as a single flat <built-in method> frame with no internal structure — you see that geometry is expensive but not where. Always add --native when the hot path is a compiled extension.

3. Separate GIL-held CPU from thread wait. Re-record counting only GIL-holding samples to confirm whether the cost is real Python execution.

bash
# --gil: a frame that stays wide here holds the GIL (true Python CPU);
# a frame that shrinks was thread/native wait masquerading as busy.
py-spy record --pid "$PID" --gil --rate 100 --duration 30 --output gil.svg

4. Run it continuously as a sidecar. Archive a rolling flame graph so a post-incident investigation has a profile from before the incident.

bash
# Cron or sidecar loop; speedscope format is smaller and diffable across captures.
while true; do
  ts="$(date +%s)"
  py-spy record --pid "$PID" --rate 100 --duration 60 \
    --format speedscope --output "/profiles/geofence-${ts}.json"
done

5. Read the graph. Open flame.svg in a browser, click to zoom a subtree, use the search box to highlight polygon or route, and compare the highlighted total against the loop’s epoll_wait width. The frame to optimize is the widest real work frame in a worker thread — never the loop’s idle wait.

Benchmark and verification

The case below is representative: a node holding a 41ms P99 at 30k events/sec. The flame graph, captured with --native, showed 34% of samples in a single _ray_cast_crossing frame inside the exact point-in-polygon step — a pure-Python edge loop that had never been compiled. Swapping it for a Numba-JIT routine that releases the GIL moved the cost off the interpreter. The before/after, same load and same node:

Metric Before (pure-Python ray cast) After (Numba JIT, GIL released)
P50 evaluation 12 ms 6 ms
P95 evaluation 27 ms 9 ms
P99 evaluation 41 ms 12 ms
_ray_cast_crossing flame width 34% <2%
Widest frame after fix epoll_wait (idle) epoll_wait (idle)
P95 evaluation and 2 more — Before (pure-Python ray cast) vs After (Numba JIT, GIL released) P95 evaluation, P99 evaluation, _ray_cast_crossing flame width drawn for Before (pure-Python ray cast), After (Numba JIT, GIL released). Each measure keeps its own scale because the rows are in different units. P95 evaluation and 2 more — Before (pure-Python ray cast) vs After (Numba JIT, GIL released) the table above, drawn to scale Before (pure-Python ray cast) After (Numba JIT, GIL released) P95 evaluation 27 ms 9 ms P99 evaluation 41 ms 12 ms _ray_cast_crossing flame width 34% <2% The 3 rows with the widest spread; the table above carries all 5.
P95 evaluation, P99 evaluation and 1 more for Before (pure-Python ray cast), After (Numba JIT, GIL released), drawn from the measurements in the table above. Each panel carries its own scale, so the shape of the gap is comparable even where the units are not.

The verification discipline: after the fix, re-record and confirm the previously hot frame has collapsed below the noise floor and the widest remaining frame is the loop’s idle wait — the signature of a pipeline that is now I/O-bound rather than CPU-bound. A minimal harness to reproduce the profiling target under controlled load:

python
import asyncio

async def drive(evaluator, rate_hz: int, seconds: int) -> None:
    """Feed synthetic pings at a fixed rate so py-spy samples a steady state."""
    interval = 1.0 / rate_hz
    deadline = asyncio.get_running_loop().time() + seconds
    while asyncio.get_running_loop().time() < deadline:
        await evaluator.enqueue(_synthetic_ping())  # bounded-queue enqueue
        await asyncio.sleep(interval)  # pace the load so the profile is stationary

def _synthetic_ping() -> object:
    return object()  # placeholder telemetry

Attach py-spy record to this process while drive runs, and the flame graph reflects the steady-state hot path rather than startup or teardown transients.

Failure modes and edge cases

  • Sampling a native extension without --native. The compiled point-in-polygon frame collapses to an opaque built-in bar and the actual hot loop is invisible. If a wide frame has no children and is named <built-in>, re-record with --native.
  • Permission / ptrace denial. py-spy dump failing with Operation not permitted means the kernel’s ptrace_scope blocks cross-process reads. Either run py-spy as root, grant CAP_SYS_PTRACE, or set /proc/sys/kernel/yama/ptrace_scope to 0 in a controlled environment. In containers, the target and py-spy must share a PID namespace.
  • Misreading loop idle as a bottleneck. The single most common error: epoll_wait or select is the widest frame, and an inexperienced reader “optimizes” it. That frame is the event loop waiting for the next event — shrinking it means more load, not less. Always cross-check against the worker-thread stacks.
  • Sampling rate too low for short spikes. A 5ms GC pause or a rare deep-polygon evaluation may fall between 100Hz samples. For sub-millisecond frames raise --rate to 500–1000Hz, accepting the higher overhead on the target for the duration of the capture.
  • Thread churn hiding cost. A ProcessPoolExecutor or short-lived thread that exits between samples leaves fragmentary stacks. Profile against a warm, steady-state pool; py-spy attributes samples to the thread alive at sample time, so a churning pool smears the geometry cost across ephemeral frames.