Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  GPU_STALL_CAUSES,
  GPU_STALL_CAUSE_LABELS,
  type ChartGpuUtilizationCause,
  type ChartGpuUtilizationData,
  type ChartGpuUtilizationDevice,
  type ChartGpuUtilizationSample,
  type ChartGpuUtilizationStall,
} from "./chart-gpu-utilization.contract"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-gpu-utilization.json

Prompt

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.

On This Page