feat(profiler): add TracingProfiler with per-call thread-aware tracing - #169
Conversation
Add TracingProfiler class that collects per-call (function, thread_id, start_ns, end_ns) data using sys.monitoring (PEP 669) on Python 3.12+ with sys.settrace fallback for older versions. Key features: - Thread-aware collection via per-thread call stacks - Coroutine-aware: PY_YIELD/PY_RESUME produce separate spans - Same output interface as Profiler (output_text, output_html) - Raw trace access via traces() method - Aggregation to existing table/flamegraph/icicle visualizations Also extracts HTML builders to module-level functions (_render_table_html, _render_flame_html) for reuse by both Profiler and TracingProfiler. Reference library: yappi (added to bench-profiler extra). Closes #168
There was a problem hiding this comment.
Review — feat(profiler): add TracingProfiler
CI green across lint + 3.10–3.13. Solid work — dual-backend design (sys.monitoring + settrace fallback), thread-aware per-call tracing, clean HTML builder refactoring to shared module-level functions, and thorough test coverage (58 correctness + 7 benchmark + 3 yappi cross-validation). Approved.
Actionable
-
Duplicate yappi import block —
test_profiler_correctness.pyhas thetry: import yappi/_HAS_YAPPIblock twice (once near the top after helpers, once again before the TracingProfiler tests at ~line 655). Second one is redundant, remove it. -
builtins=Truesilently ignored — constructor accepts it and docstring says "Reserved for future use", but a caller passingTracingProfiler(builtins=True)gets no indication it's a no-op. They'd silently miss C-level calls. RaiseNotImplementedError("C-level tracing not yet supported")whenbuiltins=True, so misuse fails loud. -
_acquire_tool_idskipsPROFILER_ID(2) — tries(3, 4, 0, 1, 5)but never triessys.monitoring.PROFILER_ID(2), which is the semantically correct slot for a profiler. Should try 2 first (or at least include it in the list). If another profiler already holds it, theget_tool(tid) is Nonecheck will skip it naturally. -
_resolve_sort_keycoupling —TracingProfilercallsProfiler._resolve_sort_key(sort_by)in several places. Since the HTML renderers were already extracted to module-level, this helper should follow — avoids the cross-class internal dependency.
Non-blocking observations
-
Tool ID leak on partial
_start_monitoringfailure — if_start_monitoringraises after_acquire_tool_idsucceeds (e.g.register_callbackfails),self._runningis never set, sostop()won't free the tool ID. Atry/exceptin_start_monitoringthat callsfree_tool_idon failure would close this gap. -
Flamegraph roots not merged for yielding coroutines —
PY_YIELD/PY_RESUMEproduce separate trace spans (by design), but_extract_call_treedoesn't merge top-level roots by name. A coroutine that yields will appear as multiple root entries in flamegraph/icicle output. Children are merged within nodes but roots are not. Worth documenting or adding a root-merge pass.
Overall clean and well-tested. 👍
There was a problem hiding this comment.
Good addition — TracingProfiler fills the per-call analysis gap that Profiler (cProfile wrapper) can't cover. The dual-backend design (sys.monitoring on 3.12+ / sys.settrace fallback), thread-aware collection, coroutine handling via PY_YIELD/PY_RESUME, and the shared HTML rendering refactor are all clean. CI green across lint + 3.10–3.13, 140 tests pass.
A few items worth addressing:
Actionable
-
_merge_childrenkeys by function name only — Two distinct functions with the same name from different files/classes get merged into one flamegraph node. Should key by(name, file, lineno)to match the_extract_rowsaggregation key. -
_on_py_exitignores thecodeparameter — Stack is popped blindly without verifying the popped frame matches the exiting function. If the stack ever desynchronizes (e.g. C extension not firing a PY_START), the wrong function gets attributed silently. A debug assertion likeassert func == code.co_qualnamewould be cheap insurance. -
_extract_rowsusesid(rec)aschild_sumkey — Works today because all TraceRecord objects are alive simultaneously, but any future refactoring that copies/recreates records would silently break the parent→child linkage. Using the record's index in the list would be more robust. -
Duplicate yappi import block in test_profiler_correctness.py — The
try: import yappi/except ImportErrorblock appears at both lines ~36 and ~655. Second one is unnecessary.
Non-blocking
-
builtins=Truesilently no-ops — Accepted but ignored. Awarnings.warn()or docstring note would prevent callers from assuming C-level tracing is active. -
_stop_settraceunconditionally setssys.settrace(None)— Destroys any pre-existing trace function (debuggers, coverage). Saving and restoring the previous trace function would be more cooperative. -
_stacksdefaultdict without locking — Safe under the GIL but won't be under free-threaded 3.13t. Worth a note if you plan to support--disable-gilin the future. -
_ensure_stopped()passes while running —traces()/output_text()are callable during active profiling, which could give inconsistent partial results. May want to either snapshot or raise.
Overall: well-structured, great test coverage (58 TracingProfiler tests + 3 yappi cross-validation + benchmarks), clean HTML refactor to shared module-level functions. The _acquire_tool_id skip-2 trick to avoid stomping coverage is a nice touch.
There was a problem hiding this comment.
Review — feat(profiler): add TracingProfiler
CI green across lint + 3.10–3.13. Dual-backend design is solid, thread-aware collection is correct, test coverage is thorough (58 TracingProfiler + 3 yappi cross-validation + 7 benchmarks), and the HTML builder extraction to module-level is a clean refactor. Approved.
Actionable
-
_merge_childrenkeys by function name only (+1 Clementine) —key = child["name"]will merge distinct functions that happen to share a name (e.g. two different__init__methods). Should key by(name, file, lineno)to match the(rec.func, rec.file, rec.lineno)triple used in_extract_rows. -
Duplicate yappi import block (+1 Elena, Clementine) —
test_profiler_correctness.pyhas thetry: import yappi/_HAS_YAPPIblock at both ~line 36 and ~line 655. Remove the second one. -
builtins=Truesilently ignored (+1 Elena, Clementine) — Constructor accepts it and docstring says "Reserved for future use", but a caller passingTracingProfiler(builtins=True)silently gets no C-level tracing. ANotImplementedErroror at minimum awarnings.warn()would prevent silent misuse. -
settracefallback has different thread coverage thansys.monitoring—sys.monitoringevents are process-wide (all threads, including pre-existing ones), butsys.settraceonly covers the calling thread, andthreading.settraceonly applies to newly spawned threads. Pre-existing worker threads won't be traced on <3.12. This is a meaningful behavioral asymmetry between backends — worth a docstring note onTracingProfilerso users on 3.10/3.11 know why their thread pool might show gaps.
Non-blocking
-
_on_py_exitdoesn't validate the popped frame (+1 Clementine) — The stack pop is blind; no check thatstack[-1].co_qualname == code.co_qualname. A cheapassertwould catch desync from C extensions that don't firePY_START. -
_ensure_stoppednaming — The method doesn't ensure the profiler is stopped; it checks that data exists (raises only whenwall_end_ns == 0 and not running).traces()andoutput_text()are callable during active profiling and return partial/live data, which may surprise callers. The name_ensure_has_datawould better communicate what it actually guards. -
Incomplete call frames silently discarded — Functions mid-call when
stop()is called leave entries in_stacksthat never becomeTraceRecords. Expected behavior for a profiler, but worth a brief note in the class docstring so users know functions that haven't returned by stop-time won't appear. -
id(rec)aschild_sumkey (+1 Clementine) — Works because all TraceRecord objects are alive simultaneously inrecords, butid()only guarantees uniqueness while the object lives. An index-based approach would be more self-documenting and future-proof.
Clean work overall. The _acquire_tool_id skipping slot 2 (coverage.py) is a nice touch, and the per-thread stacks with a shared _records_lock for the output list is the right pattern. 👍
Actionable fixes: - Raise NotImplementedError when builtins=True (C-level tracing unsupported) - Include PROFILER_ID (2) in tool ID acquisition list - Extract _resolve_sort_key to module-level function - Key _merge_children by (name, file, lineno) not just name - Verify popped frame matches code in _on_py_exit - Use stable integer indices instead of id(rec) in _extract_rows - Remove duplicate yappi import block in tests Non-blocking fixes: - Guard against tool ID leak on partial _start_monitoring failure - Save/restore pre-existing trace function in settrace backend - Raise ProfilerError when accessing data while profiler is running
|
All review feedback addressed in 200793e. Thanks @elena-oaklight and @clementine-oaklight for the thorough reviews. Actionable — all fixed
Non-blocking — also fixed
142 tests pass, pre-commit clean. |
- Merge root-level nodes by (name, file, lineno) in _extract_call_tree so coroutines split by yield/resume appear as a single flamegraph entry - Add comment noting _stacks defaultdict is lock-free under GIL and needs locking for free-threaded builds
Summary
TracingProfilerclass toprofiler/profiler.pythat collects per-call(function, thread_id, start_ns, end_ns)trace data usingsys.monitoring(PEP 669, Python 3.12+) withsys.settracefallbackProfiler(output_text,output_htmlwith table/flamegraph/icicle styles), plustraces()for raw data accessyappi>=1.6.0as reference library for correctness and benchmark comparisonCloses #168
Test plan
Profilertests pass (refactored HTML builders are behavior-preserving)TracingProfilercorrectness tests: lifecycle, async, trace data, thread awareness, coroutine tracing, text/HTML output, data extraction, edge casespre-commit run --all-filespasses (ruff, ruff-format, ty, complexipy)make test-profiler— 140 tests pass