GPU Utilization
A four-state cluster-utilisation card: a device-by-time SM occupancy strip, one memory line per device group against the HBM ceiling, and idle stalls drawn as bubbles sized by their duration — with the idle share, the longest stall and a wall-clock budget that totals exactly 100% derived in-component.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-gpu-utilization.jsonPrompt
Build a React + TypeScript + Tailwind "ChartGpuUtilization" card in one
hand-rolled SVG plus plain HTML, with zod. No chart library: the card composes
three views on ONE time axis — a device-by-time occupancy strip, a memory lane
per device group, and a lane of stall bubbles — and a generic wrapper would
fight all three.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string; memoryCapacityGb: number > 0;
devices: { id; label; group?; samples: { t >= 0; smUtil 0..100;
memGb >= 0 }[] }[];
stalls?: { fromT; toT; cause; note? }[] }.
- `cause` is a CLOSED taxonomy — "all-reduce" | "data-loader" | "checkpoint" |
"other" — with a label map beside it. A free string lets two exporters spell
"NCCL wait" three ways and splits one drain across three legend rows.
- `t` is seconds since the run started, not a wall-clock timestamp: two nodes
whose clocks differ by 200 ms would otherwise draw two different runs.
- smUtil is SM OCCUPANCY, not MFU. Say so in the card: a memory-bound kernel
holds a row bright at 100%.
- Stalls are INTERVALS, not samples. A stall that opened between two profiler
ticks still cost the run its seconds, so durations come from the interval and
never from counting dark cells.
- superRefine: a ready card needs one device with one sample; device ids unique;
toT strictly after fromT. Guard every dereference — zod runs all refinements,
so an unguarded access throws out of safeParse instead of returning an issue.
- Props = z.infer plus showMemory (default true), onRetry, className and the
div's native props minus `title`; forwardRef to the card root and spread the
rest onto it.
- Export the maths beside the component so a test can print the numbers behind
the picture: buildGpuUtilizationModel(), buildGpuStallSummary(),
mergeGpuIntervals(), largestRemainderShares(), gpuOccupancyLevel(),
gpuOccupancyOpacity(), gpuTimeStep(), gpuAxisStep(), formatGpuClock(),
formatGpuDuration().
Behavior
- ONE TIME AXIS, THREE VIEWS. Take the union of every sampled second; the window
is [first, last + dwell] where dwell is the MEDIAN gap (a mean would let one
restart gap widen every cell). The strip, the memory lane, the bubbles, the
gridlines and the ticks all read that axis. Ticks come off a CLOCK ladder
(1/2/5/10/15/30/60/120/300 ... s, then whole hours), never a 1-2-5 decimal
one: a decimal step labels a five-minute window "1:40, 3:20" — arithmetically
correct and unreadable. Memory keeps the decimal ladder, because GB are
decimal.
- A HOLE STAYS A HOLE. A cell spans from its own second to the next one, capped
at one dwell, so a device that stopped logging does not paint occupancy over
seconds it never measured. Repeated timestamps are dropped and counted, never
averaged: the mean of a contradiction is a number nobody measured.
- OCCUPANCY BANDS ARE ABSOLUTE. Five fixed bands (0-20 ... 80-100, lower bound
included), not a ladder cut from the busiest cell — occupancy is already a
percentage of a known whole, and a data-relative ramp paints a run that never
cleared 30% exactly like one that never dropped below 90.
- IDLE IS THE UNION. Clip stalls to the window, merge them, and charge the union
against the wall clock. Attribute each elementary stretch to whichever stall
opened first (payload order breaking a tie), so per-cause seconds always add
up to the union — overlapping stalls can never bill 130% of a run.
- BUDGET THAT ADDS UP. Split the window into compute plus one bucket per cause
present, and apportion the percentages by LARGEST REMAINDER. Rounding each
bucket on its own is the bug: 70 + 12.67 + 10 + 7.33 rounds to 70+13+10+7 only
if the leftover point is handed to the largest fraction; naive rounding prints
99 or 101 and the reader stops trusting the card. One apportionment feeds the
bar widths, the legend percentages and the screen-reader table.
- MEMORY LANE = the GROUP MAXIMUM at each second, not the mean: the ceiling is
what decides an out-of-memory, and it keeps the peak-memory tile on a vertex
of a drawn line instead of beside it. The y axis floors at a round step under
the lowest lane rather than at zero — what a lane is read for is its distance
to the capacity rule — and the card SAYS SO in the footnote, printing the floor
and the ticks through the same GB formatter so a 0.5 GB step on a 16 GB card
reads 13.5 / 14 / 14.5 instead of labelling three gridlines "14". A run where no
device reported memory at all — a profiler stream with no memory column exports
zeros — makes the peak tile say so rather than name a blank device at a dash.
- BUBBLES ARE THE CONTROL. Pack the stalls into lanes by greedy interval packing
so two overlapping bubbles never sit on top of each other, and make the lane a
role="listbox" with one tab stop, aria-activedescendant, one role="option" per
stall, arrow keys plus Home/End, and click-to-select on a hit rect spanning
the stall's whole column. Selection defaults to the longest stall, so the card
ships with a live readout. Selecting one reads out its cause, duration, clock
range, share of the window, mean occupancy inside against outside every stall,
the busiest device inside it, and peak memory inside it.
- NEVER TINT THE STRIP. The selected stall is marked with its two edges, a wash
over the MEMORY lane only, and a ring — a tint laid over an occupancy cell
would change the very value its colour reports.
- Labels degrade instead of colliding: place bubble labels longest-first, try
"cause 38 s" then "38 s", clamp inside the plot, and drop a label that would
overlap one already placed on the same lane. Every stall is still in the
readout and the table.
- Four first-class branches of one card: loading (tiles + strip + lane + bar
silhouette, aria-hidden, plus an sr-only role=status), empty (worded so it
cannot be mistaken for a failed fetch, and it states what would fill it),
error (role=alert; Try again only when onRetry was passed), ready. status
"ready" with no drawable device renders the empty branch.
- The ONLY subscription is a ResizeObserver on the plot box, disconnected on
unmount and whenever the node changes. No timers, no rAF, no window listeners.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground panel, border tiles and
gridlines, fill-muted row tracks, text-muted-foreground for axis and prose,
destructive for the error line, stroke-ring for focus and selection. Occupancy
is var(--chart-1) at a five-step opacity ramp over the muted track (floor 0.24
— a 0.08 wash vanishes on a near-black card); memory lanes are var(--chart-2)
told apart by a FIVE-RUNG DASH LADDER cycled per lane — the causes already hold
the other chart tokens, so the dash is the whole of that second channel, and a
ladder that dotted everything past the second lane would hand a four-node
cluster two lines its legend cannot name; causes are var(--chart-3) / (--chart-4) /
(--chart-5) with the unattributed bucket falling back to muted-foreground.
Never a hex, never an oklch() literal.
- COLOUR IS NEVER THE ONLY CHANNEL: each bubble prints its cause tag and
duration, the budget legend repeats both in words, the memory lanes differ by
dash, and the sr-only tables carry every number.
- Text over marks uses paintOrder="stroke" with a var(--card) halo so a label
survives the busiest cell; every number is tabular-nums.
- ACCESSIBILITY: the svg is role="group" (never role="img" — that is
children-presentational and would silence the listbox inside it), named by the
heading and described by the summary line; keyboard moves announce through an
sr-only role=status; sr-only WRAPPER DIVs (never sr-only on a table itself —
CSS width is only a lower bound for a table box) carry a per-device table, the
budget table and a stall table.
- Motion: the only animation is the skeleton pulse, carrying
motion-reduce:animate-none; the retry button's transition carries
motion-reduce:transition-none.
Customization levers
- Density: ROW_H / ROW_GAP are the strip's whole weight — 8/2 for a 64-device
rack wall, 16/6 for a four-device card where each row should read as a band;
MEM_H sizes the memory lane independently, and showMemory drops it entirely
for a compact tile (the peak tile stays).
- Bands: OCCUPANCY_EDGES and OCCUPANCY_OPACITY are one ladder — go to three
bands for a glanceable wall, or re-cut the edges at 25/50/75/90 if your fleet
never idles; the legend and the sr-only wording follow automatically.
- Taxonomy: extend GPU_STALL_CAUSES and its label map (add "eval", "compile",
"preemption") and give each a chart token in CAUSE_INK; the budget bar, the
bubbles and the ranking pick it up with no other edit.
- Grouping: `group` is whatever unit you compare memory at — node, pipeline
stage, tensor-parallel rank, tenant. Omit it everywhere and the card draws one
lane for the cluster; MEM_DASH is the ladder those lanes are told apart by, so
add rungs to it before comparing more groups than it has.
- Verdicts: the headline is "biggest single drain by seconds". Swap the reducer
for "most frequent cause" or "worst stall per step" and the sentence, the tile
and the table all follow, because they read the same summary object.
- Interaction: for a static report, render with the longest stall selected and
drop the listbox wrapper; for a shared cursor across a page of runs, lift the
selection into a controlled prop.
- Units: `t` in seconds and memory in GB are contract-level choices — swap
formatGpuClock / formatGpuDuration / the GB formatter for steps, tokens or MiB
in one place each.Concepts
- Device rows beat a cluster average — the reason this is a strip and not a line is the straggler: when one rank runs late, the collective parks every other rank at near-zero while that one row stays bright. Averaged into a single utilisation curve, the whole picture becomes "we were at 20%", which points at the fabric instead of at the rank that was late.
- The shape of a dark column is the diagnosis — a column dark on every row but one is a straggler; dark on all rows at once is host-side (loader starvation, checkpoint flush); dark in a repeating comb is a step boundary that never overlapped its communication. Same three-second cell, three different tickets.
- Idle is the union, billed once — overlapping stalls are merged before anything is charged, and each elementary stretch goes to whichever stall opened first. Adding durations instead is how a card ends up claiming a run lost 130% of its wall clock; the union also makes the per-cause seconds a genuine partition rather than a pile of ratios.
- A budget that adds up — compute plus one bucket per cause is apportioned by largest remainder, so the bar, the legend and the table print percentages totalling exactly 100. Rounding each bucket on its own publishes 99 or 101, and a card that cannot add up its own wall clock has undermined every other number on it.
- Occupancy bands are absolute, and occupancy is not efficiency — five fixed bands, because a data-relative ramp flatters a run that never cleared 30%. The card also says out loud that SM occupancy means "a warp was resident", not "the FLOPs were useful": a memory-bound kernel is bright.
- Memory is read against its ceiling, and the truncation is declared — lanes carry the group's highest device, the axis floors at a round step under the lowest lane, and the capacity rule is drawn across the top with the headroom printed. What matters at 95% of HBM is the last 9 GB, not the first 130.
- The bubbles are the control — the stall lane is a listbox with one tab stop, arrow keys and click-to-select over each stall's whole column, and it opens on the longest stall so the card ships with a live reading. Selecting one never tints the strip: a wash over an occupancy cell would change the value its colour reports.
Data Mixture
A four-state training-mixture card: one composition bar per training stage split by data domain, with per-domain epochs so upsampling reads as the gap between unique tokens and tokens seen.
Tournament Schedule · Order of Play
A four-state order-of-play sheet — courts as columns against a vertical time axis, every match a block as long as its estimate, floating "not before" slots, live progress, and the double bookings and rest gaps the sheet implies.