Component Performance Dashboard¶
A single self-contained HTML page with Overview / Frontend / Router / Engine / Session / Log analysis tabs, built offline from the artifacts an srt-slurm job already captures.
It answers component questions the aggregate benchmark numbers cannot: where TTFT went (admission queue vs. routing vs. prefill vs. KV transfer), whether KV cache or in-flight batch was the ceiling, how evenly the router spread load, and whether the frontend event loop was stalling.
Two pieces, vendored from the dynamo-benchmark-perf-dashboard repo:
| Layer | Path | Role |
|---|---|---|
| L2 ingest | src/ingest/ |
RAW capture -> five fixed intermediate schemas (a bundle) |
| L3 render | src/visualization/ |
bundle -> one self-contained .html |
Both are stdlib-only and are not part of the installed srtctl wheel. Run them
from a repo checkout.
Quick start — nothing to turn on¶
Every job builds its own dashboard. There is no knob and nothing to remember: post-processing writes, into the run's log dir:
perf_dashboard.html self-contained page (D3 inlined, no network needed)
perf_dashboard.json the page's DATA payload — same object the HTML embeds
perf_dashboard_bundle/ the intermediate schemas, re-renderable in seconds
perf_dashboard.json exists because the HTML is often unreadable where it lands — a
headless cluster, a CI log, an S3 prefix. It is the machine-readable form of
everything the page shows, so a run can be diffed against another, asserted on in a
test, or simply read without a browser. It is built before the S3 sync, so all three
artifacts ship with the rest of the log dir.
Driven by srtctl.analysis.perf_dashboard, which is best-effort: a rendering failure
is logged and never changes the outcome of a benchmark that already produced results.
Nothing else is required. One srtctl submission collects the data, post-processes it
and renders the page, in that order, inside the job.
What observability.enabled changes is which tabs the page carries, not whether
there is a page:
observability:
enabled: true # adds the server-side capture legs — see the table below
Without it the page is built from the client's own metrics export, the per-iteration
worker logs and the frontend log: Frontend / Router / Engine / Log-analysis.
Turning it on adds the scraped /metrics stream and the SPAN_CLOSED traces, and
with them the Overview tab. Either way there is a dashboard, because the question
it answers is asked after the run, when opting in is no longer possible.
Comparing two runs¶
python3 tools/dashboard_diff.py BASELINE/perf_dashboard.json COMPARED/perf_dashboard.json
Reports KPI deltas, tab-availability changes, per-panel movement ranked by magnitude, and population changes. Built for the ablation shape -- change one variable, run both, ask which signals responded -- which is also the honest test of whether a panel earns its place: a panel that does not move between a healthy run and a known-degraded one is not diagnostic.
It compares medians, falling back to peaks when both medians are zero (sparse counter rates sit at zero through their idle windows), and labels which statistic each row used. Without that fallback an 8x concurrency change reported 30 panels as unchanged when only 8 genuinely were.
It also prints a PROVENANCE section, which answers the question the rest of the report assumes: were these two runs actually comparable?
- Framework drift — the versions found inside the running workers, from
fingerprint_*.json. If baseline and compared ran differenttensorrt_llmbuilds, nothing else in the report means what it appears to. This is invisible otherwise: both runs simply work. - CONFIG DELTA — every differing key of the resolved
config.yaml, flattened to dotted paths. For an ablation this should name exactly one setting, and the section says outright whether the comparison isSINGLE-VARIABLEor how many settings differ. A confound becomes something you see rather than something you argue about.
Both degrade honestly: when either bundle lacks the provenance files the section says the changed variables are UNKNOWN rather than implying the runs matched.
Running a job against a modified checkout¶
If you are testing dashboard changes, the compute node runs whatever srtctl_root in
srtslurm.yaml points at -- not the tree you edited. Two things bite:
srtctl_rootis the only lever. Staging a second checkout andcd-ing into it does not select it. Pointsrtctl_rootat the new tree and confirm the job's ownuv syncline reportssrtctl==… (from file:///<your tree>).- The runtime state is gitignored, so a fresh checkout does not have it.
srtslurm.yaml,configs/nats-server,configs/etcd,configs/etcdctl,wheelhouse/dynamoandbin/uvall live outside git. Copy or symlink them from a working checkout, and do notrsync --deleteover the result -- it removes exactly those files, and the job then fails somewhere unrelated (a model path resolving against the wrong root, for instance).
If the job hit its wall clock¶
A SLURM TIMEOUT kills the job hard, so post-processing never runs and no dashboard is
written — even though the capture itself completed. This matters because the runs most
worth looking at are often the ones that ran out of time.
Nothing is lost. Every raw input survives the kill, and the bundle rebuilds from the run directory in under a minute:
cd /path/to/srt-slurm
python3 -m src.ingest.ingest --run-dir outputs/<job_id>/logs --out /tmp/<job_id>-bundle
python3 -m src.visualization.build_dynamo_bench_dash \
/tmp/<job_id>-bundle /tmp/<job_id>.html \
--frontend-log outputs/<job_id>/logs/<node>_frontend_0.out
Verified on job 2753007 (TIMEOUT at 20:12): the tachometer parquet, host_samples.jsonl,
dynamo-request-trace, all four fingerprint_*.json and resource_snapshot.json were
intact, and the bundle rebuilt in 53 s with aiperf=True traces=True metrics=True
request_trace=True.
Rebuild on a compute node, not the login node, for a full-length run. The login node's memory limit will OOM-kill a render whose
server_metrics_export.jsonlis a few hundred MB (observed at exit 137 on a 243-request run), and its/tmpis a 2 GB tmpfs.
Running it by hand¶
Against any past run — including one captured before this existed:
cd /path/to/srt-slurm
python3 -m src.ingest.ingest \
--run-dir outputs/<job_id>/logs \
--out /tmp/<job_id>-bundle \
--worker prefill=dep:4:2 --worker decode=tep:4:4
python3 -m src.visualization.build_dynamo_bench_dash \
/tmp/<job_id>-bundle /tmp/<job_id>.html \
--frontend-log outputs/<job_id>/logs/<node>_frontend_0.out
Open the .html in a browser. D3 is inlined by default, so the page works with no
network — copy it anywhere.
Capturing the inputs¶
The dashboard reads whatever the run happens to have. Four of the eight legs below do
not depend on observability.enabled at all; that knob is what adds the other four:
observability:
enabled: true
It expands (see ObservabilityConfig in src/srtctl/core/schema.py) into the legs
marked with it below. Without it the Metrics leg falls back to the client's own
export, and the page is built without the traces — so it keeps every time-series tab
and loses Overview.
| Leg | Recipe requirement | Artifact | Bundle output | Feeds |
|---|---|---|---|---|
| Metrics | observability.enabled (Tachometer), else the client's own export |
<log_dir>/tachometer/raw/scrape/*.parquet, else <log_dir>/agentic/*/…/server_metrics_export.json(l) |
server_metrics_export.jsonl |
every time-series panel |
| Request trace | observability.enabled |
<log_dir>/dynamo-request-trace |
request_trace.jsonl |
per-request card, per-session view, the waterfall's KV-transfer band |
| Per-iteration | print_iter_log: true in the engine config |
SPAN-free lines in <log_dir>/*_w*.out |
iter_bins.json |
batch composition, host/device step time |
| Traces | observability.enabled and an AIPerf benchmark |
SPAN_CLOSED lines in <log_dir>/*.out |
tempo_traces/<xid>.json |
Overview, routing outcome on the card |
| Client | an AIPerf benchmark at export level records (default) |
<log_dir>/agentic/*/aiperf_artifacts/ or artifacts/*/ |
profile_export.jsonl |
Overview, warmup filtering |
| Engine config | TRT-LLM backend | <log_dir>/trtllm_config_*.yaml |
copied verbatim | in-flight-batch ceilings |
| Frontend log | nothing — always written | <log_dir>/<node>_frontend_<i>.out |
(read directly) | Log analysis |
| Host telemetry | observability.enabled |
<log_dir>/host_samples.jsonl |
host_series.json |
host/process CPU, ctx switches, fd headroom |
Host telemetry — what the metrics stream cannot say¶
srtctl.analysis.host_sampler runs in-process during the benchmark window, on the
same observability.enabled opt-in, reading only /proc. It exists because every other leg describes what Dynamo
and TRT-LLM choose to publish, and three failure modes live below that line:
- Host CPU saturation / lock convoys. A frontend pinned at 100% of one core is indistinguishable, in every published metric, from an idle one — both report low queue depth and low in-flight. The discriminator is host CPU busy % together with involuntary context switches per thread: a convoy is a thread descheduled against its will, which no application-level counter expresses.
- File-descriptor exhaustion. An accept loop out of descriptors logs nothing;
connections are refused before the application sees them. Open fds against
RLIMIT_NOFILEis the only available warning, andfd_headroom_pctis reported even when comfortable — "the peak was 2% of the limit" is the answer that rules the hypothesis out. - A load generator that has become the bottleneck. Client 100% / server 0% is the whole diagnosis and is invisible from the server side by construction.
The sampler stores cumulative jiffie counters; the ingest derives percentages by differencing, so the capture cadence is never baked into a stored number. Per-process CPU is percent of one core, so >100 means genuinely more than one.
Scope limit, stated because it bounds every conclusion drawn from it: the sampler observes the node driving the sweep — the one hosting the frontend — and the Dynamo / AIPerf processes on it. It does not reach the worker nodes; that would cost an srun round-trip per sample, which is the expense the scraper's opt-in exists to avoid. Worker- side host health is not covered.
Which tabs a run can populate¶
A tab is rendered only when its input exists. An empty Router tab would read as "this run had no queueing"; an absent one reads as "this run was not instrumented", which is the true statement — so the tab is dropped.
| Benchmark type | Overview | Router / Engine / Frontend | Log analysis |
|---|---|---|---|
mooncake-router, router, trace-replay (AIPerf) |
yes | yes | yes |
sa-bench, sglang-bench |
no | yes | yes |
any, with observability.enabled: false |
no | no | yes |
Overview needs the client and trace legs joined on x_request_id. sa-bench runs
vLLM's benchmark_serving.py, which writes aggregate results_concurrency_*.json
only — there are no per-request records to join, so Overview is dropped. The
Log-analysis tab exists exactly for this case: it reconstructs per-request TTFT and a
shallow stage breakdown from the frontend log alone, and feeds the same renderer as
the span-sourced Overview panel.
--export-level summarysilently removes the Overview tab. AIPerf writesprofile_export.jsonlonly at export levelrecordsorraw.recordsis the default, so the stockbench.shinvocations are fine — but a recipe that passes--export-level summarykeepsprofile_export_aiperf.json(which the rollup reads) and drops the per-request export, and the join key goes with it. AIPerf is also what sets theX-Request-IDheader the Dynamo spans carry, which is what makes the client and trace legs joinable in the first place.
src/ingest/ingest.py — building the bundle¶
python3 -m src.ingest.ingest --run-dir <log_dir> --out <bundle> [flags]
Produces:
<bundle>/profile_export.jsonl client axis (schema 1)
<bundle>/server_metrics_export.jsonl metrics axis (schema 2)
<bundle>/tempo_traces/<xid>.json traces axis (schema 3)
<bundle>/request_trace.jsonl request-trace axis (schema 4)
<bundle>/iter_bins.json per-iteration axis (schema 5)
<bundle>/trtllm_config_*.yaml engine ceilings, copied verbatim
<bundle>/profile_export_aiperf.json client run summary: workload cache ceiling, validity
<bundle>/config.yaml the RESOLVED recipe — what was configured
<bundle>/fingerprint_*.json per-worker ground truth: framework versions, GPU, CUDA
<bundle>/resource_snapshot.json the allocation the numbers came from
<bundle>/benchmark_status.json exit code, error lines, AIPerf phase census
<bundle>/log_signals.json log-only signals: counts, timestamps, samples
<bundle>/run_lifecycle.json time-to-ready, readiness gap, terminal cause
<bundle>/host_series.json host + per-process CPU, RSS, ctx switches, fds
<bundle>/dashboard.yaml generated sidecar (header labels + topology)
| Flag | Default | Notes |
|---|---|---|
--client |
aiperf |
none to skip. The AIPerf export is already schema 1, so this is a passthrough |
--client-input |
agentic/*/aiperf_artifacts/… then artifacts/*/… |
both harness layouts are tried; AgentX nests one level deeper and shards by concurrency |
--traces |
spanlog |
none to skip |
--span-logs |
*.out |
srt-slurm's worker/frontend log naming |
--request-trace |
dynamo |
parses dynamo-request-trace; the only source of KV-transfer cost and session_id |
--iter-log |
trtllm |
parses print_iter_log lines from the worker logs; local->UTC offset is derived per run, not hardcoded |
| (automatic) | — | trtllm_config_*.yaml are copied from the run dir into the bundle, giving the Engine tab its real in-flight-batch ceilings instead of the --max-batch-* defaults |
| (automatic) | — | profile_export_aiperf.json — AIPerf's run summary. Carries theoretical_prefix_cache_hit (the ceiling the workload offered), error_summary / was_cancelled / branch_stats (run validity), and effective_concurrency. Its absence is a signal too: AIPerf writes it when a concurrency finishes, so a run killed by its wall clock has none |
| (automatic) | — | config.yaml, fingerprint_*.json, resource_snapshot.json — run provenance. Without these, two bundles can be compared for what moved but never for what changed |
| (automatic) | — | benchmark_status.json — why an empty dashboard is empty: exit code, error lines, and AIPerf's per-phase completed/cancelled census. A run can exit non-zero with no error-marker line at all, and the census is then the only account of what happened |
| (automatic) | — | log_signals.json — signals whose only evidence is a log line and which have no metric behind them: worker crash, Dynamo recompile storm, OOM, KV-index desync, KV-transfer timeout, NCCL error, engine stall, etcd disconnect, client cancel. Counts + timestamps + 2 sample lines each, bounded regardless of log size. Zero counts are kept — "no crashes" is a statement; absence is not |
| (automatic) | — | run_lifecycle.json — time-to-ready, the readiness gap (frontend accepting before the router can place), and the run's terminal cause. On run 2752632 that was 650 s of 28 held GPUs serving nothing, invisible on an x-axis starting at the first request |
| (automatic) | — | host_series.json — host CPU %, memory, per-process CPU / RSS / involuntary context switches / open fds, and fd headroom against RLIMIT_NOFILE. Requires the sampler (below); everything else in the bundle describes what Dynamo publishes, not the machine under it |
--worker |
— | repeatable ROLE=PARALLELISM:RANK:COUNT, e.g. prefill=dep:4:6 |
--jobs |
4 |
parallelism for the SPAN_CLOSED pre-grep |
--worker fills in dashboard.yaml's topology. It is worth passing: the renderer sums
rank x worker_count for the tok/s/GPU denominator, and falls back to 1 GPU with a
warning when it cannot work the count out.
The trace leg pre-greps
SPAN_CLOSEDlines out of the worker logs in parallel before parsing, becauseDYN_LOG=debuglogs are routinely multi-GB.
src/visualization/build_dynamo_bench_dash.py — rendering¶
python3 -m src.visualization.build_dynamo_bench_dash <bundle> <out.html> [flags]
| Flag | Default | Notes |
|---|---|---|
--frontend-log PATH |
— | adds the Log-analysis tab |
--d3 PATH |
vendored src/visualization/d3.v7.min.js |
inlined for a self-contained page |
--d3-cdn |
off | load D3 from the CDN instead (smaller file, needs network to view) |
--max-batch-prefill / --max-batch-decode |
128 / 256 |
in-flight-batch ceilings drawn on the Engine tab. Only used when the bundle has no trtllm_config_*.yaml — ingest copies those in automatically, and the real values win. On AgentX run 2739690 the true decode ceiling is 1, so the 256 default would misdraw that panel by 256x |
--gpus N |
from dashboard.yaml |
tok/s/GPU denominator |
--dump-json PATH |
— | also write the DATA payload as indented JSON (what the automatic build produces as perf_dashboard.json) |
--include-warmup |
off | by default only the profiling phase is kept |
The bundle argument is optional — with only --frontend-log, you get a
Log-analysis-only page for a run that had no observability at all:
python3 -m src.visualization.build_dynamo_bench_dash /tmp/out.html \
--frontend-log outputs/<job_id>/logs/<node>_frontend_0.out
Same-run enforcement¶
Passing a --frontend-log from a different run than the bundle fails the build,
naming both runs and the x_request_id overlap. It is not a warning: the result would
be a page whose header and Overview describe one workload while the Log-analysis tab
describes another, and a build-time warning is invisible to whoever opens the HTML.
Where the artifacts do correspond, that same pivot transfers the bundle's warmup/phase filter onto the log source.
The three dashboard entities¶
Everything the page draws is one of exactly three things. Nothing is bespoke to a run.
1. Time-series panels — src/visualization/panels.py¶
77 panels declared as data rows, evaluated by one generic function. Adding a signal means adding a dict; there is no per-panel rendering code, which is what stops a panel acquiring behaviour another panel lacks. Four kinds cover every catalogued signal:
| kind | arithmetic |
|---|---|
gauge |
the sample as-is |
counter_rate |
successive differences / dt. A backwards step means the process restarted and is dropped, not drawn as a negative spike |
hist_mean |
Δ_sum / Δ_count — the interval mean. The cumulative form flattens over a long run and hides late degradation |
ratio |
a / (a+b) on interval deltas, for genuine partner counters only |
Two more panel kinds are computed rather than scraped (kind: "derived"), and are
emitted in the same shape so the same renderer draws them: in-flight imbalance across
ranks/workers, and batch composition.
A panel whose series never moves is stated in words, not drawn. A flat line reads as a broken chart; "constant 0 for the whole run" is the finding.
2. Per-request card — DATA.rt.requests[<x_request_id>]¶
Four bands that sum to total_ms by construction: admission+routing, prefill compute,
KV transfer, steady decode. Plus what the request was (isl, osl, cached_tokens,
kv_hit_rate, turn_index, prefix_reuse_ratio) and where it went
(routing.worker_id, .dp_rank, .overlap_blocks, .router_ms, .admission_ms).
routing is an outcome, not a rationale. The router's cost comparison lives on a
free-text selector line carrying no request_id, so "which worker, with how much
overlap" is joinable per request while "why it beat the alternatives" is not.
routing.belief_error compares the router's overlap_blocks × block_size against the
engine's reported cached_tokens. They are the same quantity, so a divergence means
the router is routing on a prefix the engine does not hold.
3. Per-session view — DATA.rt.sessions¶
One row per session, identical columns for every session and every run: turns, span/busy/idle, per-turn TTFT / KV-hit / prefix-reuse series, and which decode workers and prefill ranks it touched.
Idle is broken out because a session can spend most of its wall-clock waiting on the harness rather than the server; a session-level latency figure that absorbs that is measuring think time.
Known limitations¶
- Interpretive captions on the older panels were developed on an
admission/queue-bound reference run. The numbers are data-driven, but the framing
reads best on queue-bound runs. The declarative panels in
panels.pydo not have this problem — their captions are fixed prose assembled fromwhy/source/caveatand never interpolate a run's values. - Router rationale is not recoverable. The selector line carrying logit, overlap
credit and prefill load scale has no
request_id, so it cannot be joined per request. Only the outcome is shown. iter_bins.jsonis rank 0 only, so it cannot show TP/EP-rank divergence; anditerrestarts per engine lifecycle, so it is not a usable x-axis.- Engine-side
dp_rankis a replicated broadcast. Splitting an engine metric by rank renders a flat family of lines that reads as "balanced" on an imbalanced run. Per-rank truth is frontend-side only; a test enforces that notrtllm_*panel splits bydp_rank. - Some queue gauges read a constant 0 on a run that never queued. They are still specified: queue depth is the strongest single TTFT predictor when it is not zero.
- The KV-routing panels can describe a small slice of the traffic. Session affinity
pins a request to the worker already holding its conversation, and a pinned request
never reaches the KV scorer — on run 2751593 that was 248 of 274 decisions (90.5%).
The router tab states its own coverage from
ro.coverage, but only when the frontend-log leg is present; without it, no decision count exists and no coverage claim is made. meta.n = 0does not mean the run served nothing. It counts profiling-phase requests, and a run that hits its wall clock during warmup has none — reference run 2750618 has 118 client records, allbenchmark_phase: warmup. Scrape-derived KPIs (tok_cache,kv_hit_true) are still valid on such a run; the per-request and per-session cards are the parts that needmeta.n > 0.meta.phase_filterstates which case you are looking at.- Worker fingerprints do not cover the frontend. They are written per worker, so a
frontend-only setting such as
DYN_TOKENIZER_CACHEappears in no fingerprint. Its record ismeta.provenance.config, taken from the resolvedconfig.yaml— a configured value rather than an observation from inside the running process.
Provenance and re-syncing¶
Vendored from dynamo-benchmark-perf-dashboard at commit
22f49fea243e43403690b38e70a8d4092dec4cc8. Only what the component dashboard needs was
copied; the upstream panel dashboard (dashboard.py, template/), the framework
adapters (src/common/, src/trtllm/, src/vllm/), the capture layer (src/capture/
— srt-slurm's Tachometer capture already implements that contract),
and two unreachable processors (agentperf client, live tempo trace scrape) were
deliberately left behind.
Deltas applied on top of upstream, all of them layout/wiring:
ingest.pymoved undersrc/ingest/, the renderer undersrc/visualization/.- Client default glob
artifacts/*/profile_export.jsonl; span-log default*.out. - D3 inlined from the vendored sibling by default (
--d3-cdnopts out). agentperf/temporegistry entries dropped, withget_processornaming the valid options when one is requested.
Both directories are excluded from ruff and ty in pyproject.toml so the files stay
byte-comparable with upstream; reformatting them would rewrite ~100 lines of deliberately
dense style and destroy the diff. Comments inside them that cite dashboard.py,
render_fast.sh or src/common/* are upstream provenance — those files are not here,
but the reasoning they record still applies.
Coverage lives in tests/test_component_dashboard.py, which pins the seam: the
artifact layout srt-slurm writes must be what the vendored defaults look for.