Skip to content

feat(profiler): add TracingProfiler with per-call thread-aware tracing - #169

Merged
Oaklight merged 3 commits into
masterfrom
worktree-feature+tracing-profiler
Sep 15, 2026
Merged

Oaklight merged 3 commits into
masterfrom
worktree-feature+tracing-profiler

Conversation

@Oaklight

Copy link
Copy Markdown
Owner

Summary

  • Add TracingProfiler class to profiler/profiler.py that collects per-call (function, thread_id, start_ns, end_ns) trace data using sys.monitoring (PEP 669, Python 3.12+) with sys.settrace fallback
  • Thread-aware collection with per-thread call stacks, coroutine-aware (PY_YIELD/PY_RESUME produce separate spans)
  • Same output interface as Profiler (output_text, output_html with table/flamegraph/icicle styles), plus traces() for raw data access
  • Extract HTML builders to module-level functions for reuse by both profiler classes
  • Add yappi>=1.6.0 as reference library for correctness and benchmark comparison

Closes #168

Test plan

  • All 68 existing Profiler tests pass (refactored HTML builders are behavior-preserving)
  • 58 new TracingProfiler correctness tests: lifecycle, async, trace data, thread awareness, coroutine tracing, text/HTML output, data extraction, edge cases
  • 3 yappi cross-validation tests: call counts, function names, multi-thread comparison
  • 7 new benchmark tests: tracing overhead vs yappi, output generation, multi-thread overhead
  • pre-commit run --all-files passes (ruff, ruff-format, ty, complexipy)
  • make test-profiler — 140 tests pass

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

@elena-oaklight elena-oaklight Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Duplicate yappi import block — test_profiler_correctness.py has the try: import yappi / _HAS_YAPPI block twice (once near the top after helpers, once again before the TracingProfiler tests at ~line 655). Second one is redundant, remove it.

  2. builtins=True silently ignored — constructor accepts it and docstring says "Reserved for future use", but a caller passing TracingProfiler(builtins=True) gets no indication it's a no-op. They'd silently miss C-level calls. Raise NotImplementedError("C-level tracing not yet supported") when builtins=True, so misuse fails loud.

  3. _acquire_tool_id skips PROFILER_ID (2) — tries (3, 4, 0, 1, 5) but never tries sys.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, the get_tool(tid) is None check will skip it naturally.

  4. _resolve_sort_key coupling — TracingProfiler calls Profiler._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_monitoring failure — if _start_monitoring raises after _acquire_tool_id succeeds (e.g. register_callback fails), self._running is never set, so stop() won't free the tool ID. A try/except in _start_monitoring that calls free_tool_id on failure would close this gap.

  • Flamegraph roots not merged for yielding coroutines — PY_YIELD/PY_RESUME produce separate trace spans (by design), but _extract_call_tree doesn'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. 👍

@clementine-oaklight clementine-oaklight Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. _merge_children keys 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_rows aggregation key.

  2. _on_py_exit ignores the code parameter — 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 like assert func == code.co_qualname would be cheap insurance.

  3. _extract_rows uses id(rec) as child_sum key — 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.

  4. Duplicate yappi import block in test_profiler_correctness.py — The try: import yappi / except ImportError block appears at both lines ~36 and ~655. Second one is unnecessary.

Non-blocking

  1. builtins=True silently no-ops — Accepted but ignored. A warnings.warn() or docstring note would prevent callers from assuming C-level tracing is active.

  2. _stop_settrace unconditionally sets sys.settrace(None) — Destroys any pre-existing trace function (debuggers, coverage). Saving and restoring the previous trace function would be more cooperative.

  3. _stacks defaultdict without locking — Safe under the GIL but won't be under free-threaded 3.13t. Worth a note if you plan to support --disable-gil in the future.

  4. _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.

@milo-oaklight milo-oaklight Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. _merge_children keys 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.

  2. Duplicate yappi import block (+1 Elena, Clementine) — test_profiler_correctness.py has the try: import yappi / _HAS_YAPPI block at both ~line 36 and ~line 655. Remove the second one.

  3. builtins=True silently ignored (+1 Elena, Clementine) — Constructor accepts it and docstring says "Reserved for future use", but a caller passing TracingProfiler(builtins=True) silently gets no C-level tracing. A NotImplementedError or at minimum a warnings.warn() would prevent silent misuse.

  4. settrace fallback has different thread coverage than sys.monitoring — sys.monitoring events are process-wide (all threads, including pre-existing ones), but sys.settrace only covers the calling thread, and threading.settrace only 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 on TracingProfiler so users on 3.10/3.11 know why their thread pool might show gaps.

Non-blocking

  1. _on_py_exit doesn't validate the popped frame (+1 Clementine) — The stack pop is blind; no check that stack[-1].co_qualname == code.co_qualname. A cheap assert would catch desync from C extensions that don't fire PY_START.

  2. _ensure_stopped naming — The method doesn't ensure the profiler is stopped; it checks that data exists (raises only when wall_end_ns == 0 and not running). traces() and output_text() are callable during active profiling and return partial/live data, which may surprise callers. The name _ensure_has_data would better communicate what it actually guards.

  3. Incomplete call frames silently discarded — Functions mid-call when stop() is called leave entries in _stacks that never become TraceRecords. 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.

  4. id(rec) as child_sum key (+1 Clementine) — Works because all TraceRecord objects are alive simultaneously in records, but id() 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
@Oaklight

Copy link
Copy Markdown
Owner Author

All review feedback addressed in 200793e. Thanks @elena-oaklight and @clementine-oaklight for the thorough reviews.

Actionable — all fixed

  1. Duplicate yappi import block — removed second occurrence
  2. builtins=True silently ignored — now raises NotImplementedError("C-level function tracing is not yet supported") + test added
  3. _acquire_tool_id skips PROFILER_ID — now tries (2, 3, 4, 0, 1, 5), PROFILER_ID first
  4. _resolve_sort_key coupling — extracted to module-level function, Profiler._resolve_sort_key is a thin wrapper
  5. _merge_children keys by name only — now keys by (name, file, lineno)
  6. _on_py_exit ignores code parameter — now peeks at stack top and skips if function doesn't match
  7. _extract_rows uses id(rec) — replaced with stable integer indices via enumerate()

Non-blocking — also fixed

  • Tool ID leak — _start_monitoring wraps callback registration in try/except, frees tool ID on failure
  • _stop_settrace destroys pre-existing trace — now saves sys.gettrace() in _start_settrace and restores in _stop_settrace
  • _ensure_stopped passes while running — now raises ProfilerError("profiler is still running - call stop() first")

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
@Oaklight
Oaklight merged commit 62c0166 into master Sep 15, 2026
6 checks passed
@Oaklight
Oaklight deleted the worktree-feature+tracing-profiler branch September 15, 2026 09:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(profiler): TracingProfiler — per-call tracing with thread-aware collection

1 participant