Agent tools (MCP)
TT-NN Visualizer ships a Model Context Protocol server so a coding agent can ask a report the questions a person asks it during model bring-up: which operations dominate, where the time went inside them, whether a change helped, and what the run allocated.
It is a read-only surface over the same report readers the web application uses. It starts
no web server, opens no port, and writes nothing to a report. By default it appends usage
events to ~/.ttnn-visualizer/usage/events.log (tool name and outcome only). Set
USAGE_RECORDING_DISABLED=true or create the marker file under that directory to opt out;
see the event logging reference.
Running it
ttnn-visualizer-mcp
The server speaks newline-delimited JSON-RPC on stdin and stdout, which is what an MCP client expects from a stdio server. Register it with your client the way you would any other stdio MCP server — for Claude Code:
claude mcp add ttnn-visualizer -- ttnn-visualizer-mcp
Reports are addressed by path, not by the instanceId the web application uses, so the
server needs no database and no running application.
The initialize response’s serverInfo.version is the installed ttnn-visualizer
version, so a launcher can check for a minimum release before relying on a tool;
protocolVersion is the MCP specification revision, not the package version. A
version of unknown means it could not be determined — treat it as unverifiable,
not as old.
The same response carries instructions: the rules every tool shares, which a client
puts in the agent’s system prompt before any call. They say to call load_report first,
that a performance row’s id and a profiler operation_id are different id spaces,
which units the figures are in, how a signpost range is read, what the projection,
note and caveat fields are for, and that text from the report itself, such as an op
code or an error message, is data to read rather than instructions to follow. Each tool’s
own description keeps only what is particular to it. tools/list gives every tool a title, and marks each one read-only,
idempotent and closed-world (readOnlyHint, idempotentHint, openWorldHint), since
none writes anything or reaches past the report it is given. load_report reads whatever
directory it is given, though, so a client that approves read-only tools without asking
lets an agent open any report this user can read. Answers are compact JSON.
Links into the visualizer
Answers link to the same thing in the web application, for the reader to open:
load_reportreturns anapp_linkto the report.operation_detailandoperation_provenancereturn anapp_linkto the operation.find_operations,top_ops,program_cache,memory_profile,tensor_flow,errors,dram_fallbacksandl1_peakreturn oneapp_link_template, in which{operation_id}takes any operation id the answer lists.
A link names the report by its directories, and the visualizer opens them when the link is
followed, as ttnn-visualizer --profiler-path does: each followed link starts a new session
on that report, so it lands on the report the agent read, not on whatever the reader had
open. The server itself still makes no request; following the link is what opens the
report. A link carries those directories as absolute paths on this machine, so it is for
the reader here, not for sharing.
Links point at http://localhost:8000 unless the server’s environment sets
TTNN_VISUALIZER_URL, which may name any host:
claude mcp add ttnn-visualizer -e TTNN_VISUALIZER_URL=http://localhost:8001 -- ttnn-visualizer-mcp
A TTNN_VISUALIZER_URL that is not an http(s) URL with a host gives no links at all,
rather than links to the default.
They open only in a visualizer running locally; a hosted one does not open a report by
path, and neither does one framed inside another page. A local one opens a linked directory
without asking, as long as it holds the files a report needs, so follow links you got from
your own agent. A page that plants a db.sqlite in a folder you can reach, such as
Downloads, could link the visualizer to it too. An answer read at a rank other than 0
carries no link, because the visualizer shows every operation at rank 0 and would open
another rank’s operation under the same id.
The tools
Tool |
Answers |
|---|---|
|
What this report contains, and which of the tools below apply to it: its signposts, architecture, device count and the tt-perf-report version. Returns a handle the others take. |
|
The costliest operations by device time, op-to-op gap, total percentage, FLOPS, DRAM bandwidth or core count, over the whole report or a signpost range. Each row carries the profiler |
|
Where the time went, per op or per op category, over the whole report or a signpost range: each group’s count, and its device time and op-to-op gap with the share each is of the total. |
|
Whether programs were built again rather than reused: per op code, its calls, distinct programs, and how many repeat calls hit or missed the program cache. |
|
Per-zone, per-RISC totals from |
|
How evenly one operation’s kernel time fell across the cores that ran it, or the costliest operations’: the minimum, median and maximum per-core span and per RISC, the imbalance between them, the slowest cores by coordinate, and how many cores recorded zones against the row’s core count. |
|
Per-operation-code deltas between two reports, largest movement first, over the whole reports or one signpost range in both. |
|
Operations matching a name substring or a call-stack substring, with the ids |
|
One operation: its input and output tensors with shape, dtype, layout and memory config, what it had allocated, and the circular buffers each of its device operations allocated, with size per core, core range and device count, with its error and likely DRAM fallback when it has one, and its per-device |
|
Memory footprint per operation, keyed by buffer type and ranked within each, with each type’s largest footprint, the device’s L1 geometry, and |
|
Which operation produced a tensor and which ones consumed it. |
|
What one operation was called with, and where in the model code it came from. |
|
The errors the run recorded, including an operation that started and never completed: which operation, the device operations whose launch failed, and for an allocation failure its figures and why it did not fit. |
|
Operations that likely fell back from L1 to DRAM, inferred from their memory config arguments and from retries after an L1 allocation failure. |
|
The run’s L1 peak per bank, split into circular buffers, intra-operation tensors, persistent tensors and stale ones, with the operation holding it and the operations closest to it. |
top_ops, op_breakdown, program_cache, zone_timings, core_spread and diff_reports
read the performance CSVs; the operation tools — find_operations, operation_detail,
memory_profile, tensor_flow, operation_provenance, errors, dram_fallbacks and
l1_peak — read the profiler report’s SQLite database;
load_report opens both, since its whole job is saying which of the others apply. A capture can carry either, both, or — as far as these tools are concerned —
neither, which is what makes load_report’s answer worth reading first.
Call load_report first. It reports what is answerable rather than making you discover it
one failed call at a time, because the report kinds are independent: a performance-only
capture has no operation graph and no tensor data, and a performance tool asked of a handle
loaded without a performance_path is refused and says so. zone_timings is listed only when
profile_log_device.csv decodes as UTF-8, has every column the tool reads (timer_id,
zone name, run host ID, PCIe slot, RISC processor type, core_x, core_y and
time[cycles since reset]), and has at least one data row. core_spread is listed only when
zone_timings is, the performance CSV can be reported on, the device log marks zone
starts and ends in a type or zone phase column, and its first line names a positive
CHIP_FREQ[MHz]. top_ops, op_breakdown and
program_cache are listed only when the performance CSV can be reported on, and when it
cannot, performance_csv_problem says why (empty, no data rows, missing columns: …, or
that it cannot be read or decoded).
Each database tool is checked against the tables it actually reads, so a truncated
capture that holds operations but not buffers is reported as answering
find_operations and nothing else, rather than advertising every tool and failing most
of them.
load_report also says what the capture is:
signpostslists the names a signpost range can take, in the order tt-perf-report resolves them, with how often each occurs — one name bounds both ends of a range only if it occurs twice — andsignpost_countcounts them all, since the list stops at 20. Both are absent when the performance CSV cannot be reported on, and empty when it holds no signposts. A name longer than 256 characters, which a range cannot take, is counted but left out of the list, andsignposts_unlisted_too_longsays how many were. When the CSV can be reported on but its signposts cannot be read,signposts_problemsays why. Loading reads the signposts, so a handle answers from the CSV it was loaded on: one rewritten or superseded after that is refused, andload_reportagain gives a fresh handle.archanddevice_countare keyed by the file each was read from, since a handle’s two reports can come from different runs.arch.device_logis the device profiler log’s header, whichcapturealso returns whole;arch.cluster_descriptoris what thearchmapping in rank 0’scluster_descriptor.yaml, beside the profiler database, names — as written, since older descriptors spell WormholeWormholeand newer oneswormhole_b0, and joined with commas if its chips differ. The profiler database itself records no architecture.device_count.performance_csvcounts the devices the performance CSV has rows for, which no other answer shows because rows are merged across devices; it is read with the signposts, so it is absent whenever they are.device_count.profilercounts the profiler database’s devices, every rank’s.tt_perf_report_versionis the tt-perf-report release the performance answers are generated with, which decides fields such asop_category.
What the answers mean
Five properties are deliberate, and worth knowing before you act on a number.
Every tool returns an aggregate or a bounded slice, never the table. A performance report runs to tens of thousands of rows of about thirty-five fields. Limits are capped server-side, so asking for more returns the cap rather than the report.
No rows are hidden from you. The web application’s performance view hides host operations by default and can narrow to a signpost range. Those are display choices, and an agent handed them unannounced would reason confidently about a partial set. The tools read the report with host operations included, signpost markers kept, and, unless you pass one, no signpost range.
A range is read only when you ask for one. top_ops, op_breakdown and diff_reports
take start_signpost and end_signpost, the bounds tt-perf-report --start-signpost and
--end-signpost take, so a bring-up run measured over PERF_DECODE to PERF_DECODE_END
can be checked against the same window. They are resolved the way tt-perf-report resolves
them: a name means its earliest occurrence by host start time, and an end that names the
start signpost too means its second occurrence. The window holds the rows strictly between
the two, so the bounding signposts are left out and any signposts inside it are kept.
tt-perf-report recomputes total_percent over the window, and the first operation in the
window has no op-to-op gap, because tt-perf-report measures a gap from the previous
operation and none precedes it inside the window. Ranking by op_to_op_gap therefore
leaves that operation out, and op_to_op_gap_total excludes the gap leading into the
window, which is often the largest in the capture. A ranged answer is not the whole-report
answer cut down — it is what the signposted run reports.
A range the report cannot honour is refused rather than answered from other rows: a name
the report does not hold (the refusal lists the names it does), one name for both bounds
when it occurs only once, an end that falls before the start, or a name longer than 256
characters. A report with no signposts at all is read whole, because tt-perf-report then
uses the whole file rather than some other window, and the response’s projection says so
with signpost_range_ignored. Every ranged response’s projection names the range, so a
windowed answer cannot be taken for a whole-report one. diff_reports applies one range to
both reports, and refuses when only one of them holds signposts: a window compared against
a whole report would show every row outside the window as a change. It checks the range
against both reports before generating either, and a refusal names the report it is about.
One filter is applied, deliberately: per-device rows are merged, so an operation that ran on eight devices is one row rather than eight. That is the unit an operation is reported in rather than a subset of the report — but it is a choice, so every response repeats the projection it used and you can see it.
Allocation figures are per bank, and nothing adds one memory type to another.
memory_profile and the allocations in operation_detail come from the report’s
max_size_per_bank column. That column is divided by the bank count of its own memory
type, and those counts differ — so a DRAM figure plus an L1 figure is two denominators in
one integer rather than a quantity. memory_profile therefore keys everything by buffer
type and ranks operations within a type, never across types.
A per-bank L1 figure is comparable to the l1_bank_size returned beside it, which is a
conservative bound. A device-wide total cannot be derived from the response, and the
response says so rather than offering a formula: multiplying by the bank count is only
right for a buffer interleaved across every bank, and most are not — on a local resnet50
capture the operation holding the largest L1 footprint uses 56 of 64 banks, so multiplying overstates
it by 14%, and 16-bank operations in the same report by 4x. How many banks a buffer
actually occupies lives in page-level data that no tool exposes. The report carries no
DRAM capacity at all, which is likewise stated rather than left as a gap.
What operation_detail does give you is each tensor’s memory_config, whose
memory_layout says whether a tensor is interleaved or sharded, and whose shard_spec
carries the grid and shape when it is — so you can tell why a per-bank figure does not
scale by the device’s bank count, rather than only being told that it doesn’t.
A tensor’s size is a whole-tensor byte count — where the report carries that column.
Where it does not, which is the common case, the report’s own query substitutes the
per-bank allocation figure, and the response labels it bytes_per_bank accordingly. So
read tensor_size_unit rather than assuming the two figures are in different units.
operation_detail’s circular_buffers are bytes on each core, read from the captured
graph. An L1 bank is one core’s L1, so their size_unit is bytes_per_bank, the unit
l1_peak sums circular buffers in and the unit of the L1 figures under allocations. allocations
cannot show circular buffers, though: it reads the post-operation buffers table, which a
circular buffer never enters. Each entry under circular_buffers is one device operation
that allocated circular buffers, as the app’s per-core view lists them, and each row gives
a buffer’s address, its size on each core, its core_range_set and how many distinct
cores that covers. A core_range_set past 200 characters is cut, and
core_range_set_full_length gives its length; core_count still counts all of it. A mesh records every circular buffer once per device; those copies are
one row, and device_count says how many devices allocated it. Older mesh captures give
each device its own device operation, which the app lists once per device; here those are
one entry with its own device_count, so circular_buffer_count counts buffers rather than
device copies. A device operation launched twice on the same device stays two entries. A
globally_allocated buffer is a view onto an L1 tensor that is already counted, so it adds
no L1. max_bytes_per_core is the largest circular-buffer total on any one core, and a
buffer whose core range is empty is counted in unattributed_bytes instead. At most 100
rows are listed, or 25 when include is set, as for perf_rows; the counts cover them all.
A captured graph records a program’s circular buffers only at its first launch. A
relaunch from the program cache frees circular buffers and allocates none in the graph, so
on a warm capture most device operations show none. Those launches are counted, not
dropped: unrecorded_launch_count says how many, unrecorded_device_operations names them
with each name’s launch and device counts, and caveat says their buffers are unknown
rather than zero. A device operation with no circular buffers at all looks the same in the
graph, so these are most likely, not certainly, cached relaunches.
A capture written before tt-metal #53041 leaves a failed launch’s scope open. Its
circular buffers are still listed under that device operation, as the app’s view lists
them: the graph is read in the view’s order, which moves each launch’s free-all after its
buffers. That order keeps one snapshot per device operation, so two shapes still leave
buffers out of the listing: a graph cut off right after a launch’s buffers, with no free-all
to follow, and a launch whose buffers another node splits into two runs, where the second
run’s snapshot replaces the first, as it does in the app. Those are counted in
unlisted_circular_buffer_count rather than listed, once per device section on an older
mesh capture, and caveat says circular_buffer_count and device_operation_count leave
them out. For an operation with an error, the failed launch is named in
error.failed_device_operations.
The field is absent when the report has no captured graph for the operation, and withheld
when its graph cannot be read for the rank asked for, is past the size a tool will read, or
does not parse; caveat then says which. For an operation with an error, the same graph
names error.failed_device_operations, which is withheld alike while the rest of the error
still answers. A graph that launched no device operation, with no
unrecorded_launch_count and no unlisted_circular_buffer_count, is a real zero.
memory_profile reports a floor, not the peak. It reads the buffers table, which
records tensor allocations only. A circular buffer never appears there, and neither does a
tensor allocated and freed inside a single operation. Across the local captures circular
buffers are 52–100% of the real L1 peak wherever a peak has any, and intra-op tensors a
further 24–38%; on resnet50_may08_1841 the tool reports 487,424 — a figure eighteen
operations share — where replaying the captured graph gives 1,159,840 at operation 29,
2.4x out and not the same operations. Two of the local reports hold almost no L1 tensors at
all, so the tool reports a figure near zero for a model that is saturating L1. Neither the
true peak nor the operation holding it can be derived from it: for an out-of-memory
question, read errors and l1_peak.
memory_profile groups by operation because the report records what was live at each
operation rather than what that operation allocated. A per-operation sum is therefore the
footprint at that point in the run, and the largest of them is that type’s largest
footprint. It is a maximum and never a sum across the
run: buffers persist across operations, so adding them would count one allocation once per
operation it stayed live through. Because resident memory barely moves, that figure is
usually shared by many operations, and operations_at_peak says how many — the difference between
a single operation you can go and fix and a plateau across the whole run.
A multi-host report is read one rank at a time, and refuses rather than guess. Operation ids restart at 1 per rank, so
reading every rank at once would collide operations that merely share an id. The database
tools default to rank 0, take a rank argument, and name the rank they read in every
response along with a caveat saying the figures describe that rank rather than the job. A
rank the report has no rows for is refused rather than answered empty, since “no
operations at rank 9” otherwise reads as a fact about the run. And where a report carries
rank on its operations but not on the table holding the figures — a schema mix that
would silently union every rank and attribute the total to whichever rank you asked for —
the call is refused instead, because a caveat naming a rank the numbers do not describe is
worse than no answer. Where that table feeds only an optional field, the field is withheld
instead, with a caveat saying why, and the rest answers: operation_detail’s error,
circular buffers and DRAM fallback, and, for devices, l1_pressure and
memory_profile’s device block, which are null.
A total on a partitioned run carries a caveat. When a report spans more than one
sub-device, operations on different sub-devices can run concurrently, so summed device
time, total percentages and op-to-op gaps overstate elapsed time. Responses that include a
total say so, and name the sub-devices involved — top_ops, op_breakdown and
diff_reports alike, since a share or a delta of an unsound total is unsound the same way,
and operation_detail wherever it returns report_device_time_total, since its shares
divide by that total.
Totals are only reported for metrics a sum means something for: device time, op-to-op gap and total percentage. DRAM bandwidth, FLOPS and core count are per-operation figures, and adding them across a report would produce a number that looks authoritative and is not.
A null bound does not mean an operation is fine. Every row from top_ops and every
perf_rows entry from operation_detail carries bound_analysis, which says which roofline
model tt-perf-report ran: full (matmuls) derives DRAM, FLOPs and the bound, or leaves the
bound null when the trace lacked the inputs the model needs; flops_only (convolutions)
derives FLOPs alone, so DRAM and the bound are never set; none (everything else) derives
nothing, so a blank there says nothing about whether the operation is a bottleneck. A SLOW
bound means the full model ran and neither DRAM nor FLOPs reached 65% of peak. Rows also carry
op_category, which tt-perf-report 1.4.0 sets to Compute, CCL, DM, TM, Host or
Other and leaves null on signposts. The list can grow in later releases (CCL was added
recently), so treat an unfamiliar value as a category, not an error. Other is time no
category explains.
include adds the fields that say why an operation took its time. A top_ops row and
an operation_detail perf_rows entry name the operation and its metric unless asked for
more. Asked for more, rows are capped at 25 rather than 100: with every group a row runs to
about 3 KB, and 100 of them would pass a client’s limit on one tool answer. include takes
any of these groups:
config: math fidelity (LoFi, HiFi2, HiFi3 or HiFi4, from the CSV’sMATH FIDELITY, and null for an operation without one), input and output datatypes, input 0’s memory, DRAM sharding, and the matmul block sizes. How the operation was set up.utilisation: DRAM bandwidth and FLOPs, each also as a percentage of peak, and the perf model’s ideal, compute and bandwidth times and FPU utilisation. How close the operation came to what the hardware can do; DRAM and FLOPs followbound_analysisabove.noc: NoC, multicast NoC, DRAM and Ethernet bandwidth utilisation, and the NPE congestion impact.riscs: kernel time per RISC, and how long the compute kernel waited at the front and back of its circular buffers. Which stage limits the operation: the readers and writers on BRISC and NCRISC, or unpack, math and pack on the three TRISCs.cores: available cores, the time from the first core’s kernel start to the last’s, and the per-core minimum, maximum and average kernel time. Launch skew and imbalance.dispatch: the op-to-op gap, dispatch’s command and GO-send wait times, and the metal trace ids. Why the gap before an operation is large.cache: the program hash, whether the program cache held it, and the global call count.kernels: the compute and data-movement kernel sources and their hashes, as lists.advice: tt-perf-report’s advice for the operation, which today covers matmuls.flags: the performance view’s heuristic flags for the operation —dram_bound,low_utilisation,underutilised_coresandrecompute_candidate— each with the figures it fired on. See below. It adds no CSV columns, soinclude: ["flags"]alone brings noincluded,empty_in_reportordetail_note.
Times in these groups are microseconds, like device_time, although the CSV records most
of them in nanoseconds. They come from the same merged row as the rest of it: for an
operation that ran on several devices, the slowest device’s row, except for an
all-gather, reduce-scatter or all-reduce, where tt-perf-report averages the device time
and keeps the first device’s row for everything else, so kernel_us there is one
device’s time rather than the average. The web application’s rows carry the fields
tt-perf-report generates; the ones read straight from the CSV — the math fidelity, the
per-core spread and launch skew, the circular-buffer waits, the dispatch waits, the
kernels, the perf model’s compute and bandwidth times and FPU utilisation, and the noc
group — reach only these tools.
tt-metal’s performance model leaves its ideal times at 1 ns for an operation it does not model, so a 1 ns ideal time reads as null rather than as a measured one, and so does an FPU utilisation derived from it. Read against the operation’s real duration, that placeholder would claim a million-fold gap.
Many of these fields are empty unless the capture asked for them. A field that no row of
the whole report carries, whatever range was read, is left out of the rows and named in
empty_in_report instead, with what would fill it: --collect-noc-traces for the noc
group, --profile-dispatch-cores for the dispatch wait times, a captured metal trace for
the trace ids, and a performance model that computes them for the ideal times. A field
whose column the report does not have at all is named as such, since no capture option
fills it on the tt-metal that wrote the report. A field empty on some rows only is usually
one those operations do not have, such as a block size on an operation that is not a
matmul; the noc fields are filled only for the operations tt-metal matched to its NoC
traces.
flags are the performance view’s heuristic flags, with their evidence. Asked for
them, every row carries flags, a list in the order the performance view shows them and
empty when none applies, and the total_percent that gates the first three of them. The
answer’s flag_basis says what they were read against. Each entry names its flag and
the figures behind it, in place of the view’s display text, and repeats no field the row
already carries:
dram_bound: DRAM bandwidth is the likely bottleneck.reasonisbound_dramwhen tt-perf-report’sboundisDRAM, orslow_dram_above_flopswhen it isSLOWand DRAM is a larger percentage of its peak than FLOPs are;dram_percentandflops_percentare the two percentages.low_utilisation:core_utilisation_percentis below 30%, cut rather than rounded to three places so a figure under the threshold never reads as at it. It is the perf model’s ideal time over the device time, scaled by the cores the operation could have used over the cores it did.roofline_modelledis false when tt-perf-report has no roofline model for the operation (bound_analysisnone), so the ideal time it is compared against is less reliable, and null when the report does not say.underutilised_cores: the row’scoresare fewer than a quarter ofcore_capacity, which is the row’s own available cores (core_capacity_fromavailable_cores) or, for a row without them, the device’s (max_cores).recompute_candidate: a repeat call of a program that missed the program cache — a repeat miss, asprogram_cachecounts one, thoughprogram_cachealso counts the ones on rows no flag applies to, below. Thecachegroup’sprogram_hashnames the program.
The first three apply only to an operation whose total_percent is at least 0.5, so they
move with a signpost range, over which tt-perf-report recomputes total_percent, as the
view’s move with its signpost filter. None applies to a signpost or a host operation.
Whether a row is its program’s first call is decided over the whole report, as
program_cache decides it, even when a range is read: the view decides it over the rows it
shows, so there a program first called before the window reads as first called inside it.
flag_basis.flagged_rows counts each flag over every row the answer read — the whole
report or the signpost range for top_ops, every row of the operation for
operation_detail — so a flag on a row past the cap is not missed. When every repeat call
in the report that recorded a cache result missed, flag_basis.caveat carries the caveat
program_cache gives: that is what a run with the program cache disabled records, so
check whether it was before reading each recompute candidate as a program built again.
flag_basis.note repeats the two ways the flags read the report differently from the
view, below.
flag_basis.max_cores is the device’s core count, read where the web application reads it,
through the same reader as /api/performance/device-log/meta: Max Compute Cores on the
device log’s first line (max_cores_source device_log); else the default for the device
log’s architecture, or Wormhole’s when there is no device log or it names no architecture
the application knows (architecture_default); or, where it is more, the most cores any row
of the report used (report_rows). A count of 0 falls back to Wormhole’s
(fallback_default). The rows that fallback reads are the whole report’s even for a ranged
read, since a window does not change the device, where the view reads the rows it shows.
The flags read the report differently from the view in two deliberate ways. Over a
signpost range, first calls and the report_rows fallback read the whole report, as above.
And the view reads tt-metal’s 1 ns ideal-time placeholder, described above, as a modelled
time, which against an operation’s real duration gives a utilisation near zero, so it flags
low utilisation on operations the model never modelled
(#2162): across ten local and
demo captures, 166 of its 239 low-utilisation flags. Here a 1 ns ideal time never flags
low_utilisation. Every other flag those captures carry unranged is the view’s.
The view’s other two flags are not in the list, because other tools answer them in more
detail: an allocation failure is errors’ (and operation_detail’s error), and a likely
DRAM fallback is dram_fallbacks’ (and operation_detail’s dram_fallback). Recompute
candidates are also counted per op code by program_cache.
program_cache says whether programs were built again. A row is its program’s first
call when no earlier row in the report carries its program hash, and a repeat call
otherwise, which is how the performance view counts its cache-hit percentage. A first call
builds its program, so it misses the cache by design. A repeat call should hit it: a repeat
miss built a program the run had already built, and the performance view flags it as a
recompute candidate. The answer counts calls, distinct programs, repeat calls, and repeat
hits and misses, with the most repeat misses first, per raw op code: one entry holds every
shape of a matmul, where tt-perf-report’s op_code would split it by shape. It reads the
whole report and takes no signpost range, because a program first called before a window
would read as a first call inside it. A repeat call that recorded no cache result is
neither a hit nor a miss: repeat_unrecorded counts them, the hit percentage is out of the
rest, and a caveat says so. When every repeat call that recorded a result missed, the
answer says that is what a run with the program cache disabled records, so check before
reading each miss as a program built again. A capture without the PROGRAM HASH and PROGRAM CACHE HIT
columns cannot answer, and load_report says so. Each op code’s first repeat miss is
named by its CSV row in first_repeat_miss_perf_id, and when the handle also holds a
profiler report, by first_repeat_miss_operation_id too, for operation_provenance to
turn into a call site.
op_breakdown says where the time went, grouped as the performance view’s stacked table
groups it, which is tt-perf-report’s stacked report. group_by: op, the default, takes the
op code’s first word, as tt-perf-report --group-by op does, so every shape of a matmul is
one entry and every host op falls under (torch); group_by: op_category groups by
category. The table’s third grouping, by input 0’s memory, is not offered. Signposts are
left out, and a host op counts with no device time; the application’s stacked table hides
host ops by default, so its default view has no (torch) row. Each group carries its count,
its summed device time and op-to-op gap with each one’s share of the total — to three
significant figures, as operation_detail’s shares are, so a sliver reads as one — and
total_percent, tt-perf-report’s Total % summed: the share of device time and op-to-op gap
together. A row’s op-to-op gap is the time before it, so a group’s gap is the dispatch time
spent waiting to launch its operations. Groups are ordered by by, device time unless asked
otherwise, and those past the limit are summed into rest, so the shares returned still add
up to the whole. Over a range, the shares are of the window, whose first operation has no
op-to-op gap. On every local performance capture, the groups, their counts, their device
time and their shares match the stacked report tt-perf-report generates from the same CSV.
That report itself is not generated: the answer is computed from the rows, which also gives
it the op-to-op gap the stacked report leaves out.
errors says where a run stopped and why an allocation failed. It lists the rows of the
report’s errors table, as the web application shows them. An incomplete_operation error
is an operation that started and never completed, so the run most likely crashed inside it.
An error the report could not place on an operation of that id and name has attached
false; the application shows those at report level. failed_device_operations names the
device operations in the operation’s captured graph whose launch did not complete, up to 20,
and failed_device_operation_count counts them all. When the message is one of tt-metal’s
four allocation failures — out of memory, out of memory after considering dependencies,
circular buffers beyond L1, circular buffers clashing with an L1 buffer —
allocation_failure carries its figures and diagnosis says why it did not fit, with the
reasons Allocation failures describes. Only a message’s first
32,768 characters are read for one, several times the longest real message, and its
core_range is cut like a message. requested_bytes is the whole request across
num_banks; the other bank figures are per bank. An unattached error’s operation_id can
name an unrelated operation, so it is not one to open with app_link_template. A report
that predates the errors table says so in its caveat rather than reporting no errors.
dram_fallbacks is inferred. tt-metal records nothing when an operation falls back
from L1 to DRAM. An operation counts when its memory config arguments all ask for L1 while
every output it allocated itself is in DRAM, or when it runs within three operations of a
same-named one that failed to allocate L1 and its outputs are in DRAM. An output that may
be a view of a DRAM input is left out. operation_detail carries the same dram_fallback
for its operation.
l1_peak is the figure for an out-of-memory question. It replays every operation’s
captured graph against the post-operation buffers snapshot, so it counts what
memory_profile cannot: circular buffers and tensors allocated and freed inside an
operation. The peak is in bytes per bank, split into circular buffers, intermediate,
persistent and stale tensors. Stale tensors are still allocated after their last real
consumer, the only part freeing earlier would help. top_operations keeps one entry per
distinct composition, with also_at naming the operations that repeat it, since a model
that runs one layer stack several times reaches the same figure at each. also_at names at
most 20 of them, and also_at_count counts them all: one local capture repeats a
composition 9,679 times. precision is upper-bound wherever more than one resident is
summed, since a capture does not say which cores each occupies. When the replay exceeds the
device’s L1 its figures are withheld and reason says why, as the web application’s L1 peak
panel does. The first call on a handle reads every captured graph, which can take seconds on
a large capture; later calls reuse it.
zone_timings carries a caveat of its own: cycles are summed across every core that ran
the zone, so they measure occupancy rather than wall-clock duration. A core is counted per
device — the captures we test against span 8 and 32 PCIe slots, and coordinates alone
repeat on each — so a zone can report thousands of cores on a multi-device run. Durations
need the device log’s type column to pair zone starts with ends; a capture without it
reports occurrence counts only, and a capture that stopped mid-zone reports how many starts
and ends failed to pair so a partial total does not read as a complete one.
core_spread reads each operation on one device only, never pooling devices, since each
runs its own clock. A performance row’s global call count is the device log’s run host ID
and its device is the log’s PCIe slot. That pair does not single out one execution: a
traced operation’s replays share it, and call counts repeat from one capture to the next.
So a row’s zones are only those inside its own DEVICE FW START CYCLE to
DEVICE FW END CYCLE, which keeps each replay’s figures its own. An operation with zones
under its call count and device but none inside its firmware cycles has in_log false.
When no row of that call count and device has zones inside its own cycles, the caveat says
the log belongs to another execution — usually a device log beside a different run’s
performance CSV. When other replays do, it names a profiler buffer that filled partway
through the trace as the likelier cause, since that drops later replays’ zones. A row without firmware cycles takes every zone of its call
count and device, and when other rows share them its figures pool theirs, which a caveat
says. On a multi-device report a row is the slowest device’s, except an all-gather,
reduce-scatter or all-reduce, where it is the first device’s but its device_time is the
mean across devices, so its core spans need not match it; each operation names its
device. A core’s span runs from its first kernel start to its last kernel end across its
RISCs, which is how tt-metal measures DEVICE KERNEL DURATION PER CORE, and the
performance CSV’s own minimum, maximum and average are returned beside it where the capture
has them. imbalance is the maximum over the median. slowest_cores name cores by the
device log’s NOC coordinates, not logical worker coordinates: Wormhole’s worker (0,0)
logs as (1,1). An operation with no kernel zones for its call count and device has
in_log false and no figures rather than zeros: the log can cover different runs from the
CSV, or stop before them. When fewer cores recorded zones than the row reports, the spread
covers only the cores that did. Dropped markers cause that, and tt-metal reports a full
profiler buffer in its host log only, never in the device log. When more cores recorded
zones than the row reports, as a DRAM-sharded matmul’s helper cores do, the median and
imbalance mix cores with different roles, while the maximum and slowest_cores still
name the cores that set the time; a caveat says so. Unpaired zones are counted per call
count and device, not per replay, so an unpaired zone in one replay is reported on every
replay of that operation. A kernel row whose slot, core or cycle count is not a number is
left out and counted in a caveat rather than failing the call. The first call on a handle
reads the whole device log once for every operation in the report, and later calls answer
from that read, with the clock it was read at.
A performance row and a profiler operation are linked by order, not by a shared id.
Neither report records the other’s id, so when a handle is loaded with both, the tools
align the device operations each profiler operation launched (from its captured graph)
against the performance rows, in order — the same match the application uses to link a
memory report to a performance report. top_ops then gives each row an operation_id,
and operation_detail lists an operation’s perf_rows with device time in microseconds;
its own duration is host seconds. Each row’s device_time_share_percent is its percent
of report_device_time_total, the device time of every row that is not a host op or a
signpost, whether or not it linked to an operation. That is not tt-perf-report’s
total_percent, which also counts the op-to-op gap, so on an untraced capture it mostly
measures dispatch gaps rather than device time. Nor is report_device_time_total quite
top_ops’s device_time_total, which sums every row’s device time; the two agree on
reports tt-perf-report writes, since it leaves host ops and signposts without one. At the
top level, device_time, device_time_share_percent and slowest_perf_id cover all of
the operation’s rows, including any past the cap on perf_rows, so the slowest row may be
one perf_rows does not list. A row whose device time tt-perf-report left blank has a
null share and is left out of the total; when no row has a device time, those three keys
are absent.
A device operation whose launch threw — an allocation failure, for one — never reached the
device and has no performance row, so the match leaves it out. Left in, it would put a name in
the order that no row answers, and a report with a failure would usually not link at all. Newer captures mark its scope aborted; older ones leave it
unclosed. The operation keeps any earlier device operations that did run.
Every response that carries the link says how it went in operation_link:
linkednames the rank the ids belong to — the CSV records no rank, and rank 0 is what the application links against — andmatched_rowssays how many rows linked.operation_detailread at any other rank reportsunavailableand lists noperf_rows, because ids restart per rank.unlinkedmeans the two sequences do not line up — usually two different runs, or a performance capture that stopped early — and everyoperation_idis then null rather than guessed.unavailablemeans the match was never tried: one half is missing or cannot be read, the profiler report records no captured graph, one of its captured graphs is not readable, or a multi-host report carries norankon the graph or device tables. A link that skipped an unreadable graph could still align and pair rows with the wrong operations, so none is attempted.
On a linked pair an operation_id is still null for a row with no profiler operation:
a host op, a signpost, or a row past the end of a profiler capture that stopped before
the performance one did. The match tolerates trailing rows, as the application’s does.
top_ops counts such rows in operation_link.unlinked_by_reason — signpost,
host_op and past_profiler_capture — totals them in unlinked_row_count, and names
the first row past the profiler capture in first_past_profiler_capture_id. Read the
counts there rather than counting null ids among the ranked rows: the ranking leaves out
any row with no value for its metric, which is every signpost, so matched_rows and the
rows returned do not add up to the report.
A signpost range narrows the ranked rows and nothing else. The link is matched against the
whole report, since alignment is by order and a window would start it mid-run; a row’s id is
its CSV position in either read, so its operation_id is the one it has unranged. Its
operation_link then carries scope: whole_report, because every count in it, and
operation_detail’s report_device_time_total and shares, describe the whole report while
top_ops’s own op_count and totals describe the window.
The link is resolved once per handle, on the first top_ops, program_cache or
operation_detail that needs it, and reads every captured graph at the linked rank. On a handle with both reports,
the first operation_detail therefore also generates the performance report, which can
take seconds on a large capture; every later call reuses both. A link that failed on a read
that might pass next time — a locked database, a temp-file error in tt-perf-report — is not
kept, so the next call tries again. A handle keeps answering from the capture it first
read, so it never mixes two. A read that needs the CSV again after it was rewritten, or
after a newer ops_perf_results file appeared — a range not asked before, or one dropped
from the cache — is refused rather than lined up against operations linked from the old
capture; call load_report again for a fresh handle.
An operation id is not the end of the answer. memory_profile names the operations
holding its largest footprint, and operation_provenance turns such an id into the two things you need
to act on it: the arguments it was called with, and the innermost stack frame’s file,
line, function and source line.
top_ops names an operation too, but its id is a row of the performance CSV and does
not belong here (diff_reports returns no id at all: it groups by op code): the two ids are small integers in overlapping
ranges, so passing one where the other belongs answers about a different operation
rather than refusing. Pass a top_ops row’s operation_id instead, or search for the
name with find_operations. program_cache’s first_repeat_miss_perf_id is a CSV row in the
same way, and first_repeat_miss_operation_id is the one to pass. Frames are recorded innermost first, so the call site is the ttnn.<op>
call in the model code with no guessing about which frame is yours, and it is the same
frame the operation details panel shows for that operation.
Argument values are capped, with truncated and full_length set when one was cut, and
truncated_count saying how many of them were: truncation is the normal case rather than
the exception — 28% of the argument values across the local captures run past the cap,
and the longest is 177,717 characters, because a tensor repr or a Conv2dConfig lands in
one.
Two different absences are reported apart. A capture that records no trace for an operation says so. A capture that records a trace this parser cannot read — a native C++ backtrace, which 8 of 86 local captures write for every operation — says the call site is unknown rather than absent, because those are different facts and only one of them is about the operation.
operation_provenance takes a limit for the outward chain. The call site is reported
separately and never counted in it, so a small number is usually right: a real trace runs
to tens of frames, most of them the test harness that invoked the model.
find_operations takes called_from for the inverse question, which is the one that
follows: not “what about this operation” but “what about every operation from that
helper”. It matches anywhere in the stack, so a layer module finds the operations it
called through helpers rather than only those it called directly.
Limitations
Accuracy questions are not answerable. Both tensor-comparison tables exist in every local capture and are empty in every one, so there is nothing to build a PCC tool on.
Page-level memory questions — the per-bank, per-page detail behind the web
application’s memory plot — are not exposed, because buffer_pages runs to millions of
rows on an ordinary capture and needs a different shape than a tool response. Per-operation
L1 fullness, free-segment counts and the largest free block are derived from buffers and
devices instead (bounded per device to [address_at_first_l1_cb_buffer, address_at_first_l1_cb_buffer + l1_bank_size), capped at worker_l1_size, and reporting
the most constrained device — the one with the smallest largest free block — with
free-space figures marked as an upper bound because circular buffers and intra-op tensors
are absent) and exposed as l1_pressure on operation_detail and memory_profile. Its
figures are bytes per bank (size_unit), so a whole-tensor size in bytes is not
comparable to them. It is null, with a caveat saying why, for an operation with no
buffers rows, a report whose devices record no L1 geometry, or a report holding more
than one rank whose devices table carries no rank. A device_id with more than one
row is read as its last row, as the web application reads it, for l1_pressure and the
device block alike, and device.devices counts distinct ids.
Device profiler logs carry named zones only where a kernel was instrumented to emit them.
Most captures contain only the default firmware and kernel zones — BRISC-FW,
BRISC-KERNEL and the same pair for NCRISC and TRISC, plus ERISC where a capture
used ethernet cores — so on those reports zone_timings describes RISC-level phases rather
than named model operations. A capture whose kernels do emit named zones reports them
alongside the defaults.
The transport is a minimal JSON-RPC implementation rather than the official MCP SDK, which keeps the server free of additional dependencies while the tool set settles.