Skip to content

Visualize dstack metrics as sparklines - #4086

Open
peterschmidt85 wants to merge 6 commits into
masterfrom
worktree-metrics-sparklines
Open

Visualize dstack metrics as sparklines#4086
peterschmidt85 wants to merge 6 commits into
masterfrom
worktree-metrics-sparklines

Conversation

@peterschmidt85

@peterschmidt85 peterschmidt85 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Before

dstack metrics printed a line of raw numbers per GPU, repeated on every poll under --watch:

gpu=0 mem=70.65GB/80GB util=0%
gpu=1 mem=70.65GB/80GB util=0%
... (six more)

Eight GPUs meant eight near-identical lines, and none of them showed whether a number was steady, climbing, or a momentary spike. The shape of a run was only visible by sitting and watching the command.

After

        UTILIZATION                                          MEMORY
cpu     ▂▂▃▂▂▂▃▂▂▂▂▃▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂ 34% of 208             ▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅ 589GB/1480GB

gpu=0   ▆▆▇▆▆▆▇▆▁▁▁▆▆▇▆▆▆▇▆▆▆▇▆▆▆▇▆▆▆ 88%                    ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 74GB/80GB
gpu=1   ▆▇▆▆▆▇▆▆▁▁▁▆▇▆▆▆▇▆▆▆▇▆▆▆▇▆▆▆▇ 86%                    ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 74GB/80GB

        4 Aug 10:15 ───────────────── now                    4 Aug 10:15 ───────────────── now

Each series is drawn over the metrics the server retains — about an hour for a running job.

Design notes

Height is the peak, colour is the mean. A cell covers a few minutes of samples. Using the mean for height would let a card that touched 100% for ten seconds disappear entirely; using the peak alone can't distinguish that card from one that held 100% for the whole bucket. Both are drawn, so both questions are answerable.

Colour is never the only carrier. The latest value is printed beside every sparkline, and the fixed 0–100% scale means glyph height always means the same thing as the number next to it — no autoscaling, so a row at 19% of capacity is not drawn nearly full. The number itself is left unstyled: colouring it just repeats what the sparkline already says.

Glyphs stop at , not . A full block fills its cell to the top edge, so a column of them in consecutive rows fuses into a single mass and the rows stop reading as separate series.

Device rows come from the offer rather than from the metrics, so the shape of a run is visible before any samples arrive. The sparklines take whatever width the terminal has (10–80 cells, clamped), and fall back to ASCII glyphs where the console encoding can't carry block characters.

Behaviour change

Like dstack logs, this now shows a single job: --replica and --job select one, both defaulting to 0. Previously every job of a service was printed at once, so comparing replicas now means one invocation each.

Why api/server/_metrics.py changes too

The metrics endpoint takes a limit on how many samples to return, declared as limit: int = 1 rather than Optional[int]. The Python client had no limit parameter at all, so every request it made fell through to that default of 1.

That was correct until now — the old output only ever printed the latest reading, so one sample was exactly what it needed. Sparklines need the series, so the client has to start asking for it. Without this the command still renders, but on a single sample: one glyph, however long the run has been up, which looks plausible rather than broken.

The client now passes limit (plus after and before, which the endpoint already accepts), and the CLI requests 1000 samples — the same number the UI's metrics page asks for. That is a sample count rather than a time window, so it outruns the hour of history a running job retains and a young run is never under-filled.

Backward compatibility

Output format changes, and only one job is printed. Anything parsing dstack metrics output will break — it was a NAME STATUS CPU MEMORY GPU table and is now charts. Comparing replicas means one invocation per replica. --json support for scripted use will follow in a separate PR.

No CLI flags removed. The command took only -w/--watch; --replica and --job are additive and default to 0.

Older servers are fine. limit has been on the endpoint since 0.18.44, and unknown query params are ignored rather than rejected, so the worst case against an older server is a single sample rather than an error.

MetricsAPIClient.get_job_metrics stays source-compatible. after, before and limit are appended after the existing parameters and default to None, and are left out of the request when unset — an existing call sends the same request it sent before.

🤖 Generated with Claude Code

@peterschmidt85
peterschmidt85 force-pushed the worktree-metrics-sparklines branch from 3be1e89 to 3b83f2e Compare August 4, 2026 10:12
Andrey Cheptsov and others added 6 commits August 4, 2026 15:58
`dstack metrics` printed one line of raw numbers per GPU, repeated per
poll under `--watch`. Eight GPUs meant eight near-identical lines, and
nothing about them showed whether a number was steady, climbing or a
spike — the shape of a run was only visible by watching the command.

Draw each series as a sparkline instead, over the metrics the server
retains (~1h for a running job). Glyph height is the bucket's peak so a
brief 100% cannot hide, colour is its mean so a card that *held* 100%
reads differently from one that *touched* it, and the latest value is
printed beside every sparkline — colour is never the only carrier. A
labelled rule under the table gives the span, ending in `now` while the
job is running and an absolute local time once it is not.

Like `dstack logs`, this shows a single job: `--replica` and `--job`
select one, both defaulting to 0. Previously every job of a service was
printed at once, so comparing replicas now means one invocation each.

Also send `limit` explicitly from the API client. The endpoint declares
it as `limit: int = 1` rather than Optional, so omitting it silently
capped the response at a single sample.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`test_two_columns_reused_by_every_resource` asserted the column headers
read `UTILIZATION` and `MEMORY` and the row labels read `cpu`, `gpu=0`,
`gpu=1` — the same literals written a few lines up in `get_metrics_table`.

There is no way for the layout to break that this catches and the other
tests do not: a row losing its number is covered, the axis falling out of
alignment with the cells is covered, and the table overflowing the
terminal is covered. All it added was a second place to edit whenever the
columns change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`concepts/metrics.md` and `guides/migration/slurm.md` both printed the
old `NAME STATUS CPU MEMORY GPU` table as their example, which no longer
exists. Both samples are rendered from the current code rather than
written by hand.

The concepts page also claimed the command shows "the most recently
tracked" metrics; it now charts the last hour. Records the single-job
behaviour and `--replica`/`--job` there too, next to the `dstack logs`
reference it mirrors.

Drops a docstring on `format_memory` pointing at its test file: the path
went stale when the test moved, and the function did not carry one
before it was made public.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`agentic-orchestration.md` printed the old `NAME STATUS CPU MEMORY GPU`
table, and `dstack-metrics.md` illustrated the command with a screenshot
of that same output. Both now show output rendered from the current code.

`dstack-metrics.md` also said the command displays metrics per job for
multi-job runs. Rather than rewrite the 2024 text, this adds a note in
the style the post already uses for the `dstack stats` rename.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every sample rendered flat -- twenty-seven identical glyphs per row. The
generator drove them with a sine of period ~19 samples while a cell buckets
~13, so each bucket's peak came out the same and the variation averaged
itself away. A chart that never changes argues against the feature.

Drives them from profiles slower than one cell instead: a warmup ramp while
weights and data load, steady training, a checkpoint stall two thirds in
where the accelerators drop to idle and the CPU picks up, and memory that
allocates during warmup then holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`slices` draws one cell per sample and never invents more, so a run
younger than the terminal is wide fills only part of the row. `_axis`
drew the full requested width regardless, which claimed a span nothing
had been measured over -- and because the axis cell was then wider than
the sparkline cell, Rich widened the whole UTILIZATION column to fit it,
pushing MEMORY sideways and opening a gap after every label.

On a nine-minute-old run at 200 columns that was 53 cells of data under
a 70-column rule. Runs older than about fifteen minutes were unaffected,
which is why it went unnoticed: every fixture had more samples than
cells.

Draws the rule dotted rather than solid while here. It only has to bind
each pair of stamps -- with plain whitespace the gap inside a pair grows
wider than the gap between the two columns past ~120 columns, so the
stamps group with the wrong neighbour -- and it need not compete with
the sparklines above it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@peterschmidt85
peterschmidt85 force-pushed the worktree-metrics-sparklines branch from 7afabba to c4a8d0c Compare August 4, 2026 14:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant