diff --git a/extra/mcp/eval_guide.md b/extra/mcp/eval_guide.md index 6d51722c..ae44880b 100644 --- a/extra/mcp/eval_guide.md +++ b/extra/mcp/eval_guide.md @@ -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 diff --git a/extra/mcp/test_plot_samples.py b/extra/mcp/test_plot_samples.py new file mode 100644 index 00000000..d4c9c1d3 --- /dev/null +++ b/extra/mcp/test_plot_samples.py @@ -0,0 +1,48 @@ +"""Needs a saved trace that contains a plot: + TRACY_TEST_TRACE=/path/to.tracy TRACY_TEST_PLOT= 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 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 bf91186f..28b6168b 100644 --- a/python/bindings/ServerModule.cpp +++ b/python/bindings/ServerModule.cpp @@ -1,5 +1,7 @@ #include #include +#include +#include #include #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 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> 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( 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::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::max() ) + // --- Memory pools --- .def( "get_memory_pools", locked( +[]( const Worker& w ) { std::vector result;