mirror of
https://github.com/wolfpld/tracy.git
synced 2026-10-29 12:05:30 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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) == {}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user