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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3DnWkXakGRgz9C8BaLM47
This commit is contained in:
Alan Tse
2026-10-27 10:04:47 -07:00
co-authored by Claude Sonnet 5.5
parent 0ea62d9551
commit bbb4eebc61
3 changed files with 76 additions and 0 deletions
+3
View File
@@ -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
+37
View File
@@ -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) == {}
+36
View File
@@ -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<std::string, int16_t> 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)