From df1dea764ad1e94172edb599f0f614a9f7f1ad75 Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Thu, 27 Aug 2026 12:00:25 -0700 Subject: [PATCH 1/4] =?UTF-8?q?feat(inference):=20role-local=20measured=20?= =?UTF-8?q?energy=20axes=20+=20per-worker=20power=20drilldown=20|=20?= =?UTF-8?q?=E6=8E=A8=E7=90=86=E5=9B=BE=E8=A1=A8=EF=BC=9A=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=A7=92=E8=89=B2=E7=BA=A7=E5=AE=9E=E6=B5=8B=E8=83=BD=E8=80=97?= =?UTF-8?q?=E5=9D=90=E6=A0=87=E8=BD=B4=E4=B8=8E=E6=AF=8F=20worker=20?= =?UTF-8?q?=E5=8A=9F=E8=80=97=E4=B8=8B=E9=92=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add y_measuredPrefillJPerInputToken and y_measuredDecodeJPerOutputToken to the Measured Energy group (bilingual labels, lower polarity, explanations), map prefill_joules_per_input_token / decode_joules_per_output_token into chart fields, and render a pinned-only per-worker power table (role, chips, watts, optional temp/util/mem/hosts, 8-row cap, HTML-escaped JSONB strings) in all three tooltip generators. 在“实测能耗”分组新增每输入 token 实测 Prefill 能耗与每输出 token 实测 Decode 能耗两个 Y 轴(双语标签、越低越好、附说明),将角色级能耗字段映射 为图表字段,并在三种工具提示中新增仅固定时显示的每 worker 功耗表 (角色、芯片数、功耗,可选温度/利用率/显存/主机,上限 8 行,HTML 转义)。 Co-Authored-By: Claude Fable 5 --- .../inference/axis-metric-explanations.ts | 37 +++++++++++ .../components/inference/metric-registry.ts | 20 +++++- .../app/src/components/inference/types.ts | 3 + .../inference/utils/tooltipUtils.ts | 64 +++++++++++++++++++ packages/app/src/lib/chart-utils.ts | 10 +++ 5 files changed, 133 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/inference/axis-metric-explanations.ts b/packages/app/src/components/inference/axis-metric-explanations.ts index 3fdf2a499..1356f361c 100644 --- a/packages/app/src/components/inference/axis-metric-explanations.ts +++ b/packages/app/src/components/inference/axis-metric-explanations.ts @@ -262,6 +262,41 @@ function measuredJoulesPerToken(tokenType: TokenType): MetricExplanation { }; } +const MEASURED_ROLE_EN: Record<'prefill' | 'decode', { tokens: string; isolates: string }> = { + prefill: { tokens: 'input (prompt)', isolates: 'prompt-processing' }, + decode: { tokens: 'output', isolates: 'token-generation' }, +}; + +const MEASURED_ROLE_ZH: Record<'prefill' | 'decode', { tokens: string; isolates: string }> = { + prefill: { tokens: '输入', isolates: '提示词处理' }, + decode: { tokens: '输出', isolates: 'token 生成' }, +}; + +function measuredRoleJoulesPerToken(role: 'prefill' | 'decode'): MetricExplanation { + return { + description: { + en: + `Measured accelerator energy consumed by the ${role} workers per ` + + `${MEASURED_ROLE_EN[role].tokens} token, from runner power telemetry integrated over ` + + `the run. Unlike the whole-deployment J/tok metrics, only that role's energy is ` + + `charged, so it isolates ${MEASURED_ROLE_EN[role].isolates} efficiency in ` + + `disaggregated deployments.${MEASURED_TIER_NOTE_EN}`, + zh: + `每个${MEASURED_ROLE_ZH[role].tokens} token 由 ${MEASURED_PHASE_ZH[role]}工作进程消耗的` + + `加速器实测能耗,由运行器功耗遥测在整个运行期间积分得到。与全部署 J/tok 指标不同,` + + `它只计入该角色的能耗,因此可以在分离式部署中单独衡量${ + MEASURED_ROLE_ZH[role].isolates + }效率。${MEASURED_TIER_NOTE_ZH}`, + }, + formula: { + en: + `J/tok = measured ${role}-worker energy over the run ÷ ` + + `${role === 'prefill' ? 'input' : 'output'} tokens processed`, + zh: `J/tok = 运行期间 ${role} 工作进程实测能耗 ÷ 处理的${MEASURED_ROLE_ZH[role].tokens} token 数`, + }, + }; +} + /** * Every `METRIC_REGISTRY` key gets a bilingual explanation and a structural * formula. Grounded in `buildDerivedChartFields` (src/lib/chart-utils.ts) and @@ -352,6 +387,8 @@ export const METRIC_EXPLANATIONS: Record = { measuredJPerOutputToken: measuredJoulesPerToken('output'), measuredJPerInputToken: measuredJoulesPerToken('input'), measuredJPerTotalToken: measuredJoulesPerToken('total'), + measuredPrefillJPerInputToken: measuredRoleJoulesPerToken('prefill'), + measuredDecodeJPerOutputToken: measuredRoleJoulesPerToken('decode'), measuredJPerSuccessfulQuery: { description: { en: diff --git a/packages/app/src/components/inference/metric-registry.ts b/packages/app/src/components/inference/metric-registry.ts index 02cd67037..4749f8f3e 100644 --- a/packages/app/src/components/inference/metric-registry.ts +++ b/packages/app/src/components/inference/metric-registry.ts @@ -375,6 +375,14 @@ export const METRIC_REGISTRY = { titleZh: '每输出 token 实测焦耳能耗', polarity: 'lower', }, + measuredDecodeJPerOutputToken: { + field: 'measuredDecodeJPerOutputToken.y', + label: 'Measured Decode J per Output Token (J/tok)', + labelZh: '每输出 token 实测 Decode 能耗(J/tok)', + title: 'Measured Decode Joules per Output Token', + titleZh: '每输出 token 实测 Decode 焦耳能耗', + polarity: 'lower', + }, measuredJPerInputToken: { field: 'measuredJPerInputToken.y', label: 'Measured J per Input Token (J/tok)', @@ -383,6 +391,14 @@ export const METRIC_REGISTRY = { titleZh: '每输入 token 实测焦耳能耗', polarity: 'lower', }, + measuredPrefillJPerInputToken: { + field: 'measuredPrefillJPerInputToken.y', + label: 'Measured Prefill J per Input Token (J/tok)', + labelZh: '每输入 token 实测 Prefill 能耗(J/tok)', + title: 'Measured Prefill Joules per Input Token', + titleZh: '每输入 token 实测 Prefill 焦耳能耗', + polarity: 'lower', + }, measuredJPerTotalToken: { field: 'measuredJPerTotalToken.y', label: 'Measured J per Token (J/tok)', @@ -484,7 +500,7 @@ export interface MetricControlGroup { } /** - * The nine runner-telemetry y-axes in the "Measured Energy" control group. + * The runner-telemetry y-axes in the "Measured Energy" control group. * Exported (and referenced by the group below, so the two cannot drift) for * consumers that treat measured axes specially — the legacy-power point ring, * tooltip tier line, and footer legend key. @@ -494,7 +510,9 @@ export const MEASURED_ENERGY_METRIC_CONFIG_KEYS = [ 'y_measuredDecodeAvgPower', 'y_measuredAvgPower', 'y_measuredJPerInputToken', + 'y_measuredPrefillJPerInputToken', 'y_measuredJPerOutputToken', + 'y_measuredDecodeJPerOutputToken', 'y_measuredJPerTotalToken', 'y_measuredJPerSuccessfulQuery', 'y_measuredWhPerSuccessfulQuery', diff --git a/packages/app/src/components/inference/types.ts b/packages/app/src/components/inference/types.ts index e95ca1fb0..68bbc6198 100644 --- a/packages/app/src/components/inference/types.ts +++ b/packages/app/src/components/inference/types.ts @@ -336,6 +336,9 @@ export interface InferenceData extends Partial + s + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + +const WORKER_POWER_STRINGS = { + en: { + heading: 'Measured Worker Power', + chips: 'chips', + more: (n: number) => `+${n} more workers`, + }, + zh: { + heading: '各 Worker 实测功耗', + chips: '芯片', + more: (n: number) => `另有 ${n} 个 worker`, + }, +} as const; + +/** Pinned tooltips list at most this many workers before the "+N more" line. */ +const WORKER_ROWS_LIMIT = 8; + +/** + * Per-worker measured power drilldown. Rendered only while the tooltip is + * pinned (same rule as the point-detail actions) so hover tooltips stay lean; + * nothing renders when `workers` is absent or empty — production AgentX rows + * currently ship without it. + */ +const generateWorkerPowerHTML = (d: InferenceData, isPinned: boolean, locale: Locale): string => { + if (!isPinned || !Array.isArray(d.workers) || d.workers.length === 0) return ''; + const t = WORKER_POWER_STRINGS[locale]; + const rows = d.workers.slice(0, WORKER_ROWS_LIMIT).map((w) => { + const parts = [ + `${escapeHtml(w.role)}[${w.worker_idx}]`, + `${w.num_gpus} ${t.chips}`, + `${fmt(w.avg_power_w)} W`, + ]; + if (typeof w.avg_temp_c === 'number') { + parts.push( + typeof w.peak_temp_c === 'number' + ? `${fmt(w.avg_temp_c)}/${fmt(w.peak_temp_c)}°C` + : `${fmt(w.avg_temp_c)}°C`, + ); + } + if (typeof w.avg_util_pct === 'number') parts.push(`${fmt(w.avg_util_pct)}%`); + if (typeof w.avg_mem_used_mb === 'number') parts.push(`${fmt(w.avg_mem_used_mb / 1024)} GiB`); + if (Array.isArray(w.hosts) && w.hosts.length > 0) parts.push(escapeHtml(w.hosts.join(','))); + return `
${parts.join(' · ')}
`; + }); + const overflow = d.workers.length - WORKER_ROWS_LIMIT; + return `
+
${t.heading}
+ ${rows.join('')} + ${overflow > 0 ? `
${t.more(overflow)}
` : ''} +
`; +}; + const CACHE_STRINGS = { en: { offloadType: 'Offload Type', @@ -483,6 +544,7 @@ export const generateTooltipContent = (config: TooltipConfig): string => { ${tooltipLine(t.precision, `${d.precision.toUpperCase()}`)} ${generateCacheMetadataHTML(d, locale)} ${generateAgenticHTML(d, locale)} + ${generateWorkerPowerHTML(d, isPinned, locale)} ${runLinkHTML(runUrl)} ${viewActionsHTML(isPinned, Boolean(hasTrace), Boolean(config.hasLog), d.id, d.benchmark_type, locale)} @@ -524,6 +586,7 @@ export const generateOverlayTooltipContent = (config: OverlayTooltipConfig): str ${tooltipLine(t.precision, `${d.precision.toUpperCase()}`)} ${generateCacheMetadataHTML(d, locale)} ${generateAgenticHTML(d, locale)} + ${generateWorkerPowerHTML(d, isPinned, locale)} `; }; @@ -582,6 +645,7 @@ export const generateGPUGraphTooltipContent = (config: TooltipConfig): string => ${tooltipLine(t.precision, `${d.precision.toUpperCase()}`)} ${generateCacheMetadataHTML(d, locale)} ${generateAgenticHTML(d, locale)} + ${generateWorkerPowerHTML(d, isPinned, locale)} ${runLinkHTML(runUrl)} ${viewActionsHTML(isPinned, Boolean(hasTrace), Boolean(hasLog), d.id, d.benchmark_type, locale)} diff --git a/packages/app/src/lib/chart-utils.ts b/packages/app/src/lib/chart-utils.ts index 7d3271656..36964ed2d 100644 --- a/packages/app/src/lib/chart-utils.ts +++ b/packages/app/src/lib/chart-utils.ts @@ -550,6 +550,8 @@ type MeasuredPowerChartFields = Partial< | 'measuredJPerOutputToken' | 'measuredJPerTotalToken' | 'measuredJPerInputToken' + | 'measuredPrefillJPerInputToken' + | 'measuredDecodeJPerOutputToken' | 'measuredJPerSuccessfulQuery' | 'measuredWhPerSuccessfulQuery' | 'measuredPowerPercentTdp' @@ -580,6 +582,14 @@ function buildMeasuredPowerChartFields( ...(typeof entry.joules_per_input_token === 'number' ? { measuredJPerInputToken: chartMetric(entry.joules_per_input_token) } : {}), + // Role-local energy is not additionally gated on power_valid here — + // rowToAggDataEntry already scrubbed it, same as the role-watts fields. + ...(typeof entry.prefill_joules_per_input_token === 'number' + ? { measuredPrefillJPerInputToken: chartMetric(entry.prefill_joules_per_input_token) } + : {}), + ...(typeof entry.decode_joules_per_output_token === 'number' + ? { measuredDecodeJPerOutputToken: chartMetric(entry.decode_joules_per_output_token) } + : {}), ...(typeof entry.joules_per_successful_query === 'number' ? { measuredJPerSuccessfulQuery: chartMetric(entry.joules_per_successful_query), From d5d9919c72263b2c05071c784f45558bad532197 Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Thu, 27 Aug 2026 12:03:28 -0700 Subject: [PATCH 2/4] =?UTF-8?q?test(inference):=20cover=20agentic=20measur?= =?UTF-8?q?ed=20power,=20role-energy=20axes,=20and=20worker=20drilldown=20?= =?UTF-8?q?|=20=E6=B5=8B=E8=AF=95=EF=BC=9A=E8=A6=86=E7=9B=96=20AgentX=20?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E5=8A=9F=E8=80=97=E3=80=81=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E7=BA=A7=E8=83=BD=E8=80=97=E5=9D=90=E6=A0=87=E8=BD=B4=E4=B8=8E?= =?UTF-8?q?=20worker=20=E4=B8=8B=E9=92=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agentic-traces transform pass-through and power_valid=0 scrub (including workers), role-energy chart-field emission/absence/zero-preservation, Pareto corners and bilingual labels for the two new axes, input/output token classification and share-link round-trip, and the pinned worker table across all three tooltip generators (cap, ZH, HTML escaping). 覆盖 agentic_traces 变换透传与 power_valid=0 清除(含 workers)、角色级 能耗图表字段的生成/缺省/零值保留、两个新坐标轴的帕累托方向与双语标签、 输入/输出 token 分类与分享链接解析回环,以及三种工具提示中固定状态下 的 worker 表(行数上限、中文、HTML 转义)。 Co-Authored-By: Claude Fable 5 --- .../measured-power-direction.test.ts | 15 ++ .../inference/metric-registry.test.ts | 8 ++ .../inference/utils/tooltip-utils.test.ts | 131 ++++++++++++++++++ .../app/src/lib/benchmark-transform.test.ts | 102 ++++++++++++++ packages/app/src/lib/chart-utils.test.ts | 30 ++++ 5 files changed, 286 insertions(+) diff --git a/packages/app/src/components/inference/measured-power-direction.test.ts b/packages/app/src/components/inference/measured-power-direction.test.ts index a8762e110..8e4aaaf19 100644 --- a/packages/app/src/components/inference/measured-power-direction.test.ts +++ b/packages/app/src/components/inference/measured-power-direction.test.ts @@ -35,6 +35,11 @@ const QUERY_ENERGY_METRICS = [ 'y_measuredWhPerSuccessfulQuery', ] as const; +const ROLE_ENERGY_METRICS = [ + 'y_measuredPrefillJPerInputToken', + 'y_measuredDecodeJPerOutputToken', +] as const; + const defs = chartDefinitions as unknown as ChartDefinition[]; const interactivityDef = defs.find((d) => d.chartType === 'interactivity')!; const e2eDef = defs.find((d) => d.chartType === 'e2e')!; @@ -149,6 +154,16 @@ describe('measured-power Pareto direction', () => { } }); + it.each(ROLE_ENERGY_METRICS)('%s is bilingual and lower-is-better', (metric) => { + expect(declaredDirection(interactivityDef, metric)).toBe('lower_right'); + expect(declaredDirection(e2eDef, metric)).toBe('lower_left'); + for (const chartDef of [interactivityDef, e2eDef]) { + expect(chartDef[metric]).toMatch(/\.y$/u); + expect(chartDef[`${metric}_label`]).toBeTruthy(); + expect(chartDef[`${metric}_labelZh`]).toBeTruthy(); + } + }); + it('leaves %TDP without a Pareto direction on either block', () => { // %TDP is a utilization gauge, not an efficiency frontier: a config running // hotter is not "worse" along an axis the roofline can order, so declaring a diff --git a/packages/app/src/components/inference/metric-registry.test.ts b/packages/app/src/components/inference/metric-registry.test.ts index adb561c56..e78ef83e5 100644 --- a/packages/app/src/components/inference/metric-registry.test.ts +++ b/packages/app/src/components/inference/metric-registry.test.ts @@ -87,6 +87,12 @@ describe('metric compatibility', () => { expect(resolveMetricConfigKey('y_measuredJPerSuccessfulQuery')).toBe( 'y_measuredJPerSuccessfulQuery', ); + expect(resolveMetricConfigKey('y_measuredPrefillJPerInputToken')).toBe( + 'y_measuredPrefillJPerInputToken', + ); + expect(resolveMetricConfigKey('y_measuredDecodeJPerOutputToken')).toBe( + 'y_measuredDecodeJPerOutputToken', + ); expect(resolveMetricConfigKey('y_costUser')).toBe('y_costUser'); expect(isBenchmarkMetricKey('tpPerGpu')).toBe(true); expect(isBenchmarkMetricKey('tokenRevenuePerGpuHour')).toBe(true); @@ -101,5 +107,7 @@ describe('metric compatibility', () => { expect(tokenMetricTypeForConfigKey('y_tpPerGpu')).toBe('total'); expect(tokenMetricTypeForConfigKey('y_tokenRevenuePerGpuHour')).toBe('total'); expect(tokenMetricTypeForConfigKey('y_measuredAvgPower')).toBe('total'); + expect(tokenMetricTypeForConfigKey('y_measuredPrefillJPerInputToken')).toBe('input'); + expect(tokenMetricTypeForConfigKey('y_measuredDecodeJPerOutputToken')).toBe('output'); }); }); diff --git a/packages/app/src/components/inference/utils/tooltip-utils.test.ts b/packages/app/src/components/inference/utils/tooltip-utils.test.ts index 44237f5f4..48e3c0ec1 100644 --- a/packages/app/src/components/inference/utils/tooltip-utils.test.ts +++ b/packages/app/src/components/inference/utils/tooltip-utils.test.ts @@ -727,6 +727,137 @@ describe('generateGPUGraphTooltipContent', () => { }); }); +// =========================================================================== +// per-worker measured power drilldown (all three generators, pinned-only) +// =========================================================================== +describe('worker power drilldown', () => { + const workers = [ + { role: 'frontend', worker_idx: 0, hosts: ['fe0'], num_gpus: 0, avg_power_w: 120 }, + { + role: 'prefill', + worker_idx: 0, + hosts: ['pn0'], + num_gpus: 8, + avg_power_w: 612.3, + avg_temp_c: 68.4, + peak_temp_c: 79.2, + avg_util_pct: 88.5, + avg_mem_used_mb: 71234.5, + }, + { role: 'decode', worker_idx: 0, hosts: ['dn0'], num_gpus: 8, avg_power_w: 701.5 }, + ]; + + const overlayData = { + label: 'feature-branch', + hardwareConfig: mockHardwareConfig, + data: [], + runUrl: 'https://example.com', + } as any; + + it('renders the worker table on a pinned tooltip in all three generators', () => { + const config = tooltipConfig({ data: pt({ workers }), isPinned: true }); + const outputs = [ + generateTooltipContent(config), + generateOverlayTooltipContent({ ...config, overlayData }), + generateGPUGraphTooltipContent(config), + ]; + for (const html of outputs) { + expect(html).toContain('data-testid="tooltip-worker-power"'); + expect(html).toContain('Measured Worker Power'); + expect(html).toContain('prefill[0]'); + expect(html).toContain('612.3 W'); + expect(html).toContain('decode[0]'); + expect(html).toContain('701.5 W'); + expect(html).toContain('frontend[0]'); + expect(html).toContain('0 chips'); + expect(html).toContain('pn0'); + } + }); + + it('includes optional telemetry cells only when the worker carries them', () => { + const html = generateTooltipContent(tooltipConfig({ data: pt({ workers }), isPinned: true })); + // Prefill worker: avg/peak temp, util %, mem in GiB (71234.5 MB ≈ 69.565 GiB). + expect(html).toContain('68.4/79.2°C'); + expect(html).toContain('88.5%'); + expect(html).toContain('69.565 GiB'); + // Decode worker row (no telemetry) keeps only role/chips/watts/hosts. + const decodeRow = html.split('decode[0]')[1].split('')[0]; + expect(decodeRow).not.toContain('°C'); + expect(decodeRow).not.toContain('%'); + expect(decodeRow).not.toContain('GiB'); + }); + + it('renders nothing when the tooltip is not pinned', () => { + const config = tooltipConfig({ data: pt({ workers }), isPinned: false }); + expect(generateTooltipContent(config)).not.toContain('tooltip-worker-power'); + expect(generateOverlayTooltipContent({ ...config, overlayData })).not.toContain( + 'tooltip-worker-power', + ); + expect(generateGPUGraphTooltipContent(config)).not.toContain('tooltip-worker-power'); + }); + + it('renders nothing when workers is absent or empty', () => { + expect(generateTooltipContent(tooltipConfig({ isPinned: true }))).not.toContain( + 'tooltip-worker-power', + ); + expect( + generateTooltipContent(tooltipConfig({ data: pt({ workers: [] }), isPinned: true })), + ).not.toContain('tooltip-worker-power'); + }); + + it('caps the table at 8 rows with a "+N more workers" line', () => { + const many = Array.from({ length: 10 }, (_, i) => ({ + role: 'decode', + worker_idx: i, + num_gpus: 8, + avg_power_w: 700 + i, + })); + const html = generateTooltipContent( + tooltipConfig({ data: pt({ workers: many }), isPinned: true }), + ); + expect(html).toContain('decode[7]'); + expect(html).not.toContain('decode[8]'); + expect(html).toContain('+2 more workers'); + }); + + it('renders the ZH strings under the zh locale', () => { + const html = generateTooltipContent( + tooltipConfig({ data: pt({ workers }), isPinned: true, locale: 'zh' }), + ); + expect(html).toContain('各 Worker 实测功耗'); + expect(html).toContain('8 芯片'); + const many = Array.from({ length: 9 }, (_, i) => ({ + role: 'decode', + worker_idx: i, + num_gpus: 8, + avg_power_w: 700, + })); + const capped = generateTooltipContent( + tooltipConfig({ data: pt({ workers: many }), isPinned: true, locale: 'zh' }), + ); + expect(capped).toContain('另有 1 个 worker'); + }); + + it('HTML-escapes role and hosts strings from the JSONB boundary', () => { + const hostile = [ + { + role: '', + worker_idx: 0, + hosts: [''], + num_gpus: 8, + avg_power_w: 700, + }, + ]; + const html = generateTooltipContent( + tooltipConfig({ data: pt({ workers: hostile }), isPinned: true }), + ); + expect(html).not.toContain(''); + expect(html).toContain('<img src=x onerror=alert(1)>'); + expect(html).toContain('<script>evil</script>'); + }); +}); + // =========================================================================== // measured-power certification tier line (all three generators) // =========================================================================== diff --git a/packages/app/src/lib/benchmark-transform.test.ts b/packages/app/src/lib/benchmark-transform.test.ts index a834c28f7..8cc155fbd 100644 --- a/packages/app/src/lib/benchmark-transform.test.ts +++ b/packages/app/src/lib/benchmark-transform.test.ts @@ -650,6 +650,108 @@ describe('rowToAggDataEntry', () => { }); }); +describe('rowToAggDataEntry — agentic measured power', () => { + // AgentX (agentic_traces) rows carry the same measured-power contract as + // fixed-seq rows; nothing in the transform may key off benchmark_type. + const agenticPowerMetrics = { + tput_per_gpu: 100, + power_valid: 1, + power_metric_schema_version: 2, + avg_power_w: 685.5, + prefill_avg_power_w: 612.3, + decode_avg_power_w: 701.5, + joules_per_input_token: 1.2, + joules_per_output_token: 9.7, + joules_per_total_token: 0.8, + joules_per_successful_query: 1542.75, + prefill_joules_per_input_token: 0.4, + decode_joules_per_output_token: 5.1, + avg_temp_c: 68.4, + peak_temp_c: 79.2, + avg_util_pct: 88.5, + avg_mem_used_mb: 71234.5, + }; + const agenticWorkers = [ + { + role: 'prefill' as const, + worker_idx: 0, + hosts: ['pn0'], + num_gpus: 8, + avg_power_w: 612.3, + avg_temp_c: 68.4, + avg_util_pct: 88.5, + }, + { role: 'decode' as const, worker_idx: 0, hosts: ['dn0'], num_gpus: 8, avg_power_w: 701.5 }, + ]; + const agenticPowerRow = (metricOverrides: Record = {}) => + makeRow({ + benchmark_type: 'agentic_traces', + isl: null, + osl: null, + disagg: true, + metrics: { ...agenticPowerMetrics, ...metricOverrides }, + workers: agenticWorkers, + }); + + it('passes the full measured-power payload through for a validated agentic disagg row', () => { + const entry = rowToAggDataEntry(agenticPowerRow()); + + expect(entry.benchmark_type).toBe('agentic_traces'); + expect(entry.power_valid).toBe(1); + expect(entry.power_metric_schema_version).toBe(2); + expect(entry.power_tier).toBe('certified'); + expect(entry.avg_power_w).toBe(685.5); + expect(entry.prefill_avg_power_w).toBe(612.3); + expect(entry.decode_avg_power_w).toBe(701.5); + expect(entry.joules_per_input_token).toBe(1.2); + expect(entry.joules_per_output_token).toBe(9.7); + expect(entry.joules_per_total_token).toBe(0.8); + expect(entry.joules_per_successful_query).toBe(1542.75); + expect(entry.prefill_joules_per_input_token).toBe(0.4); + expect(entry.decode_joules_per_output_token).toBe(5.1); + expect(entry.avg_temp_c).toBe(68.4); + expect(entry.peak_temp_c).toBe(79.2); + expect(entry.avg_util_pct).toBe(88.5); + expect(entry.avg_mem_used_mb).toBe(71234.5); + expect(entry.workers).toEqual(agenticWorkers); + }); + + it('carries agentic role energy onto chart points as the new role-local axes', () => { + const { chartData } = transformBenchmarkRows([agenticPowerRow()]); + const point = chartData.find((data) => data.length > 0)![0]; + expect(point.measuredPrefillJPerInputToken?.y).toBe(0.4); + expect(point.measuredDecodeJPerOutputToken?.y).toBe(5.1); + expect(point.workers).toEqual(agenticWorkers); + }); + + it('scrubs all measured telemetry (including workers) on an agentic power_valid=0 row', () => { + const row = agenticPowerRow({ power_valid: 0 }); + const entry = rowToAggDataEntry(row); + + expect(entry.avg_power_w).toBeUndefined(); + expect(entry.prefill_avg_power_w).toBeUndefined(); + expect(entry.decode_avg_power_w).toBeUndefined(); + expect(entry.joules_per_input_token).toBeUndefined(); + expect(entry.joules_per_output_token).toBeUndefined(); + expect(entry.joules_per_total_token).toBeUndefined(); + expect(entry.joules_per_successful_query).toBeUndefined(); + expect(entry.prefill_joules_per_input_token).toBeUndefined(); + expect(entry.decode_joules_per_output_token).toBeUndefined(); + expect(entry.avg_temp_c).toBeUndefined(); + expect(entry.peak_temp_c).toBeUndefined(); + expect(entry.avg_util_pct).toBeUndefined(); + expect(entry.avg_mem_used_mb).toBeUndefined(); + expect(entry.workers).toBeUndefined(); + expect(entry.power_tier).toBeUndefined(); + + const { chartData } = transformBenchmarkRows([row]); + const point = chartData.find((data) => data.length > 0)![0]; + expect(point.measuredPrefillJPerInputToken).toBeUndefined(); + expect(point.measuredDecodeJPerOutputToken).toBeUndefined(); + expect(point.workers).toBeUndefined(); + }); +}); + describe('transformBenchmarkRows', () => { it('returns empty arrays for empty input', () => { const { chartData, hardwareConfig } = transformBenchmarkRows([]); diff --git a/packages/app/src/lib/chart-utils.test.ts b/packages/app/src/lib/chart-utils.test.ts index f3114f760..da2c93df8 100644 --- a/packages/app/src/lib/chart-utils.test.ts +++ b/packages/app/src/lib/chart-utils.test.ts @@ -1154,6 +1154,36 @@ describe('createChartDataPoint per-stage measured power fields', () => { expect(point.measuredJPerInputToken!.y).toBe(0.18); expect(point.measuredJPerOutputToken!.y).toBe(1.64); }); + + it('emits role-local energy fields when the role joules scalars are present', () => { + const e = entry({ prefill_joules_per_input_token: 0.4, decode_joules_per_output_token: 5.1 }); + const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100'); + expect(point.measuredPrefillJPerInputToken).toBeDefined(); + expect(point.measuredPrefillJPerInputToken!.y).toBe(0.4); + expect(point.measuredPrefillJPerInputToken!.roof).toBe(false); + expect(point.measuredDecodeJPerOutputToken).toBeDefined(); + expect(point.measuredDecodeJPerOutputToken!.y).toBe(5.1); + expect(point.measuredDecodeJPerOutputToken!.roof).toBe(false); + }); + + it('omits role-local energy fields on rows without the role joules scalars', () => { + // Single-node aggregated (and legacy) rows never carry role energy — the + // fields must be absent (not 0) so the coverage filter drops them from the + // role-energy axes rather than plotting fake data. + const e = entry({ avg_power_w: 685.5, joules_per_output_token: 8.4 }); + const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100'); + expect(point.measuredPrefillJPerInputToken).toBeUndefined(); + expect(point.measuredDecodeJPerOutputToken).toBeUndefined(); + }); + + it('preserves a zero role-local energy value (not falsy-coerced away)', () => { + const e = entry({ prefill_joules_per_input_token: 0, decode_joules_per_output_token: 0 }); + const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100'); + expect(point.measuredPrefillJPerInputToken).toBeDefined(); + expect(point.measuredPrefillJPerInputToken!.y).toBe(0); + expect(point.measuredDecodeJPerOutputToken).toBeDefined(); + expect(point.measuredDecodeJPerOutputToken!.y).toBe(0); + }); }); // =========================================================================== From ffcc9728cfa8a2be74bf0ac3f1a39949ea767cfa Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Thu, 27 Aug 2026 12:08:46 -0700 Subject: [PATCH 3/4] =?UTF-8?q?test(e2e):=20AgentX=20measured-power=20spec?= =?UTF-8?q?=20with=20synthetic=20workers=20|=20=E7=AB=AF=E5=88=B0=E7=AB=AF?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=9A=E4=BD=BF=E7=94=A8=E5=90=88=E6=88=90?= =?UTF-8?q?=20worker=20=E6=95=B0=E6=8D=AE=E7=9A=84=20AgentX=20=E5=AE=9E?= =?UTF-8?q?=E6=B5=8B=E5=8A=9F=E8=80=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec-scoped intercepts inject a disagg agentic series (role energy + workers[]) and an aggregate series (whole-run metrics, workers: null): the two role-energy axes render under Measured Energy, the aggregate series coverage-filters off while the legend stays intact, the pinned tooltip shows the worker table, and a workers-less point stays clean. 通过 spec 级拦截注入分离式 agentic 序列(角色级能耗 + workers[])与 聚合序列(全程指标、workers 为 null):两个角色级能耗坐标轴显示在 “实测能耗”分组下,聚合序列因无该指标从图中过滤但图例保持完整, 固定的工具提示显示 worker 表,无 workers 的点保持正常。 Co-Authored-By: Claude Fable 5 --- .../cypress/e2e/agentic-measured-power.cy.ts | 157 ++++++++++++++++++ .../app/cypress/support/agentic-fixtures.ts | 55 ++++++ packages/app/timings.json | 4 + 3 files changed, 216 insertions(+) create mode 100644 packages/app/cypress/e2e/agentic-measured-power.cy.ts diff --git a/packages/app/cypress/e2e/agentic-measured-power.cy.ts b/packages/app/cypress/e2e/agentic-measured-power.cy.ts new file mode 100644 index 000000000..d064e4341 --- /dev/null +++ b/packages/app/cypress/e2e/agentic-measured-power.cy.ts @@ -0,0 +1,157 @@ +// AgentX measured power (PLAN-10 / gap G15): the role-local energy axes +// (Measured Prefill/Decode J per token) render for agentic runs, and pinned +// tooltips carry the per-worker power drilldown when the row ships workers[]. +// +// The shared cypress/fixtures/api/*.json files contain ZERO agentic rows (by +// design), so this spec injects agentic availability + benchmark rows via +// spec-scoped intercepts, following ttft-x-axis-toggle.cy.ts. Production +// AgentX rows currently ship workers: null (producer emission lands with +// PLAN-09), so the synthetic workers here verify the UI ahead of real data — +// and the workers-less series proves the graceful-absence path. +import { interceptDerivedAgenticMetrics, unlockAgenticGate } from '../support/e2e'; +import { + agenticMetrics, + measuredPowerMetrics, + syntheticWorkers, +} from '../support/agentic-fixtures'; + +const MODEL_DB_KEY = 'dsv4'; // DeepSeek-V4-Pro +const AGENTIC_DATE = '2026-06-12'; + +// One disaggregated series carrying role energy + workers, one aggregate +// series with whole-run measured metrics only and no workers. +const POWER_GPUS = [ + { hardware: 'b200', framework: 'vllm', disagg: true }, + { hardware: 'b300', framework: 'vllm', disagg: false }, +]; + +const agenticAvailability = POWER_GPUS.flatMap((g) => [ + { + model: MODEL_DB_KEY, + isl: null, + osl: null, + precision: 'fp4', + hardware: g.hardware, + framework: g.framework, + spec_method: 'none', + disagg: g.disagg, + benchmark_type: 'agentic_traces', + date: AGENTIC_DATE, + }, + { + model: MODEL_DB_KEY, + isl: 8192, + osl: 1024, + precision: 'fp4', + hardware: g.hardware, + framework: g.framework, + spec_method: 'none', + disagg: g.disagg, + benchmark_type: 'single_turn', + date: AGENTIC_DATE, + }, +]); + +let benchIdCursor = 930000; +const agenticBenchmarks = POWER_GPUS.flatMap((g) => + [16, 64, 128].map((conc) => ({ + id: benchIdCursor++, + hardware: g.hardware, + framework: g.framework, + model: MODEL_DB_KEY, + precision: 'fp4', + spec_method: 'none', + disagg: g.disagg, + is_multinode: g.disagg, + prefill_tp: 8, + prefill_ep: 1, + prefill_dp_attention: false, + prefill_num_workers: g.disagg ? 1 : 0, + decode_tp: 8, + decode_ep: 1, + decode_dp_attention: false, + decode_num_workers: g.disagg ? 1 : 0, + num_prefill_gpu: 8, + num_decode_gpu: 8, + isl: null, + osl: null, + conc, + offload_mode: 'off', + benchmark_type: 'agentic_traces', + image: 'vllm/vllm-openai:v0.9.0', + metrics: { ...agenticMetrics(conc), ...measuredPowerMetrics(conc, { disagg: g.disagg }) }, + workers: g.disagg ? syntheticWorkers(true) : null, + date: AGENTIC_DATE, + run_url: null, + })), +); + +const DISAGG_DOTS = '.dot-group[data-hw-key^="b200"]'; +const AGGREGATE_DOTS = '.dot-group[data-hw-key^="b300"]'; + +describe('AgentX measured power (role energy axes + worker drilldown)', () => { + beforeEach(() => { + cy.intercept('GET', '/api/v1/availability', { body: agenticAvailability }).as('availability'); + cy.intercept('GET', '/api/v1/benchmarks*', { body: agenticBenchmarks }).as('benchmarks'); + interceptDerivedAgenticMetrics(); + // No stored traces or logs for the synthetic ids: keeps the pinned + // tooltip free of the "View charts/logs" actions this spec doesn't test. + cy.intercept('GET', '/api/v1/trace-availability*', (request) => { + const ids = new URL(request.url).searchParams.get('ids')?.split(',').filter(Boolean) ?? []; + request.reply({ body: Object.fromEntries(ids.map((id) => [id, false])) }); + }); + cy.intercept('GET', '/api/v1/log-availability*', (request) => { + const ids = new URL(request.url).searchParams.get('ids')?.split(',').filter(Boolean) ?? []; + request.reply({ body: Object.fromEntries(ids.map((id) => [id, false])) }); + }); + cy.visit('/inference?i_seq=agentic-traces', { + onBeforeLoad(win) { + win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now())); + unlockAgenticGate(win); + }, + }); + cy.get('[data-testid="scatter-graph"] .dot-group').should('have.length.greaterThan', 0); + }); + + it('offers the role-energy axes and coverage-filters series without role energy', () => { + cy.get('[data-testid="yaxis-metric-selector"]').click(); + cy.contains('[data-slot="select-content"]', 'Measured Energy') + .scrollIntoView() + .should('be.visible'); + cy.contains('[role="option"]', 'Measured Prefill Joules per Input Token') + .scrollIntoView() + .should('be.visible'); + cy.contains('[role="option"]', 'Measured Decode Joules per Output Token') + .scrollIntoView() + .should('be.visible'); + + cy.contains('[role="option"]', 'Measured Prefill Joules per Input Token').click(); + cy.get('[data-slot="select-content"]').should('not.exist'); + + // Only the disagg series carries prefill_joules_per_input_token; the + // aggregate series drops off the chart while the legend universe stays + // intact (selectionPoints reconciliation). + cy.get(DISAGG_DOTS).should('have.length', 3); + cy.get(AGGREGATE_DOTS).should('not.exist'); + cy.get('[data-testid="chart-legend"]').should('contain.text', 'B200'); + cy.get('[data-testid="chart-legend"]').should('contain.text', 'B300'); + }); + + it('renders the per-worker power table on a pinned agentic tooltip', () => { + cy.get(`${DISAGG_DOTS} .visible-shape`).first().click({ force: true }); + + cy.get('[data-chart-tooltip]:visible').should('have.length', 1); + cy.get('[data-chart-tooltip]:visible [data-testid="tooltip-worker-power"]') + .should('exist') + .and('contain.text', 'prefill[0]') + .and('contain.text', '612.3 W') + .and('contain.text', 'decode[0]'); + }); + + it('stays graceful when the pinned point has no workers payload', () => { + cy.get(`${AGGREGATE_DOTS} .visible-shape`).first().click({ force: true }); + + cy.get('[data-chart-tooltip]:visible').should('have.length', 1); + cy.get('[data-testid="tooltip-worker-power"]').should('not.exist'); + }); +}); diff --git a/packages/app/cypress/support/agentic-fixtures.ts b/packages/app/cypress/support/agentic-fixtures.ts index 66adff223..eeddd609a 100644 --- a/packages/app/cypress/support/agentic-fixtures.ts +++ b/packages/app/cypress/support/agentic-fixtures.ts @@ -9,6 +9,61 @@ export function percentileLadder(prefix: string, base: number): Record { + const scale = concurrency / 16; + return { + power_valid: 1, + power_metric_schema_version: 2, + avg_power_w: 600 + 10 * scale, + joules_per_input_token: 0.3 / scale, + joules_per_output_token: 8 / scale, + joules_per_total_token: 0.9 / scale, + joules_per_successful_query: 1500 / scale, + avg_temp_c: 65 + scale, + avg_util_pct: 80 + scale, + ...(opts.disagg + ? { + prefill_avg_power_w: 612.3, + decode_avg_power_w: 701.5, + prefill_joules_per_input_token: 0.4 / scale, + decode_joules_per_output_token: 5.1 / scale, + } + : {}), + }; +} + +/** + * WorkerPower-shaped rows for the pinned-tooltip drilldown. Plain object + * literals — cypress support files never import types from src. + */ +export function syntheticWorkers(disagg: boolean) { + if (!disagg) { + return [{ role: 'agg', worker_idx: 0, hosts: ['n0'], num_gpus: 8, avg_power_w: 640.2 }]; + } + return [ + { role: 'frontend', worker_idx: 0, hosts: ['fe0'], num_gpus: 0, avg_power_w: 120 }, + { + role: 'prefill', + worker_idx: 0, + hosts: ['pn0'], + num_gpus: 8, + avg_power_w: 612.3, + avg_temp_c: 68.4, + avg_util_pct: 88.5, + }, + { role: 'decode', worker_idx: 0, hosts: ['dn0'], num_gpus: 8, avg_power_w: 701.5 }, + ]; +} + export function agenticMetrics(concurrency: number): Record { const scale = concurrency / 16; const itl = 0.011 * scale; diff --git a/packages/app/timings.json b/packages/app/timings.json index 145f41443..3dad140dd 100644 --- a/packages/app/timings.json +++ b/packages/app/timings.json @@ -8,6 +8,10 @@ "spec": "cypress/e2e/agentic-detail-back-nav.cy.ts", "duration": 2000 }, + { + "spec": "cypress/e2e/agentic-measured-power.cy.ts", + "duration": 1500 + }, { "spec": "cypress/e2e/agentic-point-coach-mark.cy.ts", "duration": 8000 From 703aaae0543e9821c1db550d62fdb3838a9a8513 Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Thu, 27 Aug 2026 12:41:52 -0700 Subject: [PATCH 4/4] =?UTF-8?q?fix(e2e):=20assert=20coverage-filtered=20se?= =?UTF-8?q?ries=20leaves=20the=20legend,=20prove=20non-destructive=20axis?= =?UTF-8?q?=20switch=20|=20=E7=AB=AF=E5=88=B0=E7=AB=AF=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=96=AD=E8=A8=80=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=E4=BC=9A=E7=A7=BB=E5=87=BA=E5=9B=BE=E4=BE=8B?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E9=AA=8C=E8=AF=81=E5=9D=90=E6=A0=87=E8=BD=B4?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E5=8F=AF=E6=97=A0=E6=8D=9F=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rendered legend is metric-aware (hwTypesWithData), so a series without the selected role-energy metric drops out of the legend too — the previous assertion expected it to stay and failed deterministically. Re-selecting a whole-run measured axis now proves the selection universe survives the switch. Also documents the MiB reading of avg_mem_used_mb in the worker tooltip. 图例渲染依据所选指标(hwTypesWithData),缺少角色级能耗指标的系列会同时从图例中 移除,原断言必然失败。现改为断言其不在图例中,并通过重新选择全局实测坐标轴验证 选择集在切换后无损恢复。同时为 worker 工具提示中 avg_mem_used_mb 的 MiB 语义 补充说明。 Co-Authored-By: Claude Fable 5 --- .../app/cypress/e2e/agentic-measured-power.cy.ts | 13 +++++++++++-- .../src/components/inference/utils/tooltipUtils.ts | 2 ++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/app/cypress/e2e/agentic-measured-power.cy.ts b/packages/app/cypress/e2e/agentic-measured-power.cy.ts index d064e4341..411173a03 100644 --- a/packages/app/cypress/e2e/agentic-measured-power.cy.ts +++ b/packages/app/cypress/e2e/agentic-measured-power.cy.ts @@ -129,11 +129,20 @@ describe('AgentX measured power (role energy axes + worker drilldown)', () => { cy.get('[data-slot="select-content"]').should('not.exist'); // Only the disagg series carries prefill_joules_per_input_token; the - // aggregate series drops off the chart while the legend universe stays - // intact (selectionPoints reconciliation). + // aggregate series is coverage-filtered off the chart AND out of the + // rendered legend (hwTypesWithData is metric-aware — InferenceContext). cy.get(DISAGG_DOTS).should('have.length', 3); cy.get(AGGREGATE_DOTS).should('not.exist'); cy.get('[data-testid="chart-legend"]').should('contain.text', 'B200'); + cy.get('[data-testid="chart-legend"]').should('not.contain.text', 'B300'); + + // The switch is non-destructive: the selection universe never intersects + // metric coverage (selectableHwTypes), so picking a whole-run measured + // axis both series carry brings the aggregate series straight back. + cy.get('[data-testid="yaxis-metric-selector"]').click(); + cy.contains('[role="option"]', 'Measured Joules per Output Token').scrollIntoView().click(); + cy.get('[data-slot="select-content"]').should('not.exist'); + cy.get(AGGREGATE_DOTS).should('have.length', 3); cy.get('[data-testid="chart-legend"]').should('contain.text', 'B300'); }); diff --git a/packages/app/src/components/inference/utils/tooltipUtils.ts b/packages/app/src/components/inference/utils/tooltipUtils.ts index e4e8625f0..da37ec9d9 100644 --- a/packages/app/src/components/inference/utils/tooltipUtils.ts +++ b/packages/app/src/components/inference/utils/tooltipUtils.ts @@ -219,6 +219,8 @@ const generateWorkerPowerHTML = (d: InferenceData, isPinned: boolean, locale: Lo ); } if (typeof w.avg_util_pct === 'number') parts.push(`${fmt(w.avg_util_pct)}%`); + // avg_mem_used_mb follows the nvidia-smi/telemetry convention (MiB despite + // the _mb suffix), hence /1024 → GiB. Revisit if PLAN-09's producer differs. if (typeof w.avg_mem_used_mb === 'number') parts.push(`${fmt(w.avg_mem_used_mb / 1024)} GiB`); if (Array.isArray(w.hosts) && w.hosts.length > 0) parts.push(escapeHtml(w.hosts.join(','))); return `
${parts.join(' · ')}
`;