Skip to content

Commit 9abb3d3

Browse files
aryguptclaude
andauthored
feat(dashboard): surface prefill/decode power-per-GPU + J/Input (measured energy) (#414)
* feat(dashboard): add prefill/decode/J-input as measured-energy metrics Wire the per-stage measured-power telemetry the runner already emits (prefill_avg_power_w, decode_avg_power_w, joules_per_input_token) as three new selectable Y-axis metrics, mirroring the existing measuredAvgPower trio: - Measured Prefill Power per GPU (W) - Measured Decode Power per GPU (W) - Measured J per Input Token (J/tok) Added across both chart configs (interactivity + e2e), Y_AXIS_METRICS, YAxisMetricKey, ChartDefinition, InferenceData, createChartDataPoint, the roofline machinery (type unions / roof-reset / markRooflinePoints), the lightweight trend-point builder, and the gated "Measured Energy" dropdown group (stays behind the existing feature gate). Purely additive — source fields already exist on AggDataEntry; no runner or DB change. Closes the gap where disagg per-stage power was ingested but not renderable. Validated against GB300 disagg data (run 26607091549). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * style(chart-utils): satisfy oxfmt format check Collapse two else-if conditions that fit on one line; oxfmt --check flagged chart-utils.ts in CI (oxc job). No logic change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d636415 commit 9abb3d3

6 files changed

Lines changed: 161 additions & 4 deletions

File tree

packages/app/src/components/inference/hooks/useInterpolatedTrendData.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,15 @@ function rowToLightweightPoint(row: BenchmarkRow): InferenceData | null {
8080
...(typeof entry.joules_per_total_token === 'number'
8181
? { measuredJPerTotalToken: { y: entry.joules_per_total_token, roof: false } }
8282
: {}),
83+
...(typeof entry.prefill_avg_power_w === 'number'
84+
? { measuredPrefillAvgPower: { y: entry.prefill_avg_power_w, roof: false } }
85+
: {}),
86+
...(typeof entry.decode_avg_power_w === 'number'
87+
? { measuredDecodeAvgPower: { y: entry.decode_avg_power_w, roof: false } }
88+
: {}),
89+
...(typeof entry.joules_per_input_token === 'number'
90+
? { measuredJPerInputToken: { y: entry.joules_per_input_token, roof: false } }
91+
: {}),
8392
};
8493
return point;
8594
}

packages/app/src/components/inference/inference-chart-config.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,20 @@
9191
"y_measuredAvgPower": "measuredAvgPower.y",
9292
"y_measuredAvgPower_label": "Measured Avg Power per GPU (W)",
9393
"y_measuredAvgPower_title": "Measured Average Power per GPU",
94+
"y_measuredPrefillAvgPower": "measuredPrefillAvgPower.y",
95+
"y_measuredPrefillAvgPower_label": "Measured Prefill Power per GPU (W)",
96+
"y_measuredPrefillAvgPower_title": "Measured Prefill Power per GPU",
97+
"y_measuredDecodeAvgPower": "measuredDecodeAvgPower.y",
98+
"y_measuredDecodeAvgPower_label": "Measured Decode Power per GPU (W)",
99+
"y_measuredDecodeAvgPower_title": "Measured Decode Power per GPU",
94100
"y_measuredJPerOutputToken": "measuredJPerOutputToken.y",
95101
"y_measuredJPerOutputToken_label": "Measured J per Output Token (J/tok)",
96102
"y_measuredJPerOutputToken_title": "Measured Joules per Output Token",
97103
"y_measuredJPerOutputToken_roofline": "lower_right",
104+
"y_measuredJPerInputToken": "measuredJPerInputToken.y",
105+
"y_measuredJPerInputToken_label": "Measured J per Input Token (J/tok)",
106+
"y_measuredJPerInputToken_title": "Measured Joules per Input Token",
107+
"y_measuredJPerInputToken_roofline": "lower_right",
98108
"y_measuredJPerTotalToken": "measuredJPerTotalToken.y",
99109
"y_measuredJPerTotalToken_label": "Measured J per Token (J/tok)",
100110
"y_measuredJPerTotalToken_title": "Measured Joules per Token (incl. prompt)",
@@ -193,10 +203,20 @@
193203
"y_measuredAvgPower": "measuredAvgPower.y",
194204
"y_measuredAvgPower_label": "Measured Avg Power per GPU (W)",
195205
"y_measuredAvgPower_title": "Measured Average Power per GPU",
206+
"y_measuredPrefillAvgPower": "measuredPrefillAvgPower.y",
207+
"y_measuredPrefillAvgPower_label": "Measured Prefill Power per GPU (W)",
208+
"y_measuredPrefillAvgPower_title": "Measured Prefill Power per GPU",
209+
"y_measuredDecodeAvgPower": "measuredDecodeAvgPower.y",
210+
"y_measuredDecodeAvgPower_label": "Measured Decode Power per GPU (W)",
211+
"y_measuredDecodeAvgPower_title": "Measured Decode Power per GPU",
196212
"y_measuredJPerOutputToken": "measuredJPerOutputToken.y",
197213
"y_measuredJPerOutputToken_label": "Measured J per Output Token (J/tok)",
198214
"y_measuredJPerOutputToken_title": "Measured Joules per Output Token",
199215
"y_measuredJPerOutputToken_roofline": "lower_left",
216+
"y_measuredJPerInputToken": "measuredJPerInputToken.y",
217+
"y_measuredJPerInputToken_label": "Measured J per Input Token (J/tok)",
218+
"y_measuredJPerInputToken_title": "Measured Joules per Input Token",
219+
"y_measuredJPerInputToken_roofline": "lower_left",
200220
"y_measuredJPerTotalToken": "measuredJPerTotalToken.y",
201221
"y_measuredJPerTotalToken_label": "Measured J per Token (J/tok)",
202222
"y_measuredJPerTotalToken_title": "Measured Joules per Token (incl. prompt)",

packages/app/src/components/inference/types.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,8 +231,11 @@ export interface InferenceData extends Partial<Omit<AggDataEntry, AggDataConflic
231231
// pre-aggregate_power.py runs (and runs with monitoring disabled) won't
232232
// emit these fields.
233233
measuredAvgPower?: { y: number; roof: boolean };
234+
measuredPrefillAvgPower?: { y: number; roof: boolean };
235+
measuredDecodeAvgPower?: { y: number; roof: boolean };
234236
measuredJPerOutputToken?: { y: number; roof: boolean };
235237
measuredJPerTotalToken?: { y: number; roof: boolean };
238+
measuredJPerInputToken?: { y: number; roof: boolean };
236239
}
237240

238241
/**
@@ -260,8 +263,11 @@ export type YAxisMetricKey =
260263
| 'jOutput'
261264
| 'jInput'
262265
| 'measuredAvgPower'
266+
| 'measuredPrefillAvgPower'
267+
| 'measuredDecodeAvgPower'
263268
| 'measuredJPerOutputToken'
264-
| 'measuredJPerTotalToken';
269+
| 'measuredJPerTotalToken'
270+
| 'measuredJPerInputToken';
265271

266272
/**
267273
* Defines the configuration and labels for a specific chart.
@@ -370,10 +376,22 @@ export interface ChartDefinition {
370376
// The field stays in the type for parity with the other y_* metrics and
371377
// so a future config can override the default.
372378
y_measuredAvgPower_roofline?: 'upper_right' | 'upper_left' | 'lower_left' | 'lower_right';
379+
y_measuredPrefillAvgPower?: string;
380+
y_measuredPrefillAvgPower_label?: string;
381+
y_measuredPrefillAvgPower_title?: string;
382+
y_measuredPrefillAvgPower_roofline?: 'upper_right' | 'upper_left' | 'lower_left' | 'lower_right';
383+
y_measuredDecodeAvgPower?: string;
384+
y_measuredDecodeAvgPower_label?: string;
385+
y_measuredDecodeAvgPower_title?: string;
386+
y_measuredDecodeAvgPower_roofline?: 'upper_right' | 'upper_left' | 'lower_left' | 'lower_right';
373387
y_measuredJPerOutputToken?: string;
374388
y_measuredJPerOutputToken_label?: string;
375389
y_measuredJPerOutputToken_title?: string;
376390
y_measuredJPerOutputToken_roofline?: 'upper_right' | 'upper_left' | 'lower_left' | 'lower_right';
391+
y_measuredJPerInputToken?: string;
392+
y_measuredJPerInputToken_label?: string;
393+
y_measuredJPerInputToken_title?: string;
394+
y_measuredJPerInputToken_roofline?: 'upper_right' | 'upper_left' | 'lower_left' | 'lower_right';
377395
y_measuredJPerTotalToken?: string;
378396
y_measuredJPerTotalToken_label?: string;
379397
y_measuredJPerTotalToken_title?: string;

packages/app/src/components/inference/ui/ChartControls.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,14 @@ const METRIC_GROUPS: { label: string; metrics: string[]; gated?: boolean }[] = [
5656
{ label: 'All-in Provisioned Energy per Token', metrics: ['y_jTotal', 'y_jOutput', 'y_jInput'] },
5757
{
5858
label: 'Measured Energy',
59-
metrics: ['y_measuredAvgPower', 'y_measuredJPerOutputToken', 'y_measuredJPerTotalToken'],
59+
metrics: [
60+
'y_measuredPrefillAvgPower',
61+
'y_measuredDecodeAvgPower',
62+
'y_measuredAvgPower',
63+
'y_measuredJPerInputToken',
64+
'y_measuredJPerOutputToken',
65+
'y_measuredJPerTotalToken',
66+
],
6067
gated: true,
6168
},
6269
{ label: 'Custom User Values', metrics: ['y_costUser', 'y_powerUser'] },

packages/app/src/lib/chart-utils.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1294,6 +1294,79 @@ describe('createChartDataPoint measured power fields', () => {
12941294
});
12951295
});
12961296

1297+
// ===========================================================================
1298+
// createChartDataPoint — per-stage measured power / energy (disagg prefill/decode)
1299+
// ===========================================================================
1300+
describe('createChartDataPoint per-stage measured power fields', () => {
1301+
it('emits measuredPrefillAvgPower when prefill_avg_power_w is present', () => {
1302+
const e = entry({ prefill_avg_power_w: 920.3 });
1303+
const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100');
1304+
expect(point.measuredPrefillAvgPower).toBeDefined();
1305+
expect(point.measuredPrefillAvgPower!.y).toBe(920.3);
1306+
expect(point.measuredPrefillAvgPower!.roof).toBe(false);
1307+
});
1308+
1309+
it('emits measuredDecodeAvgPower when decode_avg_power_w is present', () => {
1310+
const e = entry({ decode_avg_power_w: 612.1 });
1311+
const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100');
1312+
expect(point.measuredDecodeAvgPower).toBeDefined();
1313+
expect(point.measuredDecodeAvgPower!.y).toBe(612.1);
1314+
expect(point.measuredDecodeAvgPower!.roof).toBe(false);
1315+
});
1316+
1317+
it('emits measuredJPerInputToken when joules_per_input_token is present', () => {
1318+
const e = entry({ joules_per_input_token: 0.27 });
1319+
const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100');
1320+
expect(point.measuredJPerInputToken).toBeDefined();
1321+
expect(point.measuredJPerInputToken!.y).toBe(0.27);
1322+
expect(point.measuredJPerInputToken!.roof).toBe(false);
1323+
});
1324+
1325+
it('omits all per-stage fields on legacy rows predating per-stage attribution', () => {
1326+
// Single-node / pre-disagg runs emit avg_power_w only, no prefill/decode split.
1327+
const e = entry({ avg_power_w: 685.5 });
1328+
const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100');
1329+
expect(point.measuredPrefillAvgPower).toBeUndefined();
1330+
expect(point.measuredDecodeAvgPower).toBeUndefined();
1331+
expect(point.measuredJPerInputToken).toBeUndefined();
1332+
});
1333+
1334+
it('emits prefill and decode independently — the disagg per-stage split', () => {
1335+
// GB300 disagg: prefill GPUs run compute-bound (higher W) than decode GPUs.
1336+
const e = entry({ prefill_avg_power_w: 948, decode_avg_power_w: 631 });
1337+
const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100');
1338+
expect(point.measuredPrefillAvgPower!.y).toBe(948);
1339+
expect(point.measuredDecodeAvgPower!.y).toBe(631);
1340+
expect(point.measuredPrefillAvgPower!.y).toBeGreaterThan(point.measuredDecodeAvgPower!.y);
1341+
});
1342+
1343+
it('preserves a zero per-stage power value (not falsy-coerced away)', () => {
1344+
// Same typeof===number gate as total power — 0 W must survive, not be dropped.
1345+
const e = entry({ prefill_avg_power_w: 0, decode_avg_power_w: 0 });
1346+
const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100');
1347+
expect(point.measuredPrefillAvgPower).toBeDefined();
1348+
expect(point.measuredPrefillAvgPower!.y).toBe(0);
1349+
expect(point.measuredDecodeAvgPower).toBeDefined();
1350+
expect(point.measuredDecodeAvgPower!.y).toBe(0);
1351+
});
1352+
1353+
it('carries total and per-stage power together on a full disagg row', () => {
1354+
const e = entry({
1355+
avg_power_w: 853,
1356+
prefill_avg_power_w: 948,
1357+
decode_avg_power_w: 631,
1358+
joules_per_input_token: 0.18,
1359+
joules_per_output_token: 1.64,
1360+
});
1361+
const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100');
1362+
expect(point.measuredAvgPower!.y).toBe(853);
1363+
expect(point.measuredPrefillAvgPower!.y).toBe(948);
1364+
expect(point.measuredDecodeAvgPower!.y).toBe(631);
1365+
expect(point.measuredJPerInputToken!.y).toBe(0.18);
1366+
expect(point.measuredJPerOutputToken!.y).toBe(1.64);
1367+
});
1368+
});
1369+
12971370
// ===========================================================================
12981371
// createChartDataPoint — boolean narrowing for prefill/decode dp_attention, is_multinode
12991372
// ===========================================================================

packages/app/src/lib/chart-utils.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,11 @@ export const Y_AXIS_METRICS = [
151151
// Measured power / energy (sourced from runner's aggregate_power.py output;
152152
// distinct from the spec-sheet TDP-derived jTotal/jOutput/jInput above).
153153
'y_measuredAvgPower',
154+
'y_measuredPrefillAvgPower',
155+
'y_measuredDecodeAvgPower',
154156
'y_measuredJPerOutputToken',
155157
'y_measuredJPerTotalToken',
158+
'y_measuredJPerInputToken',
156159
] as const;
157160

158161
export type YAxisMetric = (typeof Y_AXIS_METRICS)[number];
@@ -401,12 +404,21 @@ export function createChartDataPoint(
401404
...(typeof entry.avg_power_w === 'number'
402405
? { measuredAvgPower: { y: entry.avg_power_w, roof: false } }
403406
: {}),
407+
...(typeof entry.prefill_avg_power_w === 'number'
408+
? { measuredPrefillAvgPower: { y: entry.prefill_avg_power_w, roof: false } }
409+
: {}),
410+
...(typeof entry.decode_avg_power_w === 'number'
411+
? { measuredDecodeAvgPower: { y: entry.decode_avg_power_w, roof: false } }
412+
: {}),
404413
...(typeof entry.joules_per_output_token === 'number'
405414
? { measuredJPerOutputToken: { y: entry.joules_per_output_token, roof: false } }
406415
: {}),
407416
...(typeof entry.joules_per_total_token === 'number'
408417
? { measuredJPerTotalToken: { y: entry.joules_per_total_token, roof: false } }
409418
: {}),
419+
...(typeof entry.joules_per_input_token === 'number'
420+
? { measuredJPerInputToken: { y: entry.joules_per_input_token, roof: false } }
421+
: {}),
410422
};
411423
}
412424

@@ -569,8 +581,11 @@ export const calculateRoofline = (
569581
| `jOutput.y`
570582
| `jInput.y`
571583
| `measuredAvgPower.y`
584+
| `measuredPrefillAvgPower.y`
585+
| `measuredDecodeAvgPower.y`
572586
| `measuredJPerOutputToken.y`
573-
| `measuredJPerTotalToken.y`,
587+
| `measuredJPerTotalToken.y`
588+
| `measuredJPerInputToken.y`,
574589
rooflineDirection: 'upper_right' | 'upper_left' | 'lower_left' | 'lower_right',
575590
): InferenceData[] => {
576591
const pointsForRoofline = points.map((p) => {
@@ -642,8 +657,11 @@ export function computeAllRooflines(
642657
| `jOutput.y`
643658
| `jInput.y`
644659
| `measuredAvgPower.y`
660+
| `measuredPrefillAvgPower.y`
661+
| `measuredDecodeAvgPower.y`
645662
| `measuredJPerOutputToken.y`
646-
| `measuredJPerTotalToken.y`,
663+
| `measuredJPerTotalToken.y`
664+
| `measuredJPerInputToken.y`,
647665
rooflineDirection,
648666
);
649667
}
@@ -688,8 +706,11 @@ export function markRooflinePoints(
688706
if (newPoint.jOutput) newPoint.jOutput.roof = false;
689707
if (newPoint.jInput) newPoint.jInput.roof = false;
690708
if (newPoint.measuredAvgPower) newPoint.measuredAvgPower.roof = false;
709+
if (newPoint.measuredPrefillAvgPower) newPoint.measuredPrefillAvgPower.roof = false;
710+
if (newPoint.measuredDecodeAvgPower) newPoint.measuredDecodeAvgPower.roof = false;
691711
if (newPoint.measuredJPerOutputToken) newPoint.measuredJPerOutputToken.roof = false;
692712
if (newPoint.measuredJPerTotalToken) newPoint.measuredJPerTotalToken.roof = false;
713+
if (newPoint.measuredJPerInputToken) newPoint.measuredJPerInputToken.roof = false;
693714

694715
for (const chartDefYKey of Y_AXIS_METRICS) {
695716
const rooflinePoints = computedRooflines[hwKey]?.[chartDefYKey];
@@ -751,13 +772,22 @@ export function markRooflinePoints(
751772
newPoint.jInput.roof = onCurrentRoofline;
752773
} else if (chartDefYKey === 'y_measuredAvgPower' && newPoint.measuredAvgPower) {
753774
newPoint.measuredAvgPower.roof = onCurrentRoofline;
775+
} else if (
776+
chartDefYKey === 'y_measuredPrefillAvgPower' &&
777+
newPoint.measuredPrefillAvgPower
778+
) {
779+
newPoint.measuredPrefillAvgPower.roof = onCurrentRoofline;
780+
} else if (chartDefYKey === 'y_measuredDecodeAvgPower' && newPoint.measuredDecodeAvgPower) {
781+
newPoint.measuredDecodeAvgPower.roof = onCurrentRoofline;
754782
} else if (
755783
chartDefYKey === 'y_measuredJPerOutputToken' &&
756784
newPoint.measuredJPerOutputToken
757785
) {
758786
newPoint.measuredJPerOutputToken.roof = onCurrentRoofline;
759787
} else if (chartDefYKey === 'y_measuredJPerTotalToken' && newPoint.measuredJPerTotalToken) {
760788
newPoint.measuredJPerTotalToken.roof = onCurrentRoofline;
789+
} else if (chartDefYKey === 'y_measuredJPerInputToken' && newPoint.measuredJPerInputToken) {
790+
newPoint.measuredJPerInputToken.roof = onCurrentRoofline;
761791
}
762792
}
763793
finalProcessedData.push(newPoint);

0 commit comments

Comments
 (0)