diff --git a/packages/app/cypress/component/gpu-graph.cy.tsx b/packages/app/cypress/component/gpu-graph.cy.tsx index 0f70e13d..917f2721 100644 --- a/packages/app/cypress/component/gpu-graph.cy.tsx +++ b/packages/app/cypress/component/gpu-graph.cy.tsx @@ -76,6 +76,36 @@ describe('GPUGraph', () => { cy.contains('No data available').should('be.visible'); }); + it('explains missing role-local energy in GPU comparison mode', () => { + mountWithProviders( +
+ +
, + { + inference: { + hardwareConfig: hwConfig, + selectedGPUs: ['mi355x'], + selectedDates: ['2026-07-25'], + selectedDateRange: { startDate: '', endDate: '' }, + activeDates: new Set(['2026-07-25_mi355x']), + selectedPrecisions: [Precision.FP8], + selectedYAxisMetric: 'y_measuredDecodeJPerOutputToken', + }, + }, + ); + + cy.contains('This dataset does not report role-level prefill/decode energy.').should( + 'be.visible', + ); + }); + it('localizes the Chinese comparison empty state', () => { mountWithProviders( diff --git a/packages/app/cypress/component/scatter-graph.cy.tsx b/packages/app/cypress/component/scatter-graph.cy.tsx index 37298864..ab3c3b08 100644 --- a/packages/app/cypress/component/scatter-graph.cy.tsx +++ b/packages/app/cypress/component/scatter-graph.cy.tsx @@ -81,6 +81,37 @@ describe('ScatterGraph', () => { cy.contains('No data available').should('be.visible'); }); + it('explains when the selected dataset lacks role-local energy', () => { + mountWithProviders( +
+ +
, + { + inference: { + hardwareConfig: hwConfig, + activeHwTypes: new Set(['mi355x']), + hwTypesWithData: new Set(), + selectedYAxisMetric: 'y_measuredPrefillJPerInputToken', + }, + unofficial: {}, + }, + ); + + cy.contains('This dataset does not report role-level prefill/decode energy.').should( + 'be.visible', + ); + cy.contains( + 'Please change the model, sequence, precision, date range or chip selection.', + ).should('not.exist'); + }); + it('localizes the complete Chinese empty state', () => { mountWithProviders( @@ -109,6 +140,35 @@ describe('ScatterGraph', () => { cy.contains('No data available').should('not.exist'); }); + it('localizes the missing role-energy explanation', () => { + mountWithProviders( + +
+ +
+
, + { + inference: { + hardwareConfig: hwConfig, + activeHwTypes: new Set(['mi355x']), + hwTypesWithData: new Set(), + selectedYAxisMetric: 'y_measuredPrefillJPerInputToken', + }, + unofficial: {}, + }, + ); + + cy.contains('当前数据集未提供 Prefill/Decode 各角色的能耗数据。').should('be.visible'); + cy.contains('请调整模型、序列长度、精度、日期范围或芯片选项。').should('not.exist'); + }); + it('renders scatter points as shapes in SVG with mock data', () => { const data = [ createMockInferenceData({ diff --git a/packages/app/cypress/e2e/certified-power-filter.cy.ts b/packages/app/cypress/e2e/certified-power-filter.cy.ts index da77736f..bf57c1f3 100644 --- a/packages/app/cypress/e2e/certified-power-filter.cy.ts +++ b/packages/app/cypress/e2e/certified-power-filter.cy.ts @@ -1,10 +1,5 @@ -// Validated-vs-historical measured power (PLAN-02 / gap G2): on the Measured -// Energy y-axes, points without a producer validation verdict carry a dotted -// ring and a footer legend key, and Quick Filters gains a "Measured Power" -// category (Validated/Historical labels, stable `i_power` share-link values). -// Fixture rows are intercepted so one config is validated (power_valid=1) and -// one is historical (no verdict), keeping every assertion deterministic regardless of -// what the production dataset contains. +// Deterministic intercepted rows exercise the validated/historical power UI: +// one row has power_valid=1 and one has no validation verdict. const POWER_MODEL = 'dsv4'; const POWER_DATE = '2026-08-20'; @@ -102,13 +97,9 @@ describe('Validated vs historical measured power', () => { it('rings legacy points on a measured axis and filters them via Quick Filters', () => { visitCertifiedPowerChart(); - // No decorations off the Measured Energy axes. cy.get('.legacy-power-ring').should('not.exist'); cy.get('[data-testid="legacy-power-key"]').should('not.exist'); - // Pick "Measured Average Power per Chip" from the y-axis dropdown. The - // select list is a scroll container and Measured Energy sits below the - // fold, so scroll before clicking. cy.get('[data-testid="yaxis-metric-selector"]').click(); cy.contains('[role="option"]', 'Measured Average Power per Chip') .scrollIntoView() @@ -122,14 +113,11 @@ describe('Validated vs historical measured power', () => { .and('contain.text', '1/3 historical') .and('contain.text', 'Best per SKU and Optimal Only are enabled'); - // The no-verdict config is ringed; the certified one is not. The footer - // legend key appears with the ringed points. cy.get('.dot-group[data-hw-key^="b200"] .legacy-power-ring').should('exist'); cy.get('.dot-group[data-hw-key^="mi300x"] .legacy-power-ring').should('not.exist'); cy.get('[data-testid="legacy-power-key"]').should('be.visible'); cy.screenshot('legacy-power-rings', { capture: 'viewport' }); - // Quick Filters gains the Measured Power category with both pills enabled. cy.get('[data-testid="scatter-quick-filters"]').click(); cy.get('[data-testid="quick-filters-dialog"]').should('be.visible'); cy.get('[data-testid="quick-filter-power-certified"]').should('be.enabled'); @@ -140,8 +128,6 @@ describe('Validated vs historical measured power', () => { cy.contains('Both are shown by default.').should('be.visible'); cy.get('body').type('{esc}'); - // Certified-only: legacy points (and with them every ring and the legend - // key) leave the chart while the certified series stays. cy.get('[data-testid="quick-filter-power-certified"]').click(); cy.get('[data-testid="quick-filters-selected-count"]').should('contain.text', '1 selected'); cy.get('.dot-group[data-hw-key^="b200"]').should('not.exist'); @@ -151,7 +137,6 @@ describe('Validated vs historical measured power', () => { cy.get('[data-testid="inference-chart-display"] svg').should('exist'); cy.screenshot('certified-only-filter', { capture: 'viewport' }); - // Clear filters restores the legacy series, rings, and legend key. cy.contains('button', 'Clear filters').click(); cy.get('[data-testid="quick-filters-selected-count"]').should('not.exist'); cy.get('[data-testid="quick-filter-power-certified"]').should( diff --git a/packages/app/cypress/support/agentic-fixtures.ts b/packages/app/cypress/support/agentic-fixtures.ts index 66adff22..eeddd609 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/src/components/inference/axis-metric-explanations.ts b/packages/app/src/components/inference/axis-metric-explanations.ts index e3a2189c..0889c585 100644 --- a/packages/app/src/components/inference/axis-metric-explanations.ts +++ b/packages/app/src/components/inference/axis-metric-explanations.ts @@ -271,6 +271,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 @@ -361,6 +396,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/measured-power-direction.test.ts b/packages/app/src/components/inference/measured-power-direction.test.ts index a8762e11..8e4aaaf1 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 7fa6a80c..7c784b85 100644 --- a/packages/app/src/components/inference/metric-registry.test.ts +++ b/packages/app/src/components/inference/metric-registry.test.ts @@ -5,6 +5,7 @@ import { DEFAULT_METRIC_CONFIG_KEY, isBenchmarkMetricKey, isMeasuredEnergyConfigKey, + isRoleLocalMeasuredEnergyConfigKey, MEASURED_ENERGY_METRIC_CONFIG_KEYS, METRIC_CONFIG_KEYS, METRIC_CONTROL_GROUPS, @@ -63,6 +64,35 @@ describe('metric registry', () => { expect(controlMetrics.toSorted()).toEqual(METRIC_CONFIG_KEYS.toSorted()); }); + it('keeps the Measured Energy key list in lockstep with the registry', () => { + // Every registry key starting `measured` must be in the exported list, so + // a new telemetry metric cannot silently miss the tier decorations. + const measuredRegistryKeys = Object.keys(METRIC_REGISTRY) + .filter((key) => key.startsWith('measured')) + .map((key) => `y_${key}`); + expect([...MEASURED_ENERGY_METRIC_CONFIG_KEYS].toSorted()).toEqual( + measuredRegistryKeys.toSorted(), + ); + + const measuredGroup = METRIC_CONTROL_GROUPS.find((group) => group.label === 'Measured Energy'); + expect(measuredGroup?.metrics).toBe(MEASURED_ENERGY_METRIC_CONFIG_KEYS); + }); + + it('identifies only the role-local prefill and decode energy axes', () => { + expect(isRoleLocalMeasuredEnergyConfigKey('y_measuredPrefillJPerInputToken')).toBe(true); + expect(isRoleLocalMeasuredEnergyConfigKey('y_measuredDecodeJPerOutputToken')).toBe(true); + expect(isRoleLocalMeasuredEnergyConfigKey('y_measuredJPerOutputToken')).toBe(false); + expect(isRoleLocalMeasuredEnergyConfigKey('y_measuredPrefillAvgPower')).toBe(false); + }); + + it('classifies measured-energy config keys', () => { + expect(isMeasuredEnergyConfigKey('y_measuredAvgPower')).toBe(true); + expect(isMeasuredEnergyConfigKey('y_measuredWhPerSuccessfulQuery')).toBe(true); + expect(isMeasuredEnergyConfigKey('y_tpPerGpu')).toBe(false); + expect(isMeasuredEnergyConfigKey('y_jTotal')).toBe(false); + expect(isMeasuredEnergyConfigKey('y')).toBe(false); + }); + it('labels every infrastructure purchasing-power metric as TCO', () => { const metricKeys = [ 'tokensPerDollarH', @@ -155,6 +185,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); @@ -171,5 +207,7 @@ describe('metric compatibility', () => { expect(tokenMetricTypeForConfigKey('y_tokenRevenuePerGpuHour')).toBe('total'); expect(tokenMetricTypeForConfigKey('y_tokensPerDollarN')).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/metric-registry.ts b/packages/app/src/components/inference/metric-registry.ts index 2ee697e2..43fa6507 100644 --- a/packages/app/src/components/inference/metric-registry.ts +++ b/packages/app/src/components/inference/metric-registry.ts @@ -377,6 +377,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)', @@ -385,6 +393,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)', @@ -492,7 +508,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. @@ -502,7 +518,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', @@ -513,11 +531,21 @@ const MEASURED_ENERGY_METRIC_CONFIG_KEY_SET: ReadonlySet = new Set( MEASURED_ENERGY_METRIC_CONFIG_KEYS, ); +const ROLE_LOCAL_MEASURED_ENERGY_METRIC_CONFIG_KEY_SET: ReadonlySet = new Set([ + 'y_measuredPrefillJPerInputToken', + 'y_measuredDecodeJPerOutputToken', +]); + /** Whether a y-axis config key plots one of the Measured Energy metrics. */ export function isMeasuredEnergyConfigKey(configKey: string): boolean { return MEASURED_ENERGY_METRIC_CONFIG_KEY_SET.has(configKey); } +/** Whether a y-axis requires the explicit prefill/decode energy breakdown. */ +export function isRoleLocalMeasuredEnergyConfigKey(configKey: string): boolean { + return ROLE_LOCAL_MEASURED_ENERGY_METRIC_CONFIG_KEY_SET.has(configKey); +} + export const METRIC_CONTROL_GROUPS: readonly MetricControlGroup[] = [ { label: 'Throughput', diff --git a/packages/app/src/components/inference/types.ts b/packages/app/src/components/inference/types.ts index 4c7875ac..54596de1 100644 --- a/packages/app/src/components/inference/types.ts +++ b/packages/app/src/components/inference/types.ts @@ -339,6 +339,9 @@ export interface InferenceData extends Partial (count > 0 ? `Quick Filters (${count})` : 'Quick Filters'), noData: 'No data available', noDataHint: 'Please change the model, sequence, precision, date range or chip selection.', + noRoleEnergyDataHint: + 'This dataset does not report role-level prefill/decode energy. Choose a different model, scenario, precision, date, or measured-energy metric.', }, zh: { logScale: '对数缩放', @@ -127,6 +132,8 @@ const GPU_STRINGS = { quickFilters: (count: number) => (count > 0 ? `快捷筛选(${count})` : '快捷筛选'), noData: '暂无数据', noDataHint: '请调整模型、序列长度、精度、日期范围或芯片选项。', + noRoleEnergyDataHint: + '当前数据集未提供 Prefill/Decode 各角色的能耗数据。请选择其他模型、场景、精度、日期或实测能耗指标。', }, } as const; @@ -184,6 +191,9 @@ const GPUGraph = React.memo( const locale = useLocale(); const legendT = GPU_STRINGS[locale]; const isMeasuredEnergyAxis = isMeasuredEnergyConfigKey(selectedYAxisMetric); + const noDataHint = isRoleLocalMeasuredEnergyConfigKey(selectedYAxisMetric) + ? legendT.noRoleEnergyDataHint + : legendT.noDataHint; const ephemeralUrlState = useEphemeralUrlState(); const { resolvedTheme } = useTheme(); const chartRef = useRef(null); @@ -721,7 +731,7 @@ const GPUGraph = React.memo( />

{legendT.noData}

-

{legendT.noDataHint}

+

{noDataHint}