From bbb4eebc61a79c48e4b6a51f7d48b8137663d146 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Tue, 27 Oct 2026 03:49:36 -0700 Subject: [PATCH] feat(mcp): get zone stats for a single thread get_all_zone_stats mixes every thread, so the main-thread cost of a zone could not be separated from worker threads. get_thread_zone_stats returns the same per-name stats for the zones recorded on one thread, and its per-thread sums match get_all_zone_stats exactly. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01L3DnWkXakGRgz9C8BaLM47 --- extra/mcp/eval_guide.md | 3 +++ extra/mcp/test_thread_zone_stats.py | 37 +++++++++++++++++++++++++++++ python/bindings/ServerModule.cpp | 36 ++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 extra/mcp/test_thread_zone_stats.py diff --git a/extra/mcp/eval_guide.md b/extra/mcp/eval_guide.md index 0f929cf9..ae44880b 100644 --- a/extra/mcp/eval_guide.md +++ b/extra/mcp/eval_guide.md @@ -19,6 +19,9 @@ data surface. Common entry points: - Frames: `get_frame_count()`, `get_frame_times()`, `get_frame_times_named(name)`, `get_frame_boundaries()`, `get_zones_in_frame(...)` - Threads: `get_threads()`, `get_thread_name(tid)`, `get_thread_context_switches(tid)` + `get_thread_zone_stats(tid)` is `get_all_zone_stats()` restricted to one thread's zones, + so a main-thread cost is not mixed with worker threads. A zone name used at several + source locations appears once in both, for its last location. - Messages / plots / locks / memory / callstacks: `get_messages()`, `get_plots()`, `get_locks()`, `get_memory_events()`, `get_callstack_frames(...)` - Plot samples: `get_plot_samples(name, start_ns=0, end_ns=max, max_samples=100000)` returns diff --git a/extra/mcp/test_thread_zone_stats.py b/extra/mcp/test_thread_zone_stats.py new file mode 100644 index 00000000..4f667367 --- /dev/null +++ b/extra/mcp/test_thread_zone_stats.py @@ -0,0 +1,37 @@ +"""Needs a saved trace: + TRACY_TEST_TRACE=/path/to.tracy python -m pytest extra/mcp/test_thread_zone_stats.py +""" +import os +import time + +import pytest + +import tracy_mcp + +TRACE = os.environ.get("TRACY_TEST_TRACE") +pytestmark = pytest.mark.skipif(not TRACE, reason="TRACY_TEST_TRACE not set") + + +@pytest.fixture(scope="module") +def worker(): + server = tracy_mcp.tracy_server + w = server.create_worker_from_file(server.open_file(TRACE)) + while not w.is_background_done(): + time.sleep(0.1) + yield w + w.shutdown() + + +def test_threads_partition_the_global_stats(worker): + counts, totals = {}, {} + for thread in worker.get_threads(): + for name, stats in worker.get_thread_zone_stats(thread["id"]).items(): + counts[name] = counts.get(name, 0) + stats.count + totals[name] = totals.get(name, 0) + stats.total + for name, stats in worker.get_all_zone_stats().items(): + assert counts.get(name, 0) == stats.count + assert totals.get(name, 0) == stats.total + + +def test_unknown_thread_is_empty(worker): + assert worker.get_thread_zone_stats(2**40) == {} diff --git a/python/bindings/ServerModule.cpp b/python/bindings/ServerModule.cpp index 7f82ffd4..28b6168b 100644 --- a/python/bindings/ServerModule.cpp +++ b/python/bindings/ServerModule.cpp @@ -323,6 +323,42 @@ PYBIND11_MODULE( TracyServerBindings, m ) return result; } ) ) + .def( "get_thread_zone_stats", locked( +[]( const Worker& w, uint64_t threadId ) { + // Same shape and name handling as get_all_zone_stats(), counting only zones recorded on one thread + py::dict result; +#ifndef TRACY_NO_STATISTICS + std::unordered_map chosen; + for( const auto& kv : w.GetSourceLocationZones() ) + { + if( kv.second.zones.size() == 0 ) continue; + chosen[w.GetZoneName( w.GetSourceLocation( kv.first ) )] = kv.first; + } + for( const auto& kv : w.GetSourceLocationZones() ) + { + const std::string name = w.GetZoneName( w.GetSourceLocation( kv.first ) ); + const auto it = chosen.find( name ); + if( it == chosen.end() || it->second != kv.first ) continue; + int64_t min = INT64_MAX, max = INT64_MIN, total = 0; + double sumSq = 0; + size_t count = 0; + for( const auto& ztd : kv.second.zones ) + { + if( w.DecompressThread( ztd.Thread() ) != threadId ) continue; + const auto* z = ztd.Zone(); + if( !z || !z->IsEndValid() ) continue; + const int64_t dur = z->End() - z->Start(); + total += dur; + sumSq += (double)dur * dur; + count++; + if( dur < min ) min = dur; + if( dur > max ) max = dur; + } + if( count ) result[name.c_str()] = ZoneStats{ min, max, total, sumSq, count, (double)total / count }; + } +#endif + return result; + } ), "thread_id"_a ) + // --- Per-occurrence zone data (for temporal correlation / distribution) --- .def( "get_zone_durations", locked( +[]( const Worker& w, const std::string& name, size_t maxSamples ) { // Accumulates across ALL srclocs with this name (same name can appear at multiple srclocs)