Merge pull request #1502 from alandtse/pr/mcp-plot-thread-stats

feat(mcp): add plot window and thread zone stats
This commit is contained in:
Bartosz Taudul
2026-10-27 20:53:12 +01:00
committed by GitHub
4 changed files with 171 additions and 0 deletions
+8
View File
@@ -19,8 +19,16 @@ 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
`(time_ns, value)` pairs, thinned to at most `max_samples`;
`get_plot_window_stats(name, start_ns, end_ns)` returns a `PlotSummary`
(count/min/max/sum/avg) over just that window. `get_plots()` only
summarises the whole trace.
- Sections: `get_sections()` — timed code sections from
`TracySectionEnter`/`TracySectionLeave` instrumentation. Returns a list of
`{category, start, end, text}` dicts (start/end in ns); `category` is the
+48
View File
@@ -0,0 +1,48 @@
"""Needs a saved trace that contains a plot:
TRACY_TEST_TRACE=/path/to.tracy TRACY_TEST_PLOT=<plot name> python -m pytest extra/mcp/test_plot_samples.py
"""
import os
import time
import pytest
import tracy_mcp
TRACE = os.environ.get("TRACY_TEST_TRACE")
PLOT = os.environ.get("TRACY_TEST_PLOT")
pytestmark = pytest.mark.skipif(not (TRACE and PLOT), reason="TRACY_TEST_TRACE and TRACY_TEST_PLOT 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_whole_window_matches_the_summary(worker):
summary = next(p for p in worker.get_plots() if p.name == PLOT)
window = worker.get_plot_window_stats(PLOT)
assert (window.count, window.min, window.max) == (summary.count, summary.min, summary.max)
def test_two_windows_partition_the_samples(worker):
first, last = worker.get_first_time(), worker.get_last_time()
mid = (first + last) // 2
a = worker.get_plot_window_stats(PLOT, first, mid)
b = worker.get_plot_window_stats(PLOT, mid, last + 1)
assert a.count + b.count == worker.get_plot_window_stats(PLOT).count
def test_samples_are_thinned_and_ordered(worker):
samples = worker.get_plot_samples(PLOT, max_samples=10)
assert 0 < len(samples) <= 10
assert [t for t, _ in samples] == sorted(t for t, _ in samples)
def test_unknown_plot_is_empty(worker):
assert worker.get_plot_samples("no such plot") == []
assert worker.get_plot_window_stats("no such plot").count == 0
+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) == {}
+78
View File
@@ -1,5 +1,7 @@
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include <algorithm>
#include <limits>
#include <unordered_map>
#ifdef _MSC_VER
@@ -321,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)
@@ -1016,6 +1054,46 @@ PYBIND11_MODULE( TracyServerBindings, m )
return result;
} ) )
.def( "get_plot_samples", locked( +[]( const Worker& w, const std::string& name, int64_t startNs, int64_t endNs, size_t maxSamples ) {
std::vector<std::pair<int64_t, double>> result;
for( const auto* pd : w.GetPlots() )
{
if( !pd || w.GetString( pd->name ) != name ) continue;
auto first = std::lower_bound( pd->data.begin(), pd->data.end(), startNs,
[]( const PlotItem& item, int64_t t ) { return item.time.Val() < t; } );
auto last = std::lower_bound( first, pd->data.end(), endNs,
[]( const PlotItem& item, int64_t t ) { return item.time.Val() < t; } );
const size_t n = last - first;
const size_t step = maxSamples && n > maxSamples ? ( n + maxSamples - 1 ) / maxSamples : 1;
for( auto it = first; it < last; it += std::min<size_t>( step, last - it ) )
result.emplace_back( it->time.Val(), it->val );
break;
}
return result;
} ), py::arg( "name" ), py::arg( "start_ns" ) = 0, py::arg( "end_ns" ) = std::numeric_limits<int64_t>::max(), py::arg( "max_samples" ) = 100000 )
.def( "get_plot_window_stats", locked( +[]( const Worker& w, const std::string& name, int64_t startNs, int64_t endNs ) {
PlotSummary result{ name, 0, 0, 0, 0, 0, "User" };
static const char* plotTypeStr[] = { "User", "Memory", "SysTime", "Power" };
for( const auto* pd : w.GetPlots() )
{
if( !pd || w.GetString( pd->name ) != name ) continue;
result.type = (uint8_t)pd->type < 4 ? plotTypeStr[(uint8_t)pd->type] : "Unknown";
auto first = std::lower_bound( pd->data.begin(), pd->data.end(), startNs,
[]( const PlotItem& item, int64_t t ) { return item.time.Val() < t; } );
for( auto it = first; it != pd->data.end() && it->time.Val() < endNs; ++it )
{
if( result.count == 0 || it->val < result.min ) result.min = it->val;
if( result.count == 0 || it->val > result.max ) result.max = it->val;
result.sum += it->val;
result.count++;
}
result.avg = result.count ? result.sum / result.count : 0.0;
break;
}
return result;
} ), py::arg( "name" ), py::arg( "start_ns" ) = 0, py::arg( "end_ns" ) = std::numeric_limits<int64_t>::max() )
// --- Memory pools ---
.def( "get_memory_pools", locked( +[]( const Worker& w ) {
std::vector<MemPoolSummary> result;