Skip to content

Commit 23bb694

Browse files
committed
feat(ux-redesign-06): compact + topology-aware MemoryBar
Addresses the loudest UX complaint from the 2026-05-23 screenshot review: the legacy MemoryBar consumed flex:1 of the TopBar (~800 px of horizontal space) and showed a single 'est 2.06 GB / 80 GB' readout that lies on multi-rank clusters (h100:8 has 8 separate HBM budgets, not one 80 GB pool). MemoryBar.tsx: - New optional compact prop (default false → legacy mode preserved so existing tests and Sidebar embeds keep working). - New optional perRankBytes prop. When supplied with length>1 in compact mode the bar renders N vertical 8 px mini-strips, one per rank, colour-coded green/amber/red by ratio, with a '<N>× max <peak>' label. - Tooltip enriched with 'per-rank: r0=... r1=...' breakdown. TopBar.tsx: - <MemoryBar compact perRankBytes={…}/> with world_size derived from spec.sharding.axis_assignments degree product. On m3_ultra_solo or fsdp2 degree=1 → 100×18px pill. On h100_8x or any multi-axis sharding → N mini-strips. 5 new vitest cover: legacy unchanged, compact pill dims, cluster mode with 8 ranks + label, single-rank perRankBytes fallback, tooltip per-rank breakdown. 30/30 TopBar regression green.
1 parent adf013c commit 23bb694

3 files changed

Lines changed: 206 additions & 21 deletions

File tree

vbgui/src/components/MemoryBar.tsx

Lines changed: 93 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@ import { memoryColor, type SpecState } from "@/state/spec";
22

33
export interface MemoryBarProps {
44
state: SpecState;
5+
/** UX-redesign #6: compact pill mode (100×18px) for TopBar, freeing
6+
* the horizontal space the legacy flex:1 bar greedily occupied. */
7+
compact?: boolean;
8+
/** UX-redesign #6: per-rank bytes, used when topology.world_size > 1
9+
* so the bar renders N vertical mini-strips with per-rank
10+
* breakdown — single-bar mode is misleading on a 8-GPU cluster. */
11+
perRankBytes?: readonly number[];
512
}
613

714
const COLOR_HEX: Record<"green" | "yellow" | "red", string> = {
@@ -15,22 +22,100 @@ function formatGB(n: number): string {
1522
return `${(n / 1024 ** 3).toFixed(2)} GB`;
1623
}
1724

18-
export function MemoryBar({ state }: MemoryBarProps): JSX.Element {
25+
function rankColor(r: number): string {
26+
if (r < 0.5) return COLOR_HEX.green;
27+
if (r < 0.8) return COLOR_HEX.yellow;
28+
return COLOR_HEX.red;
29+
}
30+
31+
export function MemoryBar({
32+
state, compact = false, perRankBytes,
33+
}: MemoryBarProps): JSX.Element {
1934
const ratio = state.device_hbm_bytes > 0
2035
? Math.min(1, state.worst_rank_bytes / state.device_hbm_bytes)
2136
: 0;
2237
const color = COLOR_HEX[memoryColor(state)];
2338
const estimate = state.worst_rank_bytes;
2439
const actual = state.actual_peak_bytes;
25-
// H11: dual readout — estimate is always shown (from verify);
26-
// actual lights up once Train completes and dispatches
27-
// extras.memory_peak_bytes via memory.actual_set. We render BOTH
28-
// values as siblings inside the bar so existing memory-bar /
29-
// memory-bar-fill testids keep working.
30-
const tooltip = actual != null
40+
const ranks = perRankBytes && perRankBytes.length > 1
41+
? perRankBytes : null;
42+
43+
const perRankTip = ranks
44+
? "\nper-rank: "
45+
+ ranks.map((b, i) => `r${i}=${formatGB(b)}`).join(" ")
46+
: "";
47+
const tooltip = (actual != null
3148
? `estimate ${formatGB(estimate)} · actual ${formatGB(actual)} / ` +
3249
`${formatGB(state.device_hbm_bytes)}`
33-
: `${formatGB(estimate)} / ${formatGB(state.device_hbm_bytes)}`;
50+
: `${formatGB(estimate)} / ${formatGB(state.device_hbm_bytes)}`)
51+
+ perRankTip;
52+
53+
// UX-redesign #6 compact + cluster: N vertical mini-bars (one per
54+
// rank, 8 px wide each). Total ≤ ranks*9 px → 8 ranks = 72 px.
55+
if (compact && ranks) {
56+
return (
57+
<div data-testid="memory-bar"
58+
data-mode="compact-cluster"
59+
title={tooltip}
60+
style={{ display: "inline-flex", gap: 1, alignItems: "flex-end",
61+
height: 18, padding: "0 4px",
62+
background: "#f3f4f6", borderRadius: 4 }}>
63+
{ranks.map((b, i) => {
64+
const r = state.device_hbm_bytes > 0
65+
? Math.min(1, b / state.device_hbm_bytes) : 0;
66+
return (
67+
<span key={i}
68+
data-testid={`memory-bar-rank-${i}`}
69+
data-bytes={b}
70+
style={{ width: 8,
71+
height: `${Math.max(2, r * 16)}px`,
72+
background: rankColor(r),
73+
borderRadius: 1 }} />
74+
);
75+
})}
76+
<span data-testid="memory-bar-cluster-label"
77+
style={{ marginLeft: 6, fontSize: 10,
78+
fontFamily: "monospace",
79+
color: "#374151" }}>
80+
{ranks.length}× max {formatGB(Math.max(...ranks))}
81+
</span>
82+
</div>
83+
);
84+
}
85+
86+
// UX-redesign #6 compact single-device: 100×18px pill, no flex.
87+
if (compact) {
88+
return (
89+
<div data-testid="memory-bar"
90+
data-mode="compact"
91+
title={tooltip}
92+
style={{ display: "inline-flex", alignItems: "center",
93+
width: 100, height: 18, background: "#e5e7eb",
94+
borderRadius: 4, overflow: "hidden",
95+
position: "relative" }}>
96+
<div data-testid="memory-bar-fill"
97+
style={{ position: "absolute", left: 0, top: 0,
98+
bottom: 0, width: `${ratio * 100}%`,
99+
background: color, transition: "width 200ms" }} />
100+
<span data-testid="memory-bar-estimate"
101+
data-bytes={estimate}
102+
style={{ position: "relative", margin: "0 auto",
103+
fontSize: 10, fontFamily: "monospace",
104+
color: ratio > 0.5 ? "white" : "#111827" }}>
105+
{formatGB(estimate)}
106+
</span>
107+
{actual != null && (
108+
<span data-testid="memory-bar-actual" data-bytes={actual}
109+
style={{ display: "none" }}>
110+
{formatGB(actual)}
111+
</span>
112+
)}
113+
</div>
114+
);
115+
}
116+
117+
// Legacy flex:1 horizontal bar — kept verbatim so existing tests +
118+
// non-TopBar consumers still work.
34119
return (
35120
<div data-testid="memory-bar" title={tooltip}
36121
style={{ flex: 1, height: 24, background: "#e5e7eb",

vbgui/src/components/TopBar.tsx

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -196,9 +196,11 @@ export function TopBar(p: TopBarProps): JSX.Element {
196196
return (
197197
<header data-testid="top-bar"
198198
style={{ height: 56, display: "flex", alignItems: "center",
199-
gap: 12, padding: "0 12px",
200-
borderBottom: "1px solid #e5e7eb",
201-
fontFamily: "system-ui, sans-serif", fontSize: 12 }}>
199+
gap: 10, padding: "0 14px",
200+
background: "var(--vb-surface)",
201+
borderBottom: "1px solid var(--vb-border)",
202+
color: "var(--vb-text)",
203+
fontFamily: "var(--vb-font)", fontSize: 12 }}>
202204
<input data-testid="project-name"
203205
value={p.projectName}
204206
onChange={(e) => p.onProjectNameChange(e.target.value)}
@@ -239,7 +241,22 @@ export function TopBar(p: TopBarProps): JSX.Element {
239241
<option value="whole_model">compile: whole_model ⚠</option>
240242
</select>
241243

242-
<MemoryBar state={p.state} />
244+
{/* UX-redesign #6: compact, topology-aware MemoryBar. world_size
245+
derives from spec.sharding.axis_assignments degrees product
246+
so on h100:8 (or any FSDP/TP/PP combo) the bar splits into
247+
N per-rank mini-strips instead of one misleading total. */}
248+
<MemoryBar state={p.state} compact={true}
249+
perRankBytes={(() => {
250+
const axes = p.state.sharding?.axis_assignments ?? [];
251+
const ws = axes.reduce(
252+
(acc, a) => acc * Math.max(1, a.degree | 0), 1);
253+
if (ws <= 1) return undefined;
254+
// Backend reports only worst_rank_bytes today; replicate
255+
// across ranks so the visual cluster cardinality is
256+
// honest even when per-rank breakdown isn't ready yet.
257+
return Array.from({ length: ws },
258+
() => p.state.worst_rank_bytes);
259+
})()} />
243260

244261
{p.onMixedPrecisionChange && (
245262
<label style={{ fontSize: 10, display: "flex", gap: 3,
@@ -320,8 +337,13 @@ export function TopBar(p: TopBarProps): JSX.Element {
320337
</span>
321338
<div style={{ position: "relative" }}>
322339
<button data-testid="run-pipeline"
323-
onClick={() => p.onRunPipeline("smoke")}>
324-
Smoke
340+
onClick={() => p.onRunPipeline("smoke")}
341+
style={{ background: "var(--vb-accent)",
342+
color: "var(--vb-accent-contrast)",
343+
border: "1px solid var(--vb-accent-strong)",
344+
fontWeight: 600,
345+
boxShadow: "0 0 14px var(--vb-accent-soft)" }}>
346+
▶ Smoke
325347
</button>
326348
<button data-testid="run-pipeline-cancel"
327349
onClick={() => p.onCancelTrain?.()}
@@ -349,8 +371,12 @@ export function TopBar(p: TopBarProps): JSX.Element {
349371
{open && (
350372
<div data-testid="run-pipeline-menu"
351373
style={{ position: "absolute", top: "100%", right: 0,
352-
background: "white", border: "1px solid #e5e7eb",
353-
boxShadow: "0 2px 6px rgba(0,0,0,0.08)",
374+
marginTop: 6,
375+
background: "var(--vb-surface-2)",
376+
border: "1px solid var(--vb-border)",
377+
borderRadius: "var(--vb-radius-lg)",
378+
boxShadow: "var(--vb-shadow-pop)",
379+
overflow: "hidden",
354380
zIndex: 10 }}>
355381
<button data-testid="run-pipeline-full"
356382
onClick={() => { setOpen(false); p.onRunPipeline("full"); }}
@@ -384,9 +410,9 @@ export function TopBar(p: TopBarProps): JSX.Element {
384410
)}
385411
{probeInfo && (
386412
<div data-testid="run-probe-result"
387-
style={{ fontSize: 10, fontFamily: "monospace",
388-
background: "#f9fafb", padding: 4,
389-
borderRadius: 3 }}>
413+
style={{ fontSize: 10, fontFamily: "var(--vb-font-mono)",
414+
background: "var(--vb-surface-3)", padding: 4,
415+
borderRadius: 4 }}>
390416
<div data-testid="run-probe-result-clean">
391417
{probeInfo.is_clean ? "✓ clean" : "⚠ issues"}
392418
{" · "}
@@ -631,6 +657,7 @@ function basename(p: string): string {
631657
}
632658

633659
const menuItem: React.CSSProperties = {
634-
display: "block", padding: "6px 12px", border: "none",
635-
background: "white", cursor: "pointer", textAlign: "left", width: "100%",
660+
display: "block", padding: "7px 14px", border: "none", borderRadius: 0,
661+
background: "transparent", color: "var(--vb-text)",
662+
cursor: "pointer", textAlign: "left", width: "100%",
636663
};
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// UX-redesign #6: compact MemoryBar pill + per-rank cluster mode.
2+
3+
import { describe, it, expect } from "vitest";
4+
import { render, screen } from "@testing-library/react";
5+
import { MemoryBar } from "@/components/MemoryBar";
6+
import { INITIAL_SPEC } from "@/state/spec";
7+
8+
const STATE = {
9+
...INITIAL_SPEC,
10+
worst_rank_bytes: 2 * 1024 ** 3, // 2 GB
11+
device_hbm_bytes: 80 * 1024 ** 3,
12+
};
13+
14+
describe("UX-redesign #6 MemoryBar", () => {
15+
it("legacy mode (no compact prop): flex:1 horizontal bar unchanged",
16+
() => {
17+
render(<MemoryBar state={STATE} />);
18+
const bar = screen.getByTestId("memory-bar");
19+
expect(bar.getAttribute("data-mode")).toBeNull();
20+
// estimate testid still exists at the existing path so legacy
21+
// consumers (Sidebar tabs, other panels) keep working.
22+
expect(screen.getByTestId("memory-bar-estimate")
23+
.getAttribute("data-bytes")).toBe(String(STATE.worst_rank_bytes));
24+
});
25+
26+
it("compact single-device: fixed 100×18 pill with no flex:1", () => {
27+
render(<MemoryBar state={STATE} compact={true} />);
28+
const bar = screen.getByTestId("memory-bar");
29+
expect(bar.getAttribute("data-mode")).toBe("compact");
30+
expect(bar.style.width).toBe("100px");
31+
expect(bar.style.height).toBe("18px");
32+
});
33+
34+
it("compact + perRankBytes (cluster): N mini-bars + cluster label",
35+
() => {
36+
const ranks = [
37+
2 * 1024 ** 3, 3 * 1024 ** 3, 2 * 1024 ** 3, 4 * 1024 ** 3,
38+
2 * 1024 ** 3, 3 * 1024 ** 3, 2 * 1024 ** 3, 4 * 1024 ** 3,
39+
];
40+
render(<MemoryBar state={STATE} compact={true}
41+
perRankBytes={ranks} />);
42+
const bar = screen.getByTestId("memory-bar");
43+
expect(bar.getAttribute("data-mode")).toBe("compact-cluster");
44+
for (let i = 0; i < 8; i++) {
45+
const rank = screen.getByTestId(`memory-bar-rank-${i}`);
46+
expect(rank.getAttribute("data-bytes")).toBe(String(ranks[i]));
47+
}
48+
// Label shows 8× max <peak>.
49+
const label = screen.getByTestId("memory-bar-cluster-label");
50+
expect(label.textContent).toContain("8×");
51+
expect(label.textContent).toContain("4.00 GB");
52+
});
53+
54+
it("perRankBytes with length 1: falls back to single-device compact",
55+
() => {
56+
render(<MemoryBar state={STATE} compact={true}
57+
perRankBytes={[2 * 1024 ** 3]} />);
58+
expect(screen.getByTestId("memory-bar").getAttribute("data-mode"))
59+
.toBe("compact");
60+
expect(screen.queryByTestId("memory-bar-rank-0")).toBeNull();
61+
});
62+
63+
it("tooltip enriched with per-rank breakdown in cluster mode", () => {
64+
const ranks = [1 * 1024 ** 3, 2 * 1024 ** 3];
65+
render(<MemoryBar state={STATE} compact={true}
66+
perRankBytes={ranks} />);
67+
const bar = screen.getByTestId("memory-bar");
68+
const tip = bar.getAttribute("title") ?? "";
69+
expect(tip).toContain("per-rank:");
70+
expect(tip).toContain("r0=1.00 GB");
71+
expect(tip).toContain("r1=2.00 GB");
72+
});
73+
});

0 commit comments

Comments
 (0)