From c24e8f74765ef8b83ecc36928201e67b56940832 Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Thu, 27 Aug 2026 11:11:29 -0700 Subject: [PATCH 01/20] =?UTF-8?q?feat(inference):=20derive=20measured-powe?= =?UTF-8?q?r=20certification=20tier=20with=20quick=20filter=20|=20?= =?UTF-8?q?=E6=8E=A8=E7=90=86=E5=9B=BE=E8=A1=A8=EF=BC=9A=E6=B4=BE=E7=94=9F?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E5=8A=9F=E8=80=97=E8=AE=A4=E8=AF=81=E5=B1=82?= =?UTF-8?q?=E7=BA=A7=E5=B9=B6=E6=96=B0=E5=A2=9E=E5=BF=AB=E6=8D=B7=E7=AD=9B?= =?UTF-8?q?=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compute a per-entry power_tier ('certified' | 'legacy') in rowToAggDataEntry from the producer verdict and whole-deployment energy semantics, without changing which values render. Add a 'Measured Power' quick-filter category (Certified/Legacy pills, i_power URL param) wired through QuickFilters, InferenceContext, the dialog, and GPUGraph. 在 rowToAggDataEntry 中根据生产端验证结论与整体部署能耗语义派生 power_tier(certified/legacy),不改变任何渲染值。新增“实测功耗” 快捷筛选(已认证/旧版,URL 参数 i_power)。 --- .../component/quick-filters-dialog.cy.tsx | 3 + packages/app/cypress/support/mock-data.ts | 5 +- .../components/inference/InferenceContext.tsx | 21 ++++++- .../app/src/components/inference/types.ts | 12 ++++ .../src/components/inference/ui/GPUGraph.tsx | 4 ++ .../inference/ui/QuickFiltersDialog.tsx | 42 +++++++++++--- .../ui/ScatterGraph.test-harness.tsx | 4 +- .../inference/utils/quickFilters.ts | 32 ++++++++++- packages/app/src/lib/benchmark-transform.ts | 57 ++++++++++++------- packages/app/src/lib/power-tier.ts | 39 +++++++++++++ packages/app/src/lib/url-state.ts | 5 +- 11 files changed, 187 insertions(+), 37 deletions(-) create mode 100644 packages/app/src/lib/power-tier.ts diff --git a/packages/app/cypress/component/quick-filters-dialog.cy.tsx b/packages/app/cypress/component/quick-filters-dialog.cy.tsx index 86cf90525..d1f42b81d 100644 --- a/packages/app/cypress/component/quick-filters-dialog.cy.tsx +++ b/packages/app/cypress/component/quick-filters-dialog.cy.tsx @@ -8,6 +8,7 @@ const availableQuickFilters: QuickFilters = { frameworks: ['vllm', 'sglang'], deployment: ['single-node', 'multi-node', 'disagg'], spec: ['mtp', 'stp'], + power: ['certified', 'legacy'], }; describe('QuickFiltersDialog', () => { @@ -47,6 +48,7 @@ describe('QuickFiltersDialog', () => { frameworks: ['sglang'], deployment: ['disagg'], spec: ['mtp'], + power: ['certified'], }, availableQuickFilters, }, @@ -57,5 +59,6 @@ describe('QuickFiltersDialog', () => { cy.get('@setQuickFilterFrameworks').should('have.been.calledWith', []); cy.get('@setQuickFilterDeployment').should('have.been.calledWith', []); cy.get('@setQuickFilterSpec').should('have.been.calledWith', []); + cy.get('@setQuickFilterPower').should('have.been.calledWith', []); }); }); diff --git a/packages/app/cypress/support/mock-data.ts b/packages/app/cypress/support/mock-data.ts index b75ef5342..3c36350b1 100644 --- a/packages/app/cypress/support/mock-data.ts +++ b/packages/app/cypress/support/mock-data.ts @@ -230,12 +230,13 @@ export function createMockInferenceContextValues( setSelectedXAxisMode: namedStub('setSelectedXAxisMode'), scaleType: 'auto', setScaleType: namedStub('setScaleType'), - quickFilters: { vendors: [], frameworks: [], deployment: [], spec: [] }, - availableQuickFilters: { vendors: [], frameworks: [], deployment: [], spec: [] }, + quickFilters: { vendors: [], frameworks: [], deployment: [], spec: [], power: [] }, + availableQuickFilters: { vendors: [], frameworks: [], deployment: [], spec: [], power: [] }, setQuickFilterVendors: namedStub('setQuickFilterVendors'), setQuickFilterFrameworks: namedStub('setQuickFilterFrameworks'), setQuickFilterDeployment: namedStub('setQuickFilterDeployment'), setQuickFilterSpec: namedStub('setQuickFilterSpec'), + setQuickFilterPower: namedStub('setQuickFilterPower'), isLegendExpanded: true, setIsLegendExpanded: namedStub('setIsLegendExpanded'), hideNonOptimal: false, diff --git a/packages/app/src/components/inference/InferenceContext.tsx b/packages/app/src/components/inference/InferenceContext.tsx index 29476d2fa..d61277aad 100644 --- a/packages/app/src/components/inference/InferenceContext.tsx +++ b/packages/app/src/components/inference/InferenceContext.tsx @@ -95,7 +95,9 @@ import { bestSeriesPerSku } from './utils/best-series-per-sku'; import { EMPTY_QUICK_FILTERS, parseDeploymentModes, + parsePowerTiers, type DeploymentMode, + type PowerTier, type QuickFilters, type SpecMode, } from './utils/quickFilters'; @@ -425,7 +427,7 @@ export function InferenceProvider({ () => (getUrlParam('i_scale') as 'auto' | 'linear' | 'log') || 'auto', ); - // ── Quick filters (vendor / framework / deployment / mtp-stp) ─────────────── + // ── Quick filters (vendor / framework / deployment / mtp-stp / power tier) ── // Coarse pre-filters applied to the point set. Empty = no constraint. // // Initialized empty rather than from the URL so the first client render matches @@ -438,8 +440,9 @@ export function InferenceProvider({ const [quickFilterFrameworks, setQuickFilterFrameworks] = useState([]); const [quickFilterDeployment, setQuickFilterDeployment] = useState([]); const [quickFilterSpec, setQuickFilterSpec] = useState([]); + const [quickFilterPower, setQuickFilterPower] = useState([]); useEffect(() => { - const parse = (key: 'i_vendor' | 'i_fw' | 'i_disagg' | 'i_spec') => { + const parse = (key: 'i_vendor' | 'i_fw' | 'i_disagg' | 'i_spec' | 'i_power') => { const v = getUrlParam(key); return v ? v.split(',').filter(Boolean) : []; }; @@ -449,10 +452,12 @@ export function InferenceProvider({ // point, so expand it to both aggregate deployment modes. const deployment = parseDeploymentModes(parse('i_disagg')); const spec = parse('i_spec') as SpecMode[]; + const power = parsePowerTiers(parse('i_power')); if (vendors.length > 0) setQuickFilterVendors(vendors); if (frameworks.length > 0) setQuickFilterFrameworks(frameworks); if (deployment.length > 0) setQuickFilterDeployment(deployment); if (spec.length > 0) setQuickFilterSpec(spec); + if (power.length > 0) setQuickFilterPower(power); }, [getUrlParam]); const quickFilters = useMemo( () => ({ @@ -460,8 +465,15 @@ export function InferenceProvider({ frameworks: quickFilterFrameworks, deployment: quickFilterDeployment, spec: quickFilterSpec, + power: quickFilterPower, }), - [quickFilterVendors, quickFilterFrameworks, quickFilterDeployment, quickFilterSpec], + [ + quickFilterVendors, + quickFilterFrameworks, + quickFilterDeployment, + quickFilterSpec, + quickFilterPower, + ], ); // Historical Trends hides Quick Filters, so never apply invisible selections there. // Agentic charts expose vendor, framework, and deployment filters, but speculative @@ -1473,6 +1485,7 @@ export function InferenceProvider({ i_fw: quickFilterFrameworks.join(','), i_disagg: quickFilterDeployment.join(','), i_spec: quickFilterSpec.join(','), + i_power: quickFilterPower.join(','), }, [ selectedYAxisMetric, @@ -1499,6 +1512,7 @@ export function InferenceProvider({ quickFilterFrameworks, quickFilterDeployment, quickFilterSpec, + quickFilterPower, ], ); @@ -1763,6 +1777,7 @@ export function InferenceProvider({ setQuickFilterFrameworks, setQuickFilterDeployment, setQuickFilterSpec, + setQuickFilterPower, setIsLegendExpanded, setHideNonOptimal, setShowPointLabels, diff --git a/packages/app/src/components/inference/types.ts b/packages/app/src/components/inference/types.ts index 0c99174d8..e95ca1fb0 100644 --- a/packages/app/src/components/inference/types.ts +++ b/packages/app/src/components/inference/types.ts @@ -3,6 +3,7 @@ import type { WorkerPower } from '@semianalysisai/inferencex-db/queries/benchmar import type { HardwareEntry } from '@/lib/constants'; import type { Model, Sequence } from '@/lib/data-mappings'; +import type { PowerTier } from '@/lib/power-tier'; import type { MetricKey } from './metric-registry'; export type { WorkerPower }; @@ -115,6 +116,14 @@ export interface AggDataEntry { // Optional because historical runs predate the fields. power_valid?: number; power_metric_schema_version?: number; + /** + * Certification tier for the measured power telemetry, derived by + * `resolvePowerTier` in the transform: `certified` for producer-validated + * rows (with whole-deployment energy semantics where applicable), `legacy` + * for telemetry that predates the validation contract, absent when no + * measured telemetry survives gating. + */ + power_tier?: PowerTier; avg_power_w?: number; joules_per_successful_query?: number; joules_per_output_token?: number; @@ -555,6 +564,8 @@ export interface QuickFilters { frameworks: string[]; deployment: DeploymentMode[]; spec: SpecMode[]; + /** Measured-power certification tiers (see `@/lib/power-tier`). */ + power: PowerTier[]; } /** @@ -660,6 +671,7 @@ export interface InferenceActionsContextType { setQuickFilterFrameworks: (frameworks: string[]) => void; setQuickFilterDeployment: (modes: DeploymentMode[]) => void; setQuickFilterSpec: (modes: SpecMode[]) => void; + setQuickFilterPower: (tiers: PowerTier[]) => void; setIsLegendExpanded: (expanded: boolean) => void; setHideNonOptimal: (hide: boolean) => void; setShowPointLabels: (show: boolean) => void; diff --git a/packages/app/src/components/inference/ui/GPUGraph.tsx b/packages/app/src/components/inference/ui/GPUGraph.tsx index 23023482d..6e08ebdbe 100644 --- a/packages/app/src/components/inference/ui/GPUGraph.tsx +++ b/packages/app/src/components/inference/ui/GPUGraph.tsx @@ -169,6 +169,7 @@ const GPUGraph = React.memo( setQuickFilterFrameworks, setQuickFilterDeployment, setQuickFilterSpec, + setQuickFilterPower, } = useInferenceActions(); const locale = useLocale(); const legendT = GPU_STRINGS[locale]; @@ -179,17 +180,20 @@ const GPUGraph = React.memo( quickFilters.vendors.length + quickFilters.frameworks.length + quickFilters.deployment.length + + quickFilters.power.length + (selectedSequence === Sequence.AgenticTraces ? 0 : quickFilters.spec.length); const clearQuickFilters = useCallback(() => { setQuickFilterVendors([]); setQuickFilterFrameworks([]); setQuickFilterDeployment([]); setQuickFilterSpec([]); + setQuickFilterPower([]); }, [ setQuickFilterVendors, setQuickFilterFrameworks, setQuickFilterDeployment, setQuickFilterSpec, + setQuickFilterPower, ]); // Shared date+GPU pairs. `dates` holds comparison-series entries (plain dates diff --git a/packages/app/src/components/inference/ui/QuickFiltersDialog.tsx b/packages/app/src/components/inference/ui/QuickFiltersDialog.tsx index 5ebf351b5..fca8d4623 100644 --- a/packages/app/src/components/inference/ui/QuickFiltersDialog.tsx +++ b/packages/app/src/components/inference/ui/QuickFiltersDialog.tsx @@ -4,6 +4,7 @@ import { ListFilter } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; import type { DeploymentMode, SpecMode } from '@/components/inference/types'; +import type { PowerTier } from '@/lib/power-tier'; import { FRAMEWORK_FAMILIES } from '@/components/inference/utils/quickFilters'; import { @@ -29,9 +30,9 @@ const STRINGS = { en: { title: 'Quick Filters', description: - 'Narrow the chart by chip vendor, serving framework, deployment mode, and speculative decoding. Selecting none in a group shows all.', + 'Narrow the chart by chip vendor, serving framework, deployment mode, speculative decoding, and measured-power certification. Selecting none in a group shows all.', agenticDescription: - 'Narrow the chart by chip vendor, serving framework, and deployment mode. Selecting none in a group shows all.', + 'Narrow the chart by chip vendor, serving framework, deployment mode, and measured-power certification. Selecting none in a group shows all.', selected: 'selected', bestPerSku: 'Best per SKU', bestPerSkuHint: 'Show only the best configuration for each chip', @@ -42,14 +43,19 @@ const STRINGS = { multiNode: 'Multi-node', disaggregated: 'Disaggregated', specDecoding: 'Spec Decoding', + power: 'Measured Power', + certified: 'Certified', + legacyTier: 'Legacy', noData: 'No data for the current selection', clear: 'Clear filters', done: 'Done', }, zh: { title: '快捷筛选', - description: '按芯片厂商、推理框架、部署模式和投机解码筛选图表。某组不选则显示全部。', - agenticDescription: '按芯片厂商、推理框架和部署模式筛选图表。某组不选则显示全部。', + description: + '按芯片厂商、推理框架、部署模式、投机解码和实测功耗认证筛选图表。某组不选则显示全部。', + agenticDescription: + '按芯片厂商、推理框架、部署模式和实测功耗认证筛选图表。某组不选则显示全部。', selected: '项已选', bestPerSku: '每个 SKU 仅显示最佳配置', bestPerSkuHint: '每款芯片只显示表现最佳的配置', @@ -60,6 +66,9 @@ const STRINGS = { multiNode: '多节点聚合', disaggregated: '分离式', specDecoding: '投机解码', + power: '实测功耗', + certified: '已认证', + legacyTier: '旧版', noData: '当前选择无可用数据', clear: '清除筛选', done: '完成', @@ -75,6 +84,7 @@ const SPEC_MODES: { value: SpecMode; label: string }[] = [ { value: 'mtp', label: 'MTP' }, { value: 'stp', label: 'STP' }, ]; +const POWER_TIERS: PowerTier[] = ['certified', 'legacy']; function toggleValue(current: T[], value: T): T[] { return current.includes(value) ? current.filter((item) => item !== value) : [...current, value]; @@ -98,6 +108,7 @@ export function QuickFiltersDialog({ setQuickFilterFrameworks, setQuickFilterDeployment, setQuickFilterSpec, + setQuickFilterPower, } = useInferenceActions(); const { selectedSequence, quickFilters } = useInferenceFilters(); const { availableQuickFilters } = useInferenceData(); @@ -114,7 +125,7 @@ export function QuickFiltersDialog({ })); const groups: { - key: 'vendor' | 'framework' | 'deployment' | 'spec'; + key: 'vendor' | 'framework' | 'deployment' | 'spec' | 'power'; label: string; options: readonly { value: string; label: string; available: boolean }[]; selected: readonly string[]; @@ -166,12 +177,24 @@ export function QuickFiltersDialog({ selected: quickFilters.spec, }, ]), + // Shown for agentic too — the pills auto-disable when no measured + // telemetry exists for the current selection. + { + key: 'power' as const, + label: t.power, + options: POWER_TIERS.map((value) => ({ + value, + label: value === 'certified' ? t.certified : t.legacyTier, + available: availableQuickFilters.power.includes(value), + })), + selected: quickFilters.power, + }, ]; const selectedCount = groups.reduce((count, group) => count + group.selected.length, 0); const handleToggle = ( - category: 'vendor' | 'framework' | 'deployment' | 'spec', + category: 'vendor' | 'framework' | 'deployment' | 'spec' | 'power', value: string, ) => { const wasActive = @@ -181,12 +204,16 @@ export function QuickFiltersDialog({ ? quickFilters.frameworks.includes(value) : category === 'deployment' ? quickFilters.deployment.includes(value as DeploymentMode) - : quickFilters.spec.includes(value as SpecMode); + : category === 'power' + ? quickFilters.power.includes(value as PowerTier) + : quickFilters.spec.includes(value as SpecMode); if (category === 'vendor') setQuickFilterVendors(toggleValue(quickFilters.vendors, value)); else if (category === 'framework') setQuickFilterFrameworks(toggleValue(quickFilters.frameworks, value)); else if (category === 'deployment') setQuickFilterDeployment(toggleValue(quickFilters.deployment, value as DeploymentMode)); + else if (category === 'power') + setQuickFilterPower(toggleValue(quickFilters.power, value as PowerTier)); else setQuickFilterSpec(toggleValue(quickFilters.spec, value as SpecMode)); track('inference_quick_filter_toggled', { category, value, active: !wasActive }); }; @@ -196,6 +223,7 @@ export function QuickFiltersDialog({ setQuickFilterFrameworks([]); setQuickFilterDeployment([]); setQuickFilterSpec([]); + setQuickFilterPower([]); track('inference_quick_filters_cleared', { source: 'dialog' }); }; diff --git a/packages/app/src/components/inference/ui/ScatterGraph.test-harness.tsx b/packages/app/src/components/inference/ui/ScatterGraph.test-harness.tsx index 952013187..99d91f476 100644 --- a/packages/app/src/components/inference/ui/ScatterGraph.test-harness.tsx +++ b/packages/app/src/components/inference/ui/ScatterGraph.test-harness.tsx @@ -124,8 +124,8 @@ export function baseInferenceState() { openRouterPricingLoading: false, openRouterPricingError: null, setTokenRevenuePriceSource: noop, - quickFilters: { vendors: [], frameworks: [], deployment: [], spec: [] }, - availableQuickFilters: { vendors: [], frameworks: [], deployment: [], spec: [] }, + quickFilters: { vendors: [], frameworks: [], deployment: [], spec: [], power: [] }, + availableQuickFilters: { vendors: [], frameworks: [], deployment: [], spec: [], power: [] }, availableRuns: {}, selectedRunId: '', hideNonOptimal: false, diff --git a/packages/app/src/components/inference/utils/quickFilters.ts b/packages/app/src/components/inference/utils/quickFilters.ts index 763738455..c7352e36e 100644 --- a/packages/app/src/components/inference/utils/quickFilters.ts +++ b/packages/app/src/components/inference/utils/quickFilters.ts @@ -8,15 +8,17 @@ import type { SpecMode, } from '@/components/inference/types'; import { frameworkFamily } from '@/lib/framework-family'; +import type { PowerTier } from '@/lib/power-tier'; -export type { AvailableQuickFilters, DeploymentMode, QuickFilters, SpecMode }; +export type { AvailableQuickFilters, DeploymentMode, PowerTier, QuickFilters, SpecMode }; /** Vendor display order for the quick-filter pills. */ const VENDOR_ORDER = ['NVIDIA', 'AMD']; /** * Quick filters let users narrow the chart to any combination of GPU vendor, - * serving framework, deployment mode, and speculative-decoding method without + * serving framework, deployment mode, speculative-decoding method, and + * measured-power certification tier without * touching the legend or GPU-config selectors. They are coarse pre-filters * applied to the point set (official + unofficial-run overlay), so the legend, * rooflines, and Pareto all reflect only the matching configs. @@ -31,8 +33,12 @@ export const EMPTY_QUICK_FILTERS: QuickFilters = { frameworks: [], deployment: [], spec: [], + power: [], }; +/** Measured-power tier display order for the quick-filter pills. */ +const POWER_TIER_ORDER: readonly PowerTier[] = ['certified', 'legacy']; + /** * Serving-framework families surfaced as quick filters, in display order. Each * family groups its base + variant engines (e.g. TRTLLM covers `trt`, `trtllm`, @@ -63,6 +69,8 @@ export function computeAvailableQuickFilters( let hasDisagg = false; let hasMtp = false; let hasStp = false; + let hasCertified = false; + let hasLegacy = false; for (const p of points) { const vendor = pointVendor(String(p.hwKey)); if (vendor) vendors.add(vendor); @@ -74,6 +82,8 @@ export function computeAvailableQuickFilters( else hasSingleNode = true; if (pointSpecMode(p) === 'mtp') hasMtp = true; else hasStp = true; + if (p.power_tier === 'certified') hasCertified = true; + else if (p.power_tier === 'legacy') hasLegacy = true; } const deployment: DeploymentMode[] = []; if (hasSingleNode) deployment.push('single-node'); @@ -87,13 +97,18 @@ export function computeAvailableQuickFilters( frameworks: FRAMEWORK_FAMILY_ORDER.filter((f) => frameworks.has(f)), deployment, spec, + power: POWER_TIER_ORDER.filter((tier) => (tier === 'certified' ? hasCertified : hasLegacy)), }; } /** True when at least one category constrains the point set. */ export function quickFiltersActive(f: QuickFilters): boolean { return ( - f.vendors.length > 0 || f.frameworks.length > 0 || f.deployment.length > 0 || f.spec.length > 0 + f.vendors.length > 0 || + f.frameworks.length > 0 || + f.deployment.length > 0 || + f.spec.length > 0 || + f.power.length > 0 ); } @@ -133,6 +148,13 @@ export function parseDeploymentModes(values: readonly string[]): DeploymentMode[ ); } +/** Parse `i_power` URL values, deduping and dropping unknown strings. */ +export function parsePowerTiers(values: readonly string[]): PowerTier[] { + return [...new Set(values)].filter( + (tier): tier is PowerTier => tier === 'certified' || tier === 'legacy', + ); +} + /** Whether a single data point satisfies every active quick-filter category. */ export function matchesQuickFilters(point: InferenceData, f: QuickFilters): boolean { if (f.vendors.length > 0) { @@ -145,6 +167,10 @@ export function matchesQuickFilters(point: InferenceData, f: QuickFilters): bool } if (f.deployment.length > 0 && !f.deployment.includes(pointDeploymentMode(point))) return false; if (f.spec.length > 0 && !f.spec.includes(pointSpecMode(point))) return false; + if (f.power.length > 0) { + const tier = point.power_tier; + if (!tier || !f.power.includes(tier)) return false; + } return true; } diff --git a/packages/app/src/lib/benchmark-transform.ts b/packages/app/src/lib/benchmark-transform.ts index 4036af80c..161e95d46 100644 --- a/packages/app/src/lib/benchmark-transform.ts +++ b/packages/app/src/lib/benchmark-transform.ts @@ -19,6 +19,7 @@ import { } from '@/lib/chart-utils'; import { getHardwareConfig } from '@/lib/constants'; import { isPersistedBenchmarkId } from '@/lib/benchmark-id'; +import { resolvePowerTier } from '@/lib/power-tier'; import type { BenchmarkRow } from '@/lib/api'; /** @@ -118,6 +119,31 @@ export function rowToAggDataEntry(row: BenchmarkRow): AggDataEntry { // failure that made versioning necessary in the first place. const hasWholeDeploymentEnergySemantics = !row.disagg || m.power_metric_schema_version === WHOLE_DEPLOYMENT_ENERGY_SCHEMA_VERSION; + // Gated measured power values, hoisted so the tier below can see exactly + // what the chart will render (behavior identical to the previous inline + // conditionals in the returned object). + const avgPowerW = measuredPowerValid ? m.avg_power_w : undefined; + const prefillAvgPowerW = measuredPowerValid ? m.prefill_avg_power_w : undefined; + const decodeAvgPowerW = measuredPowerValid ? m.decode_avg_power_w : undefined; + const joulesPerSuccessfulQuery = + measuredPowerValid && hasWholeDeploymentEnergySemantics + ? m.joules_per_successful_query + : undefined; + const joulesPerOutputToken = + measuredPowerValid && hasWholeDeploymentEnergySemantics ? m.joules_per_output_token : undefined; + const joulesPerTotalToken = + measuredPowerValid && hasWholeDeploymentEnergySemantics ? m.joules_per_total_token : undefined; + const joulesPerInputToken = + measuredPowerValid && hasWholeDeploymentEnergySemantics ? m.joules_per_input_token : undefined; + const hasMeasuredTelemetry = [ + avgPowerW, + prefillAvgPowerW, + decodeAvgPowerW, + joulesPerSuccessfulQuery, + joulesPerOutputToken, + joulesPerTotalToken, + joulesPerInputToken, + ].some((value) => typeof value === 'number'); // Prefer the dedicated column (added in migration 004); fall back to the // legacy stash inside `metrics` for any rows ingested before that column // existed. @@ -194,27 +220,20 @@ export function rowToAggDataEntry(row: BenchmarkRow): AggDataEntry { // "no measurement" from "0 W" via createChartDataPoint's typeof guard. power_valid: m.power_valid, power_metric_schema_version: m.power_metric_schema_version, - avg_power_w: measuredPowerValid ? m.avg_power_w : undefined, - joules_per_successful_query: - measuredPowerValid && hasWholeDeploymentEnergySemantics - ? m.joules_per_successful_query - : undefined, - joules_per_output_token: - measuredPowerValid && hasWholeDeploymentEnergySemantics - ? m.joules_per_output_token - : undefined, - joules_per_total_token: - measuredPowerValid && hasWholeDeploymentEnergySemantics - ? m.joules_per_total_token - : undefined, + power_tier: resolvePowerTier({ + powerValid: m.power_valid, + wholeDeploymentSemantics: hasWholeDeploymentEnergySemantics, + hasMeasuredTelemetry, + }), + avg_power_w: avgPowerW, + joules_per_successful_query: joulesPerSuccessfulQuery, + joules_per_output_token: joulesPerOutputToken, + joules_per_total_token: joulesPerTotalToken, // Role power remains unambiguous across schema versions. Version 2 also // publishes explicit role energy alongside whole-deployment joules. - prefill_avg_power_w: measuredPowerValid ? m.prefill_avg_power_w : undefined, - decode_avg_power_w: measuredPowerValid ? m.decode_avg_power_w : undefined, - joules_per_input_token: - measuredPowerValid && hasWholeDeploymentEnergySemantics - ? m.joules_per_input_token - : undefined, + prefill_avg_power_w: prefillAvgPowerW, + decode_avg_power_w: decodeAvgPowerW, + joules_per_input_token: joulesPerInputToken, prefill_joules_per_input_token: measuredPowerValid ? m.prefill_joules_per_input_token : undefined, diff --git a/packages/app/src/lib/power-tier.ts b/packages/app/src/lib/power-tier.ts new file mode 100644 index 000000000..0c7b6f9fe --- /dev/null +++ b/packages/app/src/lib/power-tier.ts @@ -0,0 +1,39 @@ +/** + * Certification tier for measured power telemetry on a benchmark entry. + * + * - `certified` — the producer (runner's `aggregate_power.py`) explicitly + * validated the measurement (`power_valid === 1`) AND the row's unprefixed + * joules fields carry whole-deployment semantics (non-disagg rows always do; + * disagg rows only under `WHOLE_DEPLOYMENT_ENERGY_SCHEMA_VERSION`, see + * `benchmark-transform.ts`). + * - `legacy` — measured telemetry renders but predates the validation + * contract: no verdict at all, or a disagg verdict without the versioned + * whole-deployment energy semantics. + * + * Absent (`undefined`) means no measured telemetry survives field gating. + */ +export type PowerTier = 'certified' | 'legacy'; + +/** + * Resolve the certification tier for one entry's measured power telemetry. + * + * Legacy admission is deliberate: historical single-node rows were never run + * through the producer validator, and hiding them would vanish the bulk of + * the measured-power dataset. An explicit `power_valid === 0` verdict is + * authoritative the other way — its telemetry is scrubbed upstream, so the + * row carries no tier rather than a "legacy" one. + */ +export function resolvePowerTier(args: { + /** Producer verdict: 1 valid, 0 invalid, undefined for legacy rows. */ + powerValid: number | undefined; + /** + * Whether the unprefixed joules fields mean whole-deployment energy + * (`!disagg || power_metric_schema_version === WHOLE_DEPLOYMENT_ENERGY_SCHEMA_VERSION`). + */ + wholeDeploymentSemantics: boolean; + /** Whether any measured power/energy value survives field gating. */ + hasMeasuredTelemetry: boolean; +}): PowerTier | undefined { + if (!args.hasMeasuredTelemetry || args.powerValid === 0) return undefined; + return args.powerValid === 1 && args.wholeDeploymentSemantics ? 'certified' : 'legacy'; +} diff --git a/packages/app/src/lib/url-state.ts b/packages/app/src/lib/url-state.ts index 244ee8bd3..4aecaa886 100644 --- a/packages/app/src/lib/url-state.ts +++ b/packages/app/src/lib/url-state.ts @@ -51,12 +51,14 @@ const URL_STATE_KEYS = [ 'i_gradlabel', 'i_linelabel', 'i_active', - // Quick filters (vendor / framework / deployment / mtp-stp). + // Quick filters (vendor / framework / deployment / mtp-stp / power tier). // `i_disagg` keeps its historical name for shared-link compatibility. 'i_vendor', 'i_fw', 'i_disagg', 'i_spec', + // Measured-power certification tiers ('certified' / 'legacy', comma-joined). + 'i_power', // Exact serving-envelope pair behind an Overview 30-day comparison cell. 'i_overview_current', 'i_overview_baseline', @@ -156,6 +158,7 @@ export const PARAM_DEFAULTS: Record = { i_fw: '', i_disagg: '', i_spec: '', + i_power: '', i_overview_current: '', i_overview_baseline: '', e_rundate: '', From 3845a93b4a6d4bc81bd676086f2a919e0ef1a67a Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Thu, 27 Aug 2026 11:11:54 -0700 Subject: [PATCH 02/20] =?UTF-8?q?feat(inference):=20mark=20legacy=20measur?= =?UTF-8?q?ed-power=20points=20on=20Measured=20Energy=20axes=20|=20?= =?UTF-8?q?=E6=8E=A8=E7=90=86=E5=9B=BE=E8=A1=A8=EF=BC=9A=E5=9C=A8=E5=AE=9E?= =?UTF-8?q?=E6=B5=8B=E8=83=BD=E8=80=97=E5=9D=90=E6=A0=87=E8=BD=B4=E4=B8=8A?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E6=97=A7=E7=89=88=E5=8A=9F=E8=80=97=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the nine Measured Energy y-axes, draw a dotted ring around points whose telemetry lacks a producer validation verdict (official and overlay render paths), show a localized footer legend key when such points are on screen, state the tier in all three tooltip generators (EN/ZH), and append a tier note to the measured axis explanations. 在九个实测能耗 Y 轴上,为缺少生产端验证结论的数据点绘制虚线圆环 (官方与叠加渲染路径),当此类点可见时在图表底部显示本地化图例, 在三种工具提示中标注认证层级(中英文),并在实测指标说明中补充 层级说明。 --- .../inference/axis-metric-explanations.ts | 50 +++++++++++------- .../components/inference/metric-registry.ts | 39 ++++++++++---- .../components/inference/ui/ChartDisplay.tsx | 10 +++- .../inference/ui/LegacyPowerLegendKey.tsx | 51 +++++++++++++++++++ .../components/inference/ui/ScatterGraph.tsx | 29 ++++++++--- .../inference/utils/legacy-power-marker.ts | 34 +++++++++++++ .../inference/utils/tooltipUtils.ts | 23 ++++++++- 7 files changed, 198 insertions(+), 38 deletions(-) create mode 100644 packages/app/src/components/inference/ui/LegacyPowerLegendKey.tsx create mode 100644 packages/app/src/components/inference/utils/legacy-power-marker.ts diff --git a/packages/app/src/components/inference/axis-metric-explanations.ts b/packages/app/src/components/inference/axis-metric-explanations.ts index 042f5d060..3fdf2a499 100644 --- a/packages/app/src/components/inference/axis-metric-explanations.ts +++ b/packages/app/src/components/inference/axis-metric-explanations.ts @@ -197,6 +197,18 @@ function provisionedJoules(tokenType: TokenType): MetricExplanation { }; } +/** + * Certification-tier note appended to every Measured Energy explanation: + * pairs the dotted legacy-power point ring and the Quick Filters → Measured + * Power pills with the footer text that explains them. + */ +const MEASURED_TIER_NOTE_EN = + ' Points without a producer validation verdict are legacy measurements — marked with a ' + + 'dotted ring on the chart and filterable via Quick Filters → Measured Power.'; +const MEASURED_TIER_NOTE_ZH = + '没有生产端验证结论的数据点属于旧版测量——在图表上以虚线圆环标记,' + + '并可通过快捷筛选中的“实测功耗”进行筛选。'; + type MeasuredPhase = 'run' | 'prefill' | 'decode'; const MEASURED_PHASE_EN: Record = { @@ -216,11 +228,13 @@ function measuredPower(phase: MeasuredPhase): MetricExplanation { description: { en: `Average per-chip accelerator power actually drawn during ${MEASURED_PHASE_EN[phase]}, ` + - 'read from runner telemetry. Unlike the all-in provisioned metrics, this reflects real ' + - 'measured draw, not the provisioned budget.', + `read from runner telemetry. Unlike the all-in provisioned metrics, this reflects real ` + + `measured draw, not the provisioned budget.${MEASURED_TIER_NOTE_EN}`, zh: `${MEASURED_PHASE_ZH[phase]}每块加速器芯片的实际平均功耗,来自运行器遥测数据。` + - '与全电源配置类指标不同,它反映的是真实实测功耗,而不是按配置计算的预算值。', + `与全电源配置类指标不同,它反映的是真实实测功耗,而不是按配置计算的预算值。${ + MEASURED_TIER_NOTE_ZH + }`, }, formula: { en: `W = mean of sampled per-chip accelerator power draw over ${MEASURED_PHASE_EN[phase]}`, @@ -236,10 +250,10 @@ function measuredJoulesPerToken(tokenType: TokenType): MetricExplanation { `Measured accelerator energy consumed per ${ tokenType === 'total' ? 'token (including prompt tokens)' : `${tokenType} token` }, from runner power telemetry integrated over the run. Lower means the system converts ` + - 'electricity into tokens more efficiently.', + `electricity into tokens more efficiently.${MEASURED_TIER_NOTE_EN}`, zh: `每个${TOKEN_TYPE_ZH[tokenType]}消耗的加速器实测能耗,由运行器功耗遥测在整个运行期间积分得到。` + - '数值越低,说明系统把电能转化为 token 的效率越高。', + `数值越低,说明系统把电能转化为 token 的效率越高。${MEASURED_TIER_NOTE_ZH}`, }, formula: { en: `J/tok = measured accelerator energy over the run ÷ ${TOKEN_TYPE_EN[tokenType]} processed`, @@ -341,12 +355,12 @@ export const METRIC_EXPLANATIONS: Record = { measuredJPerSuccessfulQuery: { description: { en: - 'Measured accelerator energy consumed per successfully completed request, from runner ' + - 'power telemetry. It charges the energy of the whole run only to requests that finished ' + - 'successfully.', + `Measured accelerator energy consumed per successfully completed request, from runner ` + + `power telemetry. It charges the energy of the whole run only to requests that finished ` + + `successfully.${MEASURED_TIER_NOTE_EN}`, zh: - '每个成功完成的请求消耗的加速器实测能耗,来自运行器功耗遥测。' + - '整个运行的能耗只计入成功完成的请求。', + `每个成功完成的请求消耗的加速器实测能耗,来自运行器功耗遥测。` + + `整个运行的能耗只计入成功完成的请求。${MEASURED_TIER_NOTE_ZH}`, }, formula: { en: 'J/query = measured accelerator energy over the run ÷ successfully completed requests', @@ -356,9 +370,9 @@ export const METRIC_EXPLANATIONS: Record = { measuredWhPerSuccessfulQuery: { description: { en: - 'The same measured energy per successful request expressed in watt-hours, a more ' + - 'familiar household unit (1 Wh = 3,600 J).', - zh: '与每次成功请求实测能耗相同的量,换算成更直观的瓦时单位(1 Wh = 3,600 J)。', + `The same measured energy per successful request expressed in watt-hours, a more ` + + `familiar household unit (1 Wh = 3,600 J).${MEASURED_TIER_NOTE_EN}`, + zh: `与每次成功请求实测能耗相同的量,换算成更直观的瓦时单位(1 Wh = 3,600 J)。${MEASURED_TIER_NOTE_ZH}`, }, formula: { en: 'Wh/query = measured J per successful query ÷ 3,600', @@ -368,11 +382,13 @@ export const METRIC_EXPLANATIONS: Record = { measuredPowerPercentTdp: { description: { en: - 'Measured average per-chip power as a share of the accelerator’s rated TDP. Values well ' + - 'below 100% suggest the workload leaves thermal or power headroom on the table.', + `Measured average per-chip power as a share of the accelerator’s rated TDP. Values well ` + + `below 100% suggest the workload leaves thermal or power headroom on the table.${ + MEASURED_TIER_NOTE_EN + }`, zh: - '每芯片实测平均功耗占加速器额定 TDP 的百分比。' + - '明显低于 100% 说明该工作负载没有用满散热或供电余量。', + `每芯片实测平均功耗占加速器额定 TDP 的百分比。` + + `明显低于 100% 说明该工作负载没有用满散热或供电余量。${MEASURED_TIER_NOTE_ZH}`, }, formula: { en: '% TDP = measured average power per chip (W) ÷ rated TDP (W) × 100', diff --git a/packages/app/src/components/inference/metric-registry.ts b/packages/app/src/components/inference/metric-registry.ts index 88bb1b16d..02cd67037 100644 --- a/packages/app/src/components/inference/metric-registry.ts +++ b/packages/app/src/components/inference/metric-registry.ts @@ -483,6 +483,33 @@ export interface MetricControlGroup { gated?: boolean; } +/** + * The nine 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. + */ +export const MEASURED_ENERGY_METRIC_CONFIG_KEYS = [ + 'y_measuredPrefillAvgPower', + 'y_measuredDecodeAvgPower', + 'y_measuredAvgPower', + 'y_measuredJPerInputToken', + 'y_measuredJPerOutputToken', + 'y_measuredJPerTotalToken', + 'y_measuredJPerSuccessfulQuery', + 'y_measuredWhPerSuccessfulQuery', + 'y_measuredPowerPercentTdp', +] as const satisfies readonly MetricConfigKey[]; + +const MEASURED_ENERGY_METRIC_CONFIG_KEY_SET: ReadonlySet = new Set( + MEASURED_ENERGY_METRIC_CONFIG_KEYS, +); + +/** 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); +} + export const METRIC_CONTROL_GROUPS: readonly MetricControlGroup[] = [ { label: 'Throughput', @@ -554,17 +581,7 @@ export const METRIC_CONTROL_GROUPS: readonly MetricControlGroup[] = [ { label: 'Measured Energy', labelZh: '实测能耗', - metrics: [ - 'y_measuredPrefillAvgPower', - 'y_measuredDecodeAvgPower', - 'y_measuredAvgPower', - 'y_measuredJPerInputToken', - 'y_measuredJPerOutputToken', - 'y_measuredJPerTotalToken', - 'y_measuredJPerSuccessfulQuery', - 'y_measuredWhPerSuccessfulQuery', - 'y_measuredPowerPercentTdp', - ], + metrics: MEASURED_ENERGY_METRIC_CONFIG_KEYS, }, { label: 'Custom User Values', diff --git a/packages/app/src/components/inference/ui/ChartDisplay.tsx b/packages/app/src/components/inference/ui/ChartDisplay.tsx index d59659522..c5e9b229d 100644 --- a/packages/app/src/components/inference/ui/ChartDisplay.tsx +++ b/packages/app/src/components/inference/ui/ChartDisplay.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { BarChart3, Table2 } from 'lucide-react'; import chartDefinitions, { + isMeasuredEnergyConfigKey, tokenMetricTypeForConfigKey, type MetricKey, } from '@/components/inference/metric-registry'; @@ -82,6 +83,7 @@ import { useLocale } from '@/lib/use-locale'; import { ATOM_FOOTNOTE_MARKER, AtomEngineFootnote } from '@/components/ui/atom-engine-footnote'; import { AgenticOptimizationNote } from '@/components/inference/ui/AgenticOptimizationNote'; import { OffloadHaloLegendKey } from '@/components/inference/ui/OffloadHaloLegendKey'; +import { LegacyPowerLegendKey } from '@/components/inference/ui/LegacyPowerLegendKey'; import AxisMetricFooter from './AxisMetricFooter'; import ChartControls from './ChartControls'; @@ -879,15 +881,21 @@ export default function ChartDisplay({ embedded = false }: { embedded?: boolean ...(footerOverlay?.clippedData ?? []).map((entry) => entry.point), ]; const hasOffloadHalo = footerPoints.some((point) => point.offload_mode === 'on'); + // Legacy-power rings render only on Measured Energy axes, so the + // key follows the same gate to never advertise an absent ring. + const hasLegacyPowerPoints = + isMeasuredEnergyConfigKey(selectedYAxisMetric) && + footerPoints.some((point) => point.power_tier === 'legacy'); const hasAtomSeries = footerPoints.some( (point) => point.framework !== undefined && getFrameworkLabel(point.framework).includes(ATOM_FOOTNOTE_MARKER), ); const footerNotices = - hasOffloadHalo || isAgenticSequence || hasAtomSeries ? ( + hasOffloadHalo || hasLegacyPowerPoints || isAgenticSequence || hasAtomSeries ? ( <> {hasOffloadHalo && } + {hasLegacyPowerPoints && } {isAgenticSequence && } {hasAtomSeries && } diff --git a/packages/app/src/components/inference/ui/LegacyPowerLegendKey.tsx b/packages/app/src/components/inference/ui/LegacyPowerLegendKey.tsx new file mode 100644 index 000000000..5f1826a68 --- /dev/null +++ b/packages/app/src/components/inference/ui/LegacyPowerLegendKey.tsx @@ -0,0 +1,51 @@ +import { POINT_SIZE } from '@/lib/chart-rendering'; +import { useLocale } from '@/lib/use-locale'; + +// Nested outside the KV-offload halo (POINT_SIZE + 4, '3 2' dashes) with a +// dotted dasharray so both decorations stay legible on one point. +export const LEGACY_POWER_RING_RADIUS = POINT_SIZE + 7; +export const LEGACY_POWER_RING_STROKE_WIDTH = 1.5; +export const LEGACY_POWER_RING_DASHARRAY = '1 3'; + +const STRINGS = { + en: 'Legacy power data (no validation verdict)', + zh: '旧版功耗数据(无验证结论)', +} as const; + +/** + * Key for the dotted ring drawn around measured-axis points whose power + * telemetry predates the producer validation contract (`power_tier === + * 'legacy'`). Rendered in the axis-metric info footer below the chart (the + * footer is `no-export`, so downloaded PNGs carry only the ring itself). + */ +export function LegacyPowerLegendKey() { + const locale = useLocale(); + + return ( +
+ + {STRINGS[locale]} +
+ ); +} diff --git a/packages/app/src/components/inference/ui/ScatterGraph.tsx b/packages/app/src/components/inference/ui/ScatterGraph.tsx index f273eabb8..52edb9521 100644 --- a/packages/app/src/components/inference/ui/ScatterGraph.tsx +++ b/packages/app/src/components/inference/ui/ScatterGraph.tsx @@ -120,6 +120,8 @@ import { } from '@/components/inference/utils/point-identity'; import LegendPointsDialog from '@/components/inference/ui/LegendPointsDialog'; import { renderOffloadHalo } from '@/components/inference/utils/offload-halo'; +import { renderLegacyPowerRing } from '@/components/inference/utils/legacy-power-marker'; +import { isMeasuredEnergyConfigKey } from '@/components/inference/metric-registry'; import { buildLegendPointsRows } from '@/components/inference/utils/legend-points-table'; import { pointLabelText } from './point-label'; import { @@ -463,6 +465,9 @@ const ScatterGraph = React.memo( const legendT = SCATTER_STRINGS[locale]; const costLimit = chartDefinition.y_cost_limit ?? 0; const latencyLimit = chartDefinition.y_latency_limit ?? 0; + // Legacy-power rings decorate points only while a Measured Energy y-axis + // is selected (see legacy-power-marker.ts). + const isMeasuredEnergyAxis = isMeasuredEnergyConfigKey(selectedYAxisMetric); const { isUnofficialRun, @@ -2604,14 +2609,15 @@ const ScatterGraph = React.memo( overlayRunColor(overlayRunIndex(d.run_url ?? null, runIndexByUrl)), ); - // Match official points: KV offload is the only persistent - // point decoration. Decode method remains in the tooltip. + // Match official points: KV offload and the measured-axis + // legacy-power ring are the only persistent point decorations. + // Decode method remains in the tooltip. overlayPoints.each(function (d) { - renderOffloadHalo( - d3.select(this), - d, - overlayRunColor(overlayRunIndex(d.run_url ?? null, runIndexByUrl)), + const overlayStroke = overlayRunColor( + overlayRunIndex(d.run_url ?? null, runIndexByUrl), ); + renderOffloadHalo(d3.select(this), d, overlayStroke); + renderLegacyPowerRing(d3.select(this), d, isMeasuredEnergyAxis, overlayStroke); }); // Labels @@ -2976,6 +2982,7 @@ const ScatterGraph = React.memo( xLabel, yLabel, selectedYAxisMetric, + isMeasuredEnergyAxis, chartDefinition, locale, drawPerfRuler, @@ -2997,9 +3004,11 @@ const ScatterGraph = React.memo( // CSS transitions for smooth opacity animation on hw toggle zoomGroup.selectAll('.dot-group').style('transition', 'opacity 150ms ease'); - // Offload halo: dashed ring on every point that used KV offload (Pareto or not) + // Offload halo: dashed ring on every point that used KV offload (Pareto or not). + // Legacy-power ring: dotted ring on unvalidated telemetry, measured axes only. zoomGroup.selectAll('.dot-group').each(function (d) { renderOffloadHalo(d3.select(this), d, 'var(--foreground)'); + renderLegacyPowerRing(d3.select(this), d, isMeasuredEnergyAxis, 'var(--foreground)'); }); avoidPointLabelCollisions(zoomGroup); @@ -3029,6 +3038,9 @@ const ScatterGraph = React.memo( optimalPointKeys, getCssColor, resolveColor, + // A metric-only change must re-run the decoration pass so legacy-power + // rings appear/disappear with the Measured Energy axis selection. + isMeasuredEnergyAxis, ], ); @@ -3062,8 +3074,9 @@ const ScatterGraph = React.memo( color, ); // A precision toggle may replace and append the visible SVG shape. - // Keep the offload halo above that shape after the swap. + // Keep the decorations above that shape after the swap. point.selectAll('.offload-halo').raise(); + point.selectAll('.legacy-power-ring').raise(); }); // Overlay points keep their X marker and run-derived color. Only their diff --git a/packages/app/src/components/inference/utils/legacy-power-marker.ts b/packages/app/src/components/inference/utils/legacy-power-marker.ts new file mode 100644 index 000000000..b0a9ae92f --- /dev/null +++ b/packages/app/src/components/inference/utils/legacy-power-marker.ts @@ -0,0 +1,34 @@ +import type * as d3 from 'd3'; + +import type { InferenceData } from '@/components/inference/types'; +import { + LEGACY_POWER_RING_DASHARRAY, + LEGACY_POWER_RING_RADIUS, + LEGACY_POWER_RING_STROKE_WIDTH, +} from '@/components/inference/ui/LegacyPowerLegendKey'; + +/** + * Dotted ring flagging measured-power telemetry without a producer validation + * verdict (`power_tier === 'legacy'`). Drawn only while a Measured Energy + * y-axis is selected; the join-on-empty-data pattern removes the ring when the + * axis changes or the point's tier is not legacy. + */ +export function renderLegacyPowerRing( + group: d3.Selection, + point: InferenceData, + isMeasuredAxis: boolean, + stroke: string, +): void { + group + .selectAll('.legacy-power-ring') + .data(isMeasuredAxis && point.power_tier === 'legacy' ? [true] : []) + .join('circle') + .attr('class', 'legacy-power-ring') + .attr('r', LEGACY_POWER_RING_RADIUS) + .attr('fill', 'none') + .attr('stroke', stroke) + .attr('stroke-width', LEGACY_POWER_RING_STROKE_WIDTH) + .attr('stroke-dasharray', LEGACY_POWER_RING_DASHARRAY) + .attr('opacity', 0.9) + .attr('pointer-events', 'none'); +} diff --git a/packages/app/src/components/inference/utils/tooltipUtils.ts b/packages/app/src/components/inference/utils/tooltipUtils.ts index ee740c4f5..8b283cc86 100644 --- a/packages/app/src/components/inference/utils/tooltipUtils.ts +++ b/packages/app/src/components/inference/utils/tooltipUtils.ts @@ -6,6 +6,7 @@ import type { Locale } from '@/lib/i18n'; import { isKvOffloadEnabled } from '@/lib/kv-offload'; import type { HardwareConfig, InferenceData, OverlayData } from '@/components/inference/types'; +import { isMeasuredEnergyConfigKey } from '@/components/inference/metric-registry'; import { meaningfulParallelismSize, parallelismLabel, @@ -135,6 +136,9 @@ const TOOLTIP_STRINGS = { precision: 'Precision', inputTputPerChip: 'Input Token Throughput per Chip', outputTputPerChip: 'Output Token Throughput per Chip', + powerData: 'Power Data', + powerCertified: 'Certified (validated measurement)', + powerLegacy: 'Legacy (no validation verdict)', }, zh: { dismiss: '点击其他区域关闭', @@ -149,9 +153,23 @@ const TOOLTIP_STRINGS = { precision: '精度', inputTputPerChip: '每芯片输入 token 吞吐量', outputTputPerChip: '每芯片输出 token 吞吐量', + powerData: '功耗数据', + powerCertified: '已认证(通过验证的测量)', + powerLegacy: '旧版(无验证结论)', }, } as const; +/** + * Measured-power certification tier line. Rendered only while a Measured + * Energy y-axis is selected and the point carries a tier — non-measured axes + * and telemetry-free points stay unchanged. + */ +const powerTierHTML = (d: InferenceData, selectedYAxisMetric: string, locale: Locale): string => { + if (!isMeasuredEnergyConfigKey(selectedYAxisMetric) || !d.power_tier) return ''; + const t = TOOLTIP_STRINGS[locale]; + return tooltipLine(t.powerData, d.power_tier === 'certified' ? t.powerCertified : t.powerLegacy); +}; + const CACHE_STRINGS = { en: { offloadType: 'Offload Type', @@ -458,6 +476,7 @@ export const generateTooltipContent = (config: TooltipConfig): string => { ${tooltipLine(t.outputTputPerChip, `${fmt(d['outputTputPerGpu'].y)}`)}` : '' } + ${powerTierHTML(d, selectedYAxisMetric, locale)} ${tooltipLine(t.totalChips, d.tp)} ${generateParallelismHTML(d, locale)} ${tooltipLine(t.concurrency, `${d.conc}`)} @@ -478,7 +497,7 @@ export const generateTooltipContent = (config: TooltipConfig): string => { * @returns HTML string for the tooltip content */ export const generateOverlayTooltipContent = (config: OverlayTooltipConfig): string => { - const { data: d, isPinned, xLabel, yLabel, overlayData } = config; + const { data: d, isPinned, xLabel, yLabel, selectedYAxisMetric, overlayData } = config; const locale = config.locale ?? 'en'; const t = TOOLTIP_STRINGS[locale]; const hwConfig = overlayData.hardwareConfig[d.hwKey]; @@ -498,6 +517,7 @@ export const generateOverlayTooltipContent = (config: OverlayTooltipConfig): str ${tooltipLine(t.date, `${d.actualDate ?? d.date}`)} ${tooltipLine(xLabel, fmt(d.x))} ${tooltipLine(yLabel, fmt(d.y))} + ${powerTierHTML(d, selectedYAxisMetric, locale)} ${tooltipLine(t.totalChips, d.tp)} ${generateParallelismHTML(d, locale)} ${tooltipLine(t.concurrency, `${d.conc}`)} @@ -555,6 +575,7 @@ export const generateGPUGraphTooltipContent = (config: TooltipConfig): string => ${tooltipLine(t.outputTputPerChip, `${fmt(d['outputTputPerGpu'].y)}`)}` : '' } + ${powerTierHTML(d, selectedYAxisMetric, locale)} ${tooltipLine(t.totalChips, d.tp)} ${generateParallelismHTML(d, locale)} ${tooltipLine(t.concurrency, `${d.conc}`)} From 38ad0eef652656ce37e9fe0e0401c8048fc587a5 Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Thu, 27 Aug 2026 11:16:50 -0700 Subject: [PATCH 03/20] =?UTF-8?q?test(inference):=20cover=20measured-power?= =?UTF-8?q?=20tier=20derivation,=20filter,=20ring,=20and=20tooltips=20|=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=9A=E8=A6=86=E7=9B=96=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?=E5=8A=9F=E8=80=97=E5=B1=82=E7=BA=A7=E6=B4=BE=E7=94=9F=E3=80=81?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E3=80=81=E5=9C=86=E7=8E=AF=E4=B8=8E=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Table-driven resolvePowerTier cases, tier derivation through rowToAggDataEntry/transformBenchmarkRows, quick-filter matching and availability, measured-energy key classification, decoration rendering per axis, tier tooltip lines (EN/ZH), and the i_power URL default. 覆盖 resolvePowerTier 表驱动用例、转换层层级派生、快捷筛选匹配与 可用性、实测能耗指标判定、按坐标轴的圆环渲染、中英文工具提示层级 行以及 i_power URL 默认值。 --- .../inference/metric-registry.test.ts | 24 +++++ .../ui/ScatterGraph.decoration.test.tsx | 35 +++++++ .../ui/ScatterGraph.overlay.test.tsx | 2 +- .../inference/utils/quickFilters.test.ts | 46 ++++++++++ .../inference/utils/tooltip-utils.test.ts | 92 +++++++++++++++++++ .../app/src/lib/benchmark-transform.test.ts | 89 ++++++++++++++++++ packages/app/src/lib/power-tier.test.ts | 71 ++++++++++++++ packages/app/src/lib/url-state.test.ts | 5 + 8 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/lib/power-tier.test.ts diff --git a/packages/app/src/components/inference/metric-registry.test.ts b/packages/app/src/components/inference/metric-registry.test.ts index 0fd7f0171..adb561c56 100644 --- a/packages/app/src/components/inference/metric-registry.test.ts +++ b/packages/app/src/components/inference/metric-registry.test.ts @@ -4,6 +4,8 @@ import { chartDefinitions, DEFAULT_METRIC_CONFIG_KEY, isBenchmarkMetricKey, + isMeasuredEnergyConfigKey, + MEASURED_ENERGY_METRIC_CONFIG_KEYS, METRIC_CONFIG_KEYS, METRIC_CONTROL_GROUPS, METRIC_REGISTRY, @@ -40,6 +42,28 @@ describe('metric registry', () => { expect(new Set(controlMetrics).size).toBe(controlMetrics.length); 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('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); + }); }); describe('metric compatibility', () => { diff --git a/packages/app/src/components/inference/ui/ScatterGraph.decoration.test.tsx b/packages/app/src/components/inference/ui/ScatterGraph.decoration.test.tsx index f70037913..cd77b5e09 100644 --- a/packages/app/src/components/inference/ui/ScatterGraph.decoration.test.tsx +++ b/packages/app/src/components/inference/ui/ScatterGraph.decoration.test.tsx @@ -127,6 +127,41 @@ describe('ScatterGraph toggle decoration', () => { unmount(); }); + it('rings legacy-power points only on Measured Energy axes', () => { + const legacy = { + ...point('h100', 'fp8', 10, 100, 1), + power_tier: 'legacy', + } as InferenceData; + const certified = { + ...point('h100', 'fp8', 20, 200, 2), + power_tier: 'certified', + } as InferenceData; + const tierless = point('h100', 'fp8', 40, 400, 4); + + inferenceState.current = { + ...baseInferenceState(), + selectedYAxisMetric: 'y_measuredAvgPower', + }; + const measured = mountChart({ data: [legacy, certified, tierless] }); + const groups = dotGroups(measured.container); + + expect(groups[0].querySelector('.legacy-power-ring')).not.toBeNull(); + expect(groups[1].querySelector('.legacy-power-ring')).toBeNull(); + expect(groups[2].querySelector('.legacy-power-ring')).toBeNull(); + // The legend key lives in ChartDisplay's axis-metric footer, not the chart. + expect(measured.container.querySelector('[data-testid="legacy-power-key"]')).toBeNull(); + measured.unmount(); + + // Non-measured axis: the same legacy point renders without a ring. + inferenceState.current = { + ...baseInferenceState(), + selectedYAxisMetric: 'y', + }; + const throughput = mountChart({ data: [legacy, certified, tierless] }); + expect(throughput.container.querySelectorAll('.legacy-power-ring')).toHaveLength(0); + throughput.unmount(); + }); + it('reads current trace availability without changing metric identity', () => { const agenticPoint = { ...point('h100', 'fp8', 20, 200, 2), diff --git a/packages/app/src/components/inference/ui/ScatterGraph.overlay.test.tsx b/packages/app/src/components/inference/ui/ScatterGraph.overlay.test.tsx index 889a4a82e..499d79bc3 100644 --- a/packages/app/src/components/inference/ui/ScatterGraph.overlay.test.tsx +++ b/packages/app/src/components/inference/ui/ScatterGraph.overlay.test.tsx @@ -213,7 +213,7 @@ describe('ScatterGraph unofficial overlays', () => { // exactly as it would the official points. inferenceState.current = { ...baseInferenceState(), - quickFilters: { vendors: ['AMD'], frameworks: [], deployment: [], spec: [] }, + quickFilters: { vendors: ['AMD'], frameworks: [], deployment: [], spec: [], power: [] }, }; const { container, unmount } = mountChart({ overlayData: { diff --git a/packages/app/src/components/inference/utils/quickFilters.test.ts b/packages/app/src/components/inference/utils/quickFilters.test.ts index 106fd2fb4..3c74d9b53 100644 --- a/packages/app/src/components/inference/utils/quickFilters.test.ts +++ b/packages/app/src/components/inference/utils/quickFilters.test.ts @@ -9,6 +9,7 @@ import { matchesQuickFilters, pointDeploymentMode, parseDeploymentModes, + parsePowerTiers, pointVendor, quickFiltersActive, type QuickFilters, @@ -67,6 +68,7 @@ describe('computeAvailableQuickFilters', () => { disagg: false, is_multinode: true, spec_decoding: 'none', + power_tier: 'certified', }), ]; expect(computeAvailableQuickFilters(points)).toEqual({ @@ -74,6 +76,7 @@ describe('computeAvailableQuickFilters', () => { frameworks: ['vllm', 'trt', 'atom'], deployment: ['single-node', 'multi-node', 'disagg'], spec: ['mtp', 'stp'], + power: ['certified'], }); }); @@ -86,15 +89,29 @@ describe('computeAvailableQuickFilters', () => { frameworks: ['vllm'], deployment: ['single-node'], spec: ['stp'], + power: [], }); }); + it('reports exactly the power tiers present, in display order', () => { + const legacy = point({ power_tier: 'legacy' }); + const certified = point({ power_tier: 'certified' }); + expect(computeAvailableQuickFilters([legacy]).power).toEqual(['legacy']); + expect(computeAvailableQuickFilters([certified]).power).toEqual(['certified']); + // Display order stays certified-first even when legacy points come first. + expect(computeAvailableQuickFilters([legacy, certified]).power).toEqual([ + 'certified', + 'legacy', + ]); + }); + it('returns all-empty for an empty point set', () => { expect(computeAvailableQuickFilters([])).toEqual({ vendors: [], frameworks: [], deployment: [], spec: [], + power: [], }); }); }); @@ -106,6 +123,17 @@ describe('quickFiltersActive', () => { expect(quickFiltersActive(filters({ frameworks: ['vllm'] }))).toBe(true); expect(quickFiltersActive(filters({ deployment: ['disagg'] }))).toBe(true); expect(quickFiltersActive(filters({ spec: ['mtp'] }))).toBe(true); + expect(quickFiltersActive(filters({ power: ['certified'] }))).toBe(true); + }); +}); + +describe('parsePowerTiers', () => { + it('keeps known tiers, removes duplicates, and drops unknown values', () => { + expect(parsePowerTiers(['certified', 'legacy', 'certified', 'bogus', ''])).toEqual([ + 'certified', + 'legacy', + ]); + expect(parsePowerTiers([])).toEqual([]); }); }); @@ -160,6 +188,24 @@ describe('matchesQuickFilters', () => { expect(matchesQuickFilters(point({ hwKey: 'h100_vllm' }), stpFilter)).toBe(true); }); + it('filters by measured-power certification tier', () => { + const certifiedOnly = filters({ power: ['certified'] }); + const bothTiers = filters({ power: ['certified', 'legacy'] }); + const certified = point({ power_tier: 'certified' }); + const legacy = point({ power_tier: 'legacy' }); + const tierless = point({}); + + expect(matchesQuickFilters(certified, certifiedOnly)).toBe(true); + expect(matchesQuickFilters(legacy, certifiedOnly)).toBe(false); + expect(matchesQuickFilters(tierless, certifiedOnly)).toBe(false); + // Both tiers still exclude points with no measured telemetry at all. + expect(matchesQuickFilters(certified, bothTiers)).toBe(true); + expect(matchesQuickFilters(legacy, bothTiers)).toBe(true); + expect(matchesQuickFilters(tierless, bothTiers)).toBe(false); + // No power constraint keeps tier-less points. + expect(matchesQuickFilters(tierless, EMPTY_QUICK_FILTERS)).toBe(true); + }); + it('treats non-standard spec methods (e.g. eagle) as spec-on (MTP), never STP', () => { const eagle = point({ hwKey: 'h200_trt_eagle', spec_decoding: 'eagle' }); // Standard (STP) means `none` only — a speculative method groups under MTP. 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 cd469a0c6..44237f5f4 100644 --- a/packages/app/src/components/inference/utils/tooltip-utils.test.ts +++ b/packages/app/src/components/inference/utils/tooltip-utils.test.ts @@ -726,3 +726,95 @@ describe('generateGPUGraphTooltipContent', () => { ).not.toContain('data-action="view-charts"'); }); }); + +// =========================================================================== +// measured-power certification tier line (all three generators) +// =========================================================================== +describe('power tier tooltip line', () => { + it('states the tier for a legacy point on a measured axis', () => { + const html = generateTooltipContent( + tooltipConfig({ + selectedYAxisMetric: 'y_measuredJPerOutputToken', + data: pt({ power_tier: 'legacy' }), + }), + ); + expect(html).toContain('Power Data: Legacy (no validation verdict)'); + }); + + it('states the certified tier on a measured axis', () => { + const html = generateTooltipContent( + tooltipConfig({ + selectedYAxisMetric: 'y_measuredAvgPower', + data: pt({ power_tier: 'certified' }), + }), + ); + expect(html).toContain('Power Data: Certified (validated measurement)'); + }); + + it('omits the tier line on non-measured axes', () => { + const html = generateTooltipContent( + tooltipConfig({ + selectedYAxisMetric: 'y_tpPerGpu', + data: pt({ power_tier: 'legacy' }), + }), + ); + expect(html).not.toContain('Power Data'); + }); + + it('omits the tier line when the point carries no tier', () => { + const html = generateTooltipContent( + tooltipConfig({ selectedYAxisMetric: 'y_measuredAvgPower', data: pt() }), + ); + expect(html).not.toContain('Power Data'); + }); + + it('renders the ZH strings under the zh locale', () => { + const legacy = generateTooltipContent( + tooltipConfig({ + selectedYAxisMetric: 'y_measuredJPerOutputToken', + data: pt({ power_tier: 'legacy' }), + locale: 'zh', + }), + ); + const certified = generateTooltipContent( + tooltipConfig({ + selectedYAxisMetric: 'y_measuredJPerOutputToken', + data: pt({ power_tier: 'certified' }), + locale: 'zh', + }), + ); + expect(legacy).toContain('功耗数据: 旧版(无验证结论)'); + expect(certified).toContain('功耗数据: 已认证(通过验证的测量)'); + }); + + it('reaches overlay and GPU-graph tooltips through the same gate', () => { + const overlay = generateOverlayTooltipContent({ + ...tooltipConfig({ + selectedYAxisMetric: 'y_measuredAvgPower', + data: pt({ power_tier: 'legacy' }), + }), + overlayData: { + label: 'feature-branch', + hardwareConfig: mockHardwareConfig, + data: [], + runUrl: 'https://example.com', + } as any, + }); + const gpuGraph = generateGPUGraphTooltipContent( + tooltipConfig({ + selectedYAxisMetric: 'y_measuredAvgPower', + data: pt({ power_tier: 'legacy' }), + }), + ); + const gpuGraphOffAxis = generateGPUGraphTooltipContent( + tooltipConfig({ + selectedYAxisMetric: 'y_costh', + data: pt({ power_tier: 'legacy' }), + }), + ); + + expect(overlay).toContain('Power Data: Legacy (no validation verdict)'); + expect(gpuGraph).toContain('Power Data: Legacy (no validation verdict)'); + expect(gpuGraphOffAxis).not.toContain('Power Data'); + }); +}); diff --git a/packages/app/src/lib/benchmark-transform.test.ts b/packages/app/src/lib/benchmark-transform.test.ts index fac8734d8..a834c28f7 100644 --- a/packages/app/src/lib/benchmark-transform.test.ts +++ b/packages/app/src/lib/benchmark-transform.test.ts @@ -454,6 +454,95 @@ describe('rowToAggDataEntry', () => { expect(point.measuredPowerPercentTdp?.y).toBe(80); }); + it('certifies a validated non-disagg row (no schema version required)', () => { + const entry = rowToAggDataEntry( + makeRow({ + metrics: { + power_valid: 1, + avg_power_w: 685.5, + }, + }), + ); + expect(entry.power_tier).toBe('certified'); + }); + + it('certifies a validated disagg row stamped with schema v2', () => { + const entry = rowToAggDataEntry( + makeRow({ + disagg: true, + metrics: { + power_valid: 1, + power_metric_schema_version: 2, + avg_power_w: 650, + joules_per_output_token: 9.7, + }, + }), + ); + expect(entry.power_tier).toBe('certified'); + }); + + it('classifies a no-verdict row with telemetry as legacy', () => { + const entry = rowToAggDataEntry( + makeRow({ + metrics: { + avg_power_w: 560, + joules_per_successful_query: 1800, + }, + }), + ); + expect(entry.power_tier).toBe('legacy'); + // The tier is informational only — telemetry still renders (G2 is + // distinguish, not exclude). + expect(entry.avg_power_w).toBe(560); + }); + + it('classifies a validated disagg row without schema v2 as legacy', () => { + const entry = rowToAggDataEntry( + makeRow({ + disagg: true, + metrics: { + power_valid: 1, + avg_power_w: 650, + prefill_avg_power_w: 612.3, + joules_per_output_token: 9.7, + }, + }), + ); + // Role watts render, but their provenance predates the versioned + // whole-deployment energy contract. + expect(entry.power_tier).toBe('legacy'); + expect(entry.prefill_avg_power_w).toBe(612.3); + expect(entry.joules_per_output_token).toBeUndefined(); + }); + + it('leaves power_tier absent for explicit power_valid=0 and telemetry-free rows', () => { + const invalid = rowToAggDataEntry( + makeRow({ + metrics: { + power_valid: 0, + avg_power_w: 685.5, + joules_per_output_token: 8.4, + }, + }), + ); + expect(invalid.power_tier).toBeUndefined(); + + const noTelemetry = rowToAggDataEntry(makeRow({ metrics: {} })); + expect(noTelemetry.power_tier).toBeUndefined(); + }); + + it('carries power_tier onto transformed chart points', () => { + const legacyRow = makeRow({ id: 1, metrics: { avg_power_w: 560 } }); + const certifiedRow = makeRow({ + id: 2, + conc: 128, + metrics: { power_valid: 1, avg_power_w: 685.5 }, + }); + const { chartData } = transformBenchmarkRows([legacyRow, certifiedRow]); + const points = chartData.find((data) => data.length > 0)!; + expect(points.map((p) => p.power_tier)).toEqual(['legacy', 'certified']); + }); + it('passes through per-worker measured power array intact', () => { const workers = [ { diff --git a/packages/app/src/lib/power-tier.test.ts b/packages/app/src/lib/power-tier.test.ts new file mode 100644 index 000000000..5e694a0fe --- /dev/null +++ b/packages/app/src/lib/power-tier.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { resolvePowerTier, type PowerTier } from './power-tier'; + +describe('resolvePowerTier', () => { + const cases: { + name: string; + powerValid: number | undefined; + wholeDeploymentSemantics: boolean; + hasMeasuredTelemetry: boolean; + expected: PowerTier | undefined; + }[] = [ + { + name: 'certified for a validated row with whole-deployment semantics', + powerValid: 1, + wholeDeploymentSemantics: true, + hasMeasuredTelemetry: true, + expected: 'certified', + }, + { + name: 'legacy for telemetry without a producer verdict', + powerValid: undefined, + wholeDeploymentSemantics: true, + hasMeasuredTelemetry: true, + expected: 'legacy', + }, + { + name: 'legacy for a validated disagg row without schema v2 semantics', + powerValid: 1, + wholeDeploymentSemantics: false, + hasMeasuredTelemetry: true, + expected: 'legacy', + }, + { + name: 'absent for an explicit invalid verdict (telemetry scrubbed upstream)', + powerValid: 0, + wholeDeploymentSemantics: true, + hasMeasuredTelemetry: false, + expected: undefined, + }, + { + // Defensive: even if a caller passed surviving telemetry with pv=0, + // the explicit verdict stays authoritative. + name: 'absent for an explicit invalid verdict regardless of telemetry', + powerValid: 0, + wholeDeploymentSemantics: true, + hasMeasuredTelemetry: true, + expected: undefined, + }, + { + name: 'absent when the row carries no measured telemetry', + powerValid: undefined, + wholeDeploymentSemantics: true, + hasMeasuredTelemetry: false, + expected: undefined, + }, + { + name: 'absent for a validated row whose telemetry fields are all missing', + powerValid: 1, + wholeDeploymentSemantics: true, + hasMeasuredTelemetry: false, + expected: undefined, + }, + ]; + + for (const { name, expected, ...args } of cases) { + it(name, () => { + expect(resolvePowerTier(args)).toBe(expected); + }); + } +}); diff --git a/packages/app/src/lib/url-state.test.ts b/packages/app/src/lib/url-state.test.ts index c10c25c12..ad6cd0742 100644 --- a/packages/app/src/lib/url-state.test.ts +++ b/packages/app/src/lib/url-state.test.ts @@ -64,6 +64,11 @@ describe('PARAM_DEFAULTS', () => { expect(PARAM_DEFAULTS.e_bench).toBe(''); }); + it('has an empty default for the measured-power quick filter (filter off)', async () => { + const { PARAM_DEFAULTS } = await import('@/lib/url-state'); + expect(PARAM_DEFAULTS.i_power).toBe(''); + }); + it('has empty string default for i_gradlabel', async () => { const { PARAM_DEFAULTS } = await import('@/lib/url-state'); expect(PARAM_DEFAULTS.i_gradlabel).toBe(''); From 0ec2afffe5e85cb7da3c071c5647b771bb280e56 Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Thu, 27 Aug 2026 11:24:56 -0700 Subject: [PATCH 04/20] =?UTF-8?q?test(e2e):=20certified-vs-legacy=20measur?= =?UTF-8?q?ed=20power=20filter=20spec=20|=20=E7=AB=AF=E5=88=B0=E7=AB=AF?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=9A=E5=B7=B2=E8=AE=A4=E8=AF=81=E4=B8=8E?= =?UTF-8?q?=E6=97=A7=E7=89=88=E5=AE=9E=E6=B5=8B=E5=8A=9F=E8=80=97=E7=AD=9B?= =?UTF-8?q?=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deterministic intercepted fixtures (one certified, one legacy config) drive the measured-axis ring, footer legend key, Certified/Legacy quick filter pills, clear-filters reset, and i_power share-link restore. 使用确定性的拦截数据(一个已认证、一个旧版配置)验证实测坐标轴 圆环、底部图例、已认证/旧版快捷筛选、清除筛选以及 i_power 分享 链接恢复。 --- .../cypress/e2e/certified-power-filter.cy.ts | 174 ++++++++++++++++++ packages/app/timings.json | 12 +- 2 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 packages/app/cypress/e2e/certified-power-filter.cy.ts diff --git a/packages/app/cypress/e2e/certified-power-filter.cy.ts b/packages/app/cypress/e2e/certified-power-filter.cy.ts new file mode 100644 index 000000000..c2f35bfad --- /dev/null +++ b/packages/app/cypress/e2e/certified-power-filter.cy.ts @@ -0,0 +1,174 @@ +// Certified-vs-legacy 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 (Certified/Legacy pills, `i_power` share-link param). Fixture rows +// are intercepted so one config is certified (power_valid=1) and one is +// legacy (no verdict), keeping every assertion deterministic regardless of +// what the production dataset contains. + +const POWER_MODEL = 'dsv4'; +const POWER_DATE = '2026-08-20'; + +const powerPercentileLadder = (prefix: string, base: number): Record => ({ + [`median_${prefix}`]: base, + [`p75_${prefix}`]: base * 1.2, + [`p90_${prefix}`]: base * 1.5, + [`p95_${prefix}`]: base * 1.7, + [`p99_${prefix}`]: base * 2.2, + [`std_${prefix}`]: base * 0.3, +}); + +const powerMetrics = (conc: number): Record => { + const scale = conc / 16; + const itl = 0.011 * scale; + return { + ...powerPercentileLadder('ttft', 0.4 * scale), + ...powerPercentileLadder('tpot', 0.012 * scale), + ...powerPercentileLadder('itl', itl), + ...powerPercentileLadder('e2el', 8 * scale), + median_intvty: 1 / itl, + p75_intvty: 1 / (itl * 1.2), + p90_intvty: 1 / (itl * 1.5), + p99_intvty: 1 / (itl * 2.2), + std_intvty: (1 / itl) * 0.1, + tput_per_gpu: 950 / Math.sqrt(scale), + output_tput_per_gpu: 210, + input_tput_per_gpu: 740, + }; +}; + +const powerConfigs = [ + // Legacy telemetry: measured watts without a producer verdict. + { hardware: 'b200', framework: 'vllm', power: { avg_power_w: 560 } }, + // Certified telemetry: explicit power_valid=1 verdict. + { hardware: 'mi300x', framework: 'sglang', power: { power_valid: 1, avg_power_w: 685.5 } }, +]; + +const powerAvailability = powerConfigs.map((config) => ({ + model: POWER_MODEL, + isl: 8192, + osl: 1024, + precision: 'fp4', + hardware: config.hardware, + framework: config.framework, + spec_method: 'none', + disagg: false, + benchmark_type: 'single_turn', + date: POWER_DATE, +})); + +let powerBenchmarkId = 990000; +const powerBenchmarks = powerConfigs.flatMap((config) => + [16, 64, 128].map((conc) => ({ + id: powerBenchmarkId++, + hardware: config.hardware, + framework: config.framework, + model: POWER_MODEL, + precision: 'fp4', + spec_method: 'none', + disagg: false, + is_multinode: false, + prefill_tp: 8, + decode_tp: 8, + num_prefill_gpu: 8, + num_decode_gpu: 8, + isl: 8192, + osl: 1024, + conc, + offload_mode: 'off', + benchmark_type: 'single_turn', + image: `${config.framework}/server:test`, + metrics: { ...powerMetrics(conc), ...config.power }, + workers: null, + date: POWER_DATE, + run_url: null, + })), +); + +function visitCertifiedPowerChart(extraParams = '') { + cy.intercept('GET', '/api/v1/availability', { body: powerAvailability }).as('availability'); + cy.intercept('GET', '/api/v1/benchmarks*', { body: powerBenchmarks }).as('benchmarks'); + cy.visit(`/inference?g_model=DeepSeek-V4-Pro&i_seq=8k/1k&i_prec=fp4${extraParams}`, { + onBeforeLoad(win) { + win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now())); + }, + }); + cy.wait(['@availability', '@benchmarks']); + cy.get('[data-testid="inference-chart-display"]', { timeout: 30_000 }).should('exist'); + cy.get('[data-testid="chart-figure"]').should('have.length.at.least', 1); +} + +describe('Certified vs legacy 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() + .should('be.visible') + .click(); + cy.get('[data-slot="select-content"]').should('not.exist'); + + // 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'); + cy.get('[data-testid="quick-filter-power-legacy"]').should('be.enabled'); + + // 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'); + cy.get('.dot-group[data-hw-key^="mi300x"]').should('exist'); + cy.get('.legacy-power-ring').should('not.exist'); + cy.get('[data-testid="legacy-power-key"]').should('not.exist'); + 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( + 'have.attr', + 'aria-pressed', + 'false', + ); + cy.contains('button', 'Done').click(); + cy.get('.dot-group[data-hw-key^="b200"] .legacy-power-ring').should('exist'); + cy.get('[data-testid="legacy-power-key"]').should('be.visible'); + }); + + it('restores a shared i_power=certified link with the pill pre-selected', () => { + // Note: filter writes live in the in-memory share-link store (the address + // bar is deliberately stripped after load — see url-state.ts), so the + // durable observable behavior is the restore direction tested here. + visitCertifiedPowerChart('&i_metric=y_measuredAvgPower&i_power=certified'); + + cy.get('.dot-group[data-hw-key^="mi300x"]').should('exist'); + cy.get('.dot-group[data-hw-key^="b200"]').should('not.exist'); + cy.get('[data-testid="legacy-power-key"]').should('not.exist'); + + cy.get('[data-testid="scatter-quick-filters"]').click(); + cy.get('[data-testid="quick-filter-power-certified"]').should( + 'have.attr', + 'aria-pressed', + 'true', + ); + cy.get('[data-testid="quick-filters-selected-count"]').should('contain.text', '1 selected'); + }); +}); diff --git a/packages/app/timings.json b/packages/app/timings.json index e9236e6a3..145f41443 100644 --- a/packages/app/timings.json +++ b/packages/app/timings.json @@ -40,14 +40,14 @@ "spec": "cypress/e2e/blog.cy.ts", "duration": 1537 }, - { - "spec": "cypress/e2e/fleet-lifecycle.cy.ts", - "duration": 18000 - }, { "spec": "cypress/e2e/calculator-overlay.cy.ts", "duration": 5318 }, + { + "spec": "cypress/e2e/certified-power-filter.cy.ts", + "duration": 1000 + }, { "spec": "cypress/e2e/chart-overflow-continuation.cy.ts", "duration": 731 @@ -120,6 +120,10 @@ "spec": "cypress/e2e/fixed-sequence-logs.cy.ts", "duration": 1000 }, + { + "spec": "cypress/e2e/fleet-lifecycle.cy.ts", + "duration": 18000 + }, { "spec": "cypress/e2e/gpu-compare-agentic-detail.cy.ts", "duration": 1104 From ac5980530f406fb963bc41368c4d8ad7019860ef Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Thu, 27 Aug 2026 11:49:56 -0700 Subject: [PATCH 05/20] =?UTF-8?q?fix(inference):=20count=20and=20clear=20t?= =?UTF-8?q?he=20power=20tier=20in=20the=20chart=20legend=20quick-filter=20?= =?UTF-8?q?reset=20|=20=E6=8E=A8=E7=90=86=E5=9B=BE=E8=A1=A8=EF=BC=9A?= =?UTF-8?q?=E5=9B=BE=E4=BE=8B=E5=BF=AB=E6=8D=B7=E7=AD=9B=E9=80=89=E8=AE=A1?= =?UTF-8?q?=E6=95=B0=E4=B8=8E=E9=87=8D=E7=BD=AE=E7=BA=B3=E5=85=A5=E5=AE=9E?= =?UTF-8?q?=E6=B5=8B=E5=8A=9F=E8=80=97=E5=B1=82=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScatterGraph keeps its own copy of the quick-filter badge count and the legend "Reset filter" handler; both omitted the new power category, so a Certified/Legacy selection was invisible in the legend badge and survived the reset click. Mirror the GPUGraph edit. --- packages/app/src/components/inference/ui/ScatterGraph.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/app/src/components/inference/ui/ScatterGraph.tsx b/packages/app/src/components/inference/ui/ScatterGraph.tsx index 52edb9521..43f041d98 100644 --- a/packages/app/src/components/inference/ui/ScatterGraph.tsx +++ b/packages/app/src/components/inference/ui/ScatterGraph.tsx @@ -460,6 +460,7 @@ const ScatterGraph = React.memo( setQuickFilterFrameworks, setQuickFilterDeployment, setQuickFilterSpec, + setQuickFilterPower, } = useInferenceActions(); const locale = useLocale(); const legendT = SCATTER_STRINGS[locale]; @@ -1073,17 +1074,20 @@ const ScatterGraph = React.memo( quickFilters.vendors.length + quickFilters.frameworks.length + quickFilters.deployment.length + + quickFilters.power.length + (selectedSequence === Sequence.AgenticTraces ? 0 : quickFilters.spec.length); const clearQuickFilters = useCallback(() => { setQuickFilterVendors([]); setQuickFilterFrameworks([]); setQuickFilterDeployment([]); setQuickFilterSpec([]); + setQuickFilterPower([]); }, [ setQuickFilterVendors, setQuickFilterFrameworks, setQuickFilterDeployment, setQuickFilterSpec, + setQuickFilterPower, ]); const pointsTable = useMemo(() => { From bcdafe76f93e8f82c9a5df2660b95c919996304d Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Thu, 27 Aug 2026 12:00:25 -0700 Subject: [PATCH 06/20] =?UTF-8?q?feat(inference):=20role-local=20measured?= =?UTF-8?q?=20energy=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 转义)。 --- .../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 d7324cebd125787cad985b8ba35f3fe980c06653 Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Thu, 27 Aug 2026 12:03:28 -0700 Subject: [PATCH 07/20] =?UTF-8?q?test(inference):=20cover=20agentic=20meas?= =?UTF-8?q?ured=20power,=20role-energy=20axes,=20and=20worker=20drilldown?= =?UTF-8?q?=20|=20=E6=B5=8B=E8=AF=95=EF=BC=9A=E8=A6=86=E7=9B=96=20AgentX?= =?UTF-8?q?=20=E5=AE=9E=E6=B5=8B=E5=8A=9F=E8=80=97=E3=80=81=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E7=BA=A7=E8=83=BD=E8=80=97=E5=9D=90=E6=A0=87=E8=BD=B4?= =?UTF-8?q?=E4=B8=8E=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 转义)。 --- .../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 87905b95bd1cf73a414889a3d5bee0bb3ca20674 Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Thu, 27 Aug 2026 12:08:46 -0700 Subject: [PATCH 08/20] =?UTF-8?q?test(e2e):=20AgentX=20measured-power=20sp?= =?UTF-8?q?ec=20with=20synthetic=20workers=20|=20=E7=AB=AF=E5=88=B0?= =?UTF-8?q?=E7=AB=AF=E6=B5=8B=E8=AF=95=EF=BC=9A=E4=BD=BF=E7=94=A8=E5=90=88?= =?UTF-8?q?=E6=88=90=20worker=20=E6=95=B0=E6=8D=AE=E7=9A=84=20AgentX=20?= =?UTF-8?q?=E5=AE=9E=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 的点保持正常。 --- .../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 ce14eec15b9daf3a54b80b07b0d95e53b2b0e661 Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Thu, 27 Aug 2026 12:41:52 -0700 Subject: [PATCH 09/20] =?UTF-8?q?fix(e2e):=20assert=20coverage-filtered=20?= =?UTF-8?q?series=20leaves=20the=20legend,=20prove=20non-destructive=20axi?= =?UTF-8?q?s=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 语义 补充说明。 --- .../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(' · ')}
`; From 6e1132c067c7e2d106594354533a2017613e2373 Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Thu, 27 Aug 2026 11:11:29 -0700 Subject: [PATCH 10/20] =?UTF-8?q?feat(inference):=20derive=20measured-powe?= =?UTF-8?q?r=20certification=20tier=20with=20quick=20filter=20|=20?= =?UTF-8?q?=E6=8E=A8=E7=90=86=E5=9B=BE=E8=A1=A8=EF=BC=9A=E6=B4=BE=E7=94=9F?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E5=8A=9F=E8=80=97=E8=AE=A4=E8=AF=81=E5=B1=82?= =?UTF-8?q?=E7=BA=A7=E5=B9=B6=E6=96=B0=E5=A2=9E=E5=BF=AB=E6=8D=B7=E7=AD=9B?= =?UTF-8?q?=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compute a per-entry power_tier ('certified' | 'legacy') in rowToAggDataEntry from the producer verdict and whole-deployment energy semantics, without changing which values render. Add a 'Measured Power' quick-filter category (Certified/Legacy pills, i_power URL param) wired through QuickFilters, InferenceContext, the dialog, and GPUGraph. 在 rowToAggDataEntry 中根据生产端验证结论与整体部署能耗语义派生 power_tier(certified/legacy),不改变任何渲染值。新增“实测功耗” 快捷筛选(已认证/旧版,URL 参数 i_power)。 --- .../component/quick-filters-dialog.cy.tsx | 3 + packages/app/cypress/support/mock-data.ts | 5 +- .../components/inference/InferenceContext.tsx | 21 ++++++- .../app/src/components/inference/types.ts | 12 ++++ .../src/components/inference/ui/GPUGraph.tsx | 4 ++ .../inference/ui/QuickFiltersDialog.tsx | 42 +++++++++++--- .../ui/ScatterGraph.test-harness.tsx | 4 +- .../inference/utils/quickFilters.ts | 32 ++++++++++- packages/app/src/lib/benchmark-transform.ts | 57 ++++++++++++------- packages/app/src/lib/power-tier.ts | 39 +++++++++++++ packages/app/src/lib/url-state.ts | 5 +- 11 files changed, 187 insertions(+), 37 deletions(-) create mode 100644 packages/app/src/lib/power-tier.ts diff --git a/packages/app/cypress/component/quick-filters-dialog.cy.tsx b/packages/app/cypress/component/quick-filters-dialog.cy.tsx index 86cf90525..d1f42b81d 100644 --- a/packages/app/cypress/component/quick-filters-dialog.cy.tsx +++ b/packages/app/cypress/component/quick-filters-dialog.cy.tsx @@ -8,6 +8,7 @@ const availableQuickFilters: QuickFilters = { frameworks: ['vllm', 'sglang'], deployment: ['single-node', 'multi-node', 'disagg'], spec: ['mtp', 'stp'], + power: ['certified', 'legacy'], }; describe('QuickFiltersDialog', () => { @@ -47,6 +48,7 @@ describe('QuickFiltersDialog', () => { frameworks: ['sglang'], deployment: ['disagg'], spec: ['mtp'], + power: ['certified'], }, availableQuickFilters, }, @@ -57,5 +59,6 @@ describe('QuickFiltersDialog', () => { cy.get('@setQuickFilterFrameworks').should('have.been.calledWith', []); cy.get('@setQuickFilterDeployment').should('have.been.calledWith', []); cy.get('@setQuickFilterSpec').should('have.been.calledWith', []); + cy.get('@setQuickFilterPower').should('have.been.calledWith', []); }); }); diff --git a/packages/app/cypress/support/mock-data.ts b/packages/app/cypress/support/mock-data.ts index 8482bd693..ec680c7cb 100644 --- a/packages/app/cypress/support/mock-data.ts +++ b/packages/app/cypress/support/mock-data.ts @@ -233,12 +233,13 @@ export function createMockInferenceContextValues( setSelectedXAxisMode: namedStub('setSelectedXAxisMode'), scaleType: 'auto', setScaleType: namedStub('setScaleType'), - quickFilters: { vendors: [], frameworks: [], deployment: [], spec: [] }, - availableQuickFilters: { vendors: [], frameworks: [], deployment: [], spec: [] }, + quickFilters: { vendors: [], frameworks: [], deployment: [], spec: [], power: [] }, + availableQuickFilters: { vendors: [], frameworks: [], deployment: [], spec: [], power: [] }, setQuickFilterVendors: namedStub('setQuickFilterVendors'), setQuickFilterFrameworks: namedStub('setQuickFilterFrameworks'), setQuickFilterDeployment: namedStub('setQuickFilterDeployment'), setQuickFilterSpec: namedStub('setQuickFilterSpec'), + setQuickFilterPower: namedStub('setQuickFilterPower'), isLegendExpanded: true, setIsLegendExpanded: namedStub('setIsLegendExpanded'), hideNonOptimal: false, diff --git a/packages/app/src/components/inference/InferenceContext.tsx b/packages/app/src/components/inference/InferenceContext.tsx index 9de9dbae3..701e9f690 100644 --- a/packages/app/src/components/inference/InferenceContext.tsx +++ b/packages/app/src/components/inference/InferenceContext.tsx @@ -96,7 +96,9 @@ import { bestSeriesPerSku } from './utils/best-series-per-sku'; import { EMPTY_QUICK_FILTERS, parseDeploymentModes, + parsePowerTiers, type DeploymentMode, + type PowerTier, type QuickFilters, type SpecMode, } from './utils/quickFilters'; @@ -442,7 +444,7 @@ export function InferenceProvider({ () => (getUrlParam('i_scale') as 'auto' | 'linear' | 'log') || 'auto', ); - // ── Quick filters (vendor / framework / deployment / mtp-stp) ─────────────── + // ── Quick filters (vendor / framework / deployment / mtp-stp / power tier) ── // Coarse pre-filters applied to the point set. Empty = no constraint. // // Initialized empty rather than from the URL so the first client render matches @@ -455,8 +457,9 @@ export function InferenceProvider({ const [quickFilterFrameworks, setQuickFilterFrameworks] = useState([]); const [quickFilterDeployment, setQuickFilterDeployment] = useState([]); const [quickFilterSpec, setQuickFilterSpec] = useState([]); + const [quickFilterPower, setQuickFilterPower] = useState([]); useEffect(() => { - const parse = (key: 'i_vendor' | 'i_fw' | 'i_disagg' | 'i_spec') => { + const parse = (key: 'i_vendor' | 'i_fw' | 'i_disagg' | 'i_spec' | 'i_power') => { const v = getUrlParam(key); return v ? v.split(',').filter(Boolean) : []; }; @@ -466,10 +469,12 @@ export function InferenceProvider({ // point, so expand it to both aggregate deployment modes. const deployment = parseDeploymentModes(parse('i_disagg')); const spec = parse('i_spec') as SpecMode[]; + const power = parsePowerTiers(parse('i_power')); if (vendors.length > 0) setQuickFilterVendors(vendors); if (frameworks.length > 0) setQuickFilterFrameworks(frameworks); if (deployment.length > 0) setQuickFilterDeployment(deployment); if (spec.length > 0) setQuickFilterSpec(spec); + if (power.length > 0) setQuickFilterPower(power); }, [getUrlParam]); const quickFilters = useMemo( () => ({ @@ -477,8 +482,15 @@ export function InferenceProvider({ frameworks: quickFilterFrameworks, deployment: quickFilterDeployment, spec: quickFilterSpec, + power: quickFilterPower, }), - [quickFilterVendors, quickFilterFrameworks, quickFilterDeployment, quickFilterSpec], + [ + quickFilterVendors, + quickFilterFrameworks, + quickFilterDeployment, + quickFilterSpec, + quickFilterPower, + ], ); // Historical Trends hides Quick Filters, so never apply invisible selections there. // Agentic charts expose vendor, framework, and deployment filters, but speculative @@ -1491,6 +1503,7 @@ export function InferenceProvider({ i_fw: quickFilterFrameworks.join(','), i_disagg: quickFilterDeployment.join(','), i_spec: quickFilterSpec.join(','), + i_power: quickFilterPower.join(','), }, [ selectedYAxisMetric, @@ -1517,6 +1530,7 @@ export function InferenceProvider({ quickFilterFrameworks, quickFilterDeployment, quickFilterSpec, + quickFilterPower, ], ); @@ -1783,6 +1797,7 @@ export function InferenceProvider({ setQuickFilterFrameworks, setQuickFilterDeployment, setQuickFilterSpec, + setQuickFilterPower, setIsLegendExpanded, setHideNonOptimal, setShowPointLabels, diff --git a/packages/app/src/components/inference/types.ts b/packages/app/src/components/inference/types.ts index e27b01229..4c7875ac8 100644 --- a/packages/app/src/components/inference/types.ts +++ b/packages/app/src/components/inference/types.ts @@ -3,6 +3,7 @@ import type { WorkerPower } from '@semianalysisai/inferencex-db/queries/benchmar import type { HardwareEntry } from '@/lib/constants'; import type { Model, Sequence } from '@/lib/data-mappings'; +import type { PowerTier } from '@/lib/power-tier'; import type { MetricKey } from './metric-registry'; export type { WorkerPower }; @@ -115,6 +116,14 @@ export interface AggDataEntry { // Optional because historical runs predate the fields. power_valid?: number; power_metric_schema_version?: number; + /** + * Certification tier for the measured power telemetry, derived by + * `resolvePowerTier` in the transform: `certified` for producer-validated + * rows (with whole-deployment energy semantics where applicable), `legacy` + * for telemetry that predates the validation contract, absent when no + * measured telemetry survives gating. + */ + power_tier?: PowerTier; avg_power_w?: number; joules_per_successful_query?: number; joules_per_output_token?: number; @@ -563,6 +572,8 @@ export interface QuickFilters { frameworks: string[]; deployment: DeploymentMode[]; spec: SpecMode[]; + /** Measured-power certification tiers (see `@/lib/power-tier`). */ + power: PowerTier[]; } /** @@ -671,6 +682,7 @@ export interface InferenceActionsContextType { setQuickFilterFrameworks: (frameworks: string[]) => void; setQuickFilterDeployment: (modes: DeploymentMode[]) => void; setQuickFilterSpec: (modes: SpecMode[]) => void; + setQuickFilterPower: (tiers: PowerTier[]) => void; setIsLegendExpanded: (expanded: boolean) => void; setHideNonOptimal: (hide: boolean) => void; setShowPointLabels: (show: boolean) => void; diff --git a/packages/app/src/components/inference/ui/GPUGraph.tsx b/packages/app/src/components/inference/ui/GPUGraph.tsx index 3396bcc9f..b520f4298 100644 --- a/packages/app/src/components/inference/ui/GPUGraph.tsx +++ b/packages/app/src/components/inference/ui/GPUGraph.tsx @@ -173,6 +173,7 @@ const GPUGraph = React.memo( setQuickFilterFrameworks, setQuickFilterDeployment, setQuickFilterSpec, + setQuickFilterPower, } = useInferenceActions(); const locale = useLocale(); const legendT = GPU_STRINGS[locale]; @@ -184,17 +185,20 @@ const GPUGraph = React.memo( quickFilters.vendors.length + quickFilters.frameworks.length + quickFilters.deployment.length + + quickFilters.power.length + (selectedSequence === Sequence.AgenticTraces ? 0 : quickFilters.spec.length); const clearQuickFilters = useCallback(() => { setQuickFilterVendors([]); setQuickFilterFrameworks([]); setQuickFilterDeployment([]); setQuickFilterSpec([]); + setQuickFilterPower([]); }, [ setQuickFilterVendors, setQuickFilterFrameworks, setQuickFilterDeployment, setQuickFilterSpec, + setQuickFilterPower, ]); // Shared date+GPU pairs. `dates` holds comparison-series entries (plain dates diff --git a/packages/app/src/components/inference/ui/QuickFiltersDialog.tsx b/packages/app/src/components/inference/ui/QuickFiltersDialog.tsx index 86f24fad5..af577e296 100644 --- a/packages/app/src/components/inference/ui/QuickFiltersDialog.tsx +++ b/packages/app/src/components/inference/ui/QuickFiltersDialog.tsx @@ -4,6 +4,7 @@ import { ListFilter } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; import type { DeploymentMode, SpecMode } from '@/components/inference/types'; +import type { PowerTier } from '@/lib/power-tier'; import { FRAMEWORK_FAMILIES } from '@/components/inference/utils/quickFilters'; import { @@ -29,9 +30,9 @@ const STRINGS = { en: { title: 'Quick Filters', description: - 'Narrow the chart by chip vendor, serving framework, deployment mode, and speculative decoding. Selecting none in a group shows all.', + 'Narrow the chart by chip vendor, serving framework, deployment mode, speculative decoding, and measured-power certification. Selecting none in a group shows all.', agenticDescription: - 'Narrow the chart by chip vendor, serving framework, and deployment mode. Selecting none in a group shows all.', + 'Narrow the chart by chip vendor, serving framework, deployment mode, and measured-power certification. Selecting none in a group shows all.', selected: 'selected', bestPerSku: 'Best per SKU', bestPerSkuHint: 'Show only the best configuration for each chip', @@ -42,14 +43,19 @@ const STRINGS = { multiNode: 'Multi-node', disaggregated: 'Disaggregated', specDecoding: 'Spec Decoding', + power: 'Measured Power', + certified: 'Certified', + legacyTier: 'Legacy', noData: 'No data for the current selection', clear: 'Clear filters', done: 'Done', }, zh: { title: '快捷筛选', - description: '按芯片厂商、推理框架、部署模式和投机解码筛选图表。某组不选则显示全部。', - agenticDescription: '按芯片厂商、推理框架和部署模式筛选图表。某组不选则显示全部。', + description: + '按芯片厂商、推理框架、部署模式、投机解码和实测功耗认证筛选图表。某组不选则显示全部。', + agenticDescription: + '按芯片厂商、推理框架、部署模式和实测功耗认证筛选图表。某组不选则显示全部。', selected: '项已选', bestPerSku: '每个 SKU 仅显示最佳配置', bestPerSkuHint: '每款芯片只显示表现最佳的配置', @@ -60,6 +66,9 @@ const STRINGS = { multiNode: '多节点聚合', disaggregated: '分离式', specDecoding: '投机解码', + power: '实测功耗', + certified: '已认证', + legacyTier: '旧版', noData: '当前选择无可用数据', clear: '清除筛选', done: '完成', @@ -75,6 +84,7 @@ const SPEC_MODES: { value: SpecMode; label: string }[] = [ { value: 'mtp', label: 'MTP' }, { value: 'stp', label: 'STP' }, ]; +const POWER_TIERS: PowerTier[] = ['certified', 'legacy']; function toggleValue(current: T[], value: T): T[] { return current.includes(value) ? current.filter((item) => item !== value) : [...current, value]; @@ -98,6 +108,7 @@ export function QuickFiltersDialog({ setQuickFilterFrameworks, setQuickFilterDeployment, setQuickFilterSpec, + setQuickFilterPower, } = useInferenceActions(); const { selectedSequence, quickFilters } = useInferenceFilters(); const { availableQuickFilters } = useInferenceData(); @@ -114,7 +125,7 @@ export function QuickFiltersDialog({ })); const groups: { - key: 'vendor' | 'framework' | 'deployment' | 'spec'; + key: 'vendor' | 'framework' | 'deployment' | 'spec' | 'power'; label: string; options: readonly { value: string; label: string; available: boolean }[]; selected: readonly string[]; @@ -166,12 +177,24 @@ export function QuickFiltersDialog({ selected: quickFilters.spec, }, ]), + // Shown for agentic too — the pills auto-disable when no measured + // telemetry exists for the current selection. + { + key: 'power' as const, + label: t.power, + options: POWER_TIERS.map((value) => ({ + value, + label: value === 'certified' ? t.certified : t.legacyTier, + available: availableQuickFilters.power.includes(value), + })), + selected: quickFilters.power, + }, ]; const selectedCount = groups.reduce((count, group) => count + group.selected.length, 0); const handleToggle = ( - category: 'vendor' | 'framework' | 'deployment' | 'spec', + category: 'vendor' | 'framework' | 'deployment' | 'spec' | 'power', value: string, ) => { const wasActive = @@ -181,12 +204,16 @@ export function QuickFiltersDialog({ ? quickFilters.frameworks.includes(value) : category === 'deployment' ? quickFilters.deployment.includes(value as DeploymentMode) - : quickFilters.spec.includes(value as SpecMode); + : category === 'power' + ? quickFilters.power.includes(value as PowerTier) + : quickFilters.spec.includes(value as SpecMode); if (category === 'vendor') setQuickFilterVendors(toggleValue(quickFilters.vendors, value)); else if (category === 'framework') setQuickFilterFrameworks(toggleValue(quickFilters.frameworks, value)); else if (category === 'deployment') setQuickFilterDeployment(toggleValue(quickFilters.deployment, value as DeploymentMode)); + else if (category === 'power') + setQuickFilterPower(toggleValue(quickFilters.power, value as PowerTier)); else setQuickFilterSpec(toggleValue(quickFilters.spec, value as SpecMode)); track('inference_quick_filter_toggled', { category, value, active: !wasActive }); }; @@ -196,6 +223,7 @@ export function QuickFiltersDialog({ setQuickFilterFrameworks([]); setQuickFilterDeployment([]); setQuickFilterSpec([]); + setQuickFilterPower([]); track('inference_quick_filters_cleared', { source: 'dialog' }); }; diff --git a/packages/app/src/components/inference/ui/ScatterGraph.test-harness.tsx b/packages/app/src/components/inference/ui/ScatterGraph.test-harness.tsx index 2708315e9..4f0fcb183 100644 --- a/packages/app/src/components/inference/ui/ScatterGraph.test-harness.tsx +++ b/packages/app/src/components/inference/ui/ScatterGraph.test-harness.tsx @@ -133,8 +133,8 @@ export function baseInferenceState() { openRouterPricingLoading: false, openRouterPricingError: null, setTokenRevenuePriceSource: noop, - quickFilters: { vendors: [], frameworks: [], deployment: [], spec: [] }, - availableQuickFilters: { vendors: [], frameworks: [], deployment: [], spec: [] }, + quickFilters: { vendors: [], frameworks: [], deployment: [], spec: [], power: [] }, + availableQuickFilters: { vendors: [], frameworks: [], deployment: [], spec: [], power: [] }, availableRuns: {}, selectedRunId: '', hideNonOptimal: false, diff --git a/packages/app/src/components/inference/utils/quickFilters.ts b/packages/app/src/components/inference/utils/quickFilters.ts index 763738455..c7352e36e 100644 --- a/packages/app/src/components/inference/utils/quickFilters.ts +++ b/packages/app/src/components/inference/utils/quickFilters.ts @@ -8,15 +8,17 @@ import type { SpecMode, } from '@/components/inference/types'; import { frameworkFamily } from '@/lib/framework-family'; +import type { PowerTier } from '@/lib/power-tier'; -export type { AvailableQuickFilters, DeploymentMode, QuickFilters, SpecMode }; +export type { AvailableQuickFilters, DeploymentMode, PowerTier, QuickFilters, SpecMode }; /** Vendor display order for the quick-filter pills. */ const VENDOR_ORDER = ['NVIDIA', 'AMD']; /** * Quick filters let users narrow the chart to any combination of GPU vendor, - * serving framework, deployment mode, and speculative-decoding method without + * serving framework, deployment mode, speculative-decoding method, and + * measured-power certification tier without * touching the legend or GPU-config selectors. They are coarse pre-filters * applied to the point set (official + unofficial-run overlay), so the legend, * rooflines, and Pareto all reflect only the matching configs. @@ -31,8 +33,12 @@ export const EMPTY_QUICK_FILTERS: QuickFilters = { frameworks: [], deployment: [], spec: [], + power: [], }; +/** Measured-power tier display order for the quick-filter pills. */ +const POWER_TIER_ORDER: readonly PowerTier[] = ['certified', 'legacy']; + /** * Serving-framework families surfaced as quick filters, in display order. Each * family groups its base + variant engines (e.g. TRTLLM covers `trt`, `trtllm`, @@ -63,6 +69,8 @@ export function computeAvailableQuickFilters( let hasDisagg = false; let hasMtp = false; let hasStp = false; + let hasCertified = false; + let hasLegacy = false; for (const p of points) { const vendor = pointVendor(String(p.hwKey)); if (vendor) vendors.add(vendor); @@ -74,6 +82,8 @@ export function computeAvailableQuickFilters( else hasSingleNode = true; if (pointSpecMode(p) === 'mtp') hasMtp = true; else hasStp = true; + if (p.power_tier === 'certified') hasCertified = true; + else if (p.power_tier === 'legacy') hasLegacy = true; } const deployment: DeploymentMode[] = []; if (hasSingleNode) deployment.push('single-node'); @@ -87,13 +97,18 @@ export function computeAvailableQuickFilters( frameworks: FRAMEWORK_FAMILY_ORDER.filter((f) => frameworks.has(f)), deployment, spec, + power: POWER_TIER_ORDER.filter((tier) => (tier === 'certified' ? hasCertified : hasLegacy)), }; } /** True when at least one category constrains the point set. */ export function quickFiltersActive(f: QuickFilters): boolean { return ( - f.vendors.length > 0 || f.frameworks.length > 0 || f.deployment.length > 0 || f.spec.length > 0 + f.vendors.length > 0 || + f.frameworks.length > 0 || + f.deployment.length > 0 || + f.spec.length > 0 || + f.power.length > 0 ); } @@ -133,6 +148,13 @@ export function parseDeploymentModes(values: readonly string[]): DeploymentMode[ ); } +/** Parse `i_power` URL values, deduping and dropping unknown strings. */ +export function parsePowerTiers(values: readonly string[]): PowerTier[] { + return [...new Set(values)].filter( + (tier): tier is PowerTier => tier === 'certified' || tier === 'legacy', + ); +} + /** Whether a single data point satisfies every active quick-filter category. */ export function matchesQuickFilters(point: InferenceData, f: QuickFilters): boolean { if (f.vendors.length > 0) { @@ -145,6 +167,10 @@ export function matchesQuickFilters(point: InferenceData, f: QuickFilters): bool } if (f.deployment.length > 0 && !f.deployment.includes(pointDeploymentMode(point))) return false; if (f.spec.length > 0 && !f.spec.includes(pointSpecMode(point))) return false; + if (f.power.length > 0) { + const tier = point.power_tier; + if (!tier || !f.power.includes(tier)) return false; + } return true; } diff --git a/packages/app/src/lib/benchmark-transform.ts b/packages/app/src/lib/benchmark-transform.ts index 283b8fad4..150831be6 100644 --- a/packages/app/src/lib/benchmark-transform.ts +++ b/packages/app/src/lib/benchmark-transform.ts @@ -19,6 +19,7 @@ import { } from '@/lib/chart-utils'; import { getHardwareConfig } from '@/lib/constants'; import { isPersistedBenchmarkId } from '@/lib/benchmark-id'; +import { resolvePowerTier } from '@/lib/power-tier'; import type { BenchmarkRow } from '@/lib/api'; /** @@ -118,6 +119,31 @@ export function rowToAggDataEntry(row: BenchmarkRow): AggDataEntry { // failure that made versioning necessary in the first place. const hasWholeDeploymentEnergySemantics = !row.disagg || m.power_metric_schema_version === WHOLE_DEPLOYMENT_ENERGY_SCHEMA_VERSION; + // Gated measured power values, hoisted so the tier below can see exactly + // what the chart will render (behavior identical to the previous inline + // conditionals in the returned object). + const avgPowerW = measuredPowerValid ? m.avg_power_w : undefined; + const prefillAvgPowerW = measuredPowerValid ? m.prefill_avg_power_w : undefined; + const decodeAvgPowerW = measuredPowerValid ? m.decode_avg_power_w : undefined; + const joulesPerSuccessfulQuery = + measuredPowerValid && hasWholeDeploymentEnergySemantics + ? m.joules_per_successful_query + : undefined; + const joulesPerOutputToken = + measuredPowerValid && hasWholeDeploymentEnergySemantics ? m.joules_per_output_token : undefined; + const joulesPerTotalToken = + measuredPowerValid && hasWholeDeploymentEnergySemantics ? m.joules_per_total_token : undefined; + const joulesPerInputToken = + measuredPowerValid && hasWholeDeploymentEnergySemantics ? m.joules_per_input_token : undefined; + const hasMeasuredTelemetry = [ + avgPowerW, + prefillAvgPowerW, + decodeAvgPowerW, + joulesPerSuccessfulQuery, + joulesPerOutputToken, + joulesPerTotalToken, + joulesPerInputToken, + ].some((value) => typeof value === 'number'); // Prefer the dedicated column (added in migration 004); fall back to the // legacy stash inside `metrics` for any rows ingested before that column // existed. @@ -194,27 +220,20 @@ export function rowToAggDataEntry(row: BenchmarkRow): AggDataEntry { // "no measurement" from "0 W" via createChartDataPoint's typeof guard. power_valid: m.power_valid, power_metric_schema_version: m.power_metric_schema_version, - avg_power_w: measuredPowerValid ? m.avg_power_w : undefined, - joules_per_successful_query: - measuredPowerValid && hasWholeDeploymentEnergySemantics - ? m.joules_per_successful_query - : undefined, - joules_per_output_token: - measuredPowerValid && hasWholeDeploymentEnergySemantics - ? m.joules_per_output_token - : undefined, - joules_per_total_token: - measuredPowerValid && hasWholeDeploymentEnergySemantics - ? m.joules_per_total_token - : undefined, + power_tier: resolvePowerTier({ + powerValid: m.power_valid, + wholeDeploymentSemantics: hasWholeDeploymentEnergySemantics, + hasMeasuredTelemetry, + }), + avg_power_w: avgPowerW, + joules_per_successful_query: joulesPerSuccessfulQuery, + joules_per_output_token: joulesPerOutputToken, + joules_per_total_token: joulesPerTotalToken, // Role power remains unambiguous across schema versions. Version 2 also // publishes explicit role energy alongside whole-deployment joules. - prefill_avg_power_w: measuredPowerValid ? m.prefill_avg_power_w : undefined, - decode_avg_power_w: measuredPowerValid ? m.decode_avg_power_w : undefined, - joules_per_input_token: - measuredPowerValid && hasWholeDeploymentEnergySemantics - ? m.joules_per_input_token - : undefined, + prefill_avg_power_w: prefillAvgPowerW, + decode_avg_power_w: decodeAvgPowerW, + joules_per_input_token: joulesPerInputToken, prefill_joules_per_input_token: measuredPowerValid ? m.prefill_joules_per_input_token : undefined, diff --git a/packages/app/src/lib/power-tier.ts b/packages/app/src/lib/power-tier.ts new file mode 100644 index 000000000..0c7b6f9fe --- /dev/null +++ b/packages/app/src/lib/power-tier.ts @@ -0,0 +1,39 @@ +/** + * Certification tier for measured power telemetry on a benchmark entry. + * + * - `certified` — the producer (runner's `aggregate_power.py`) explicitly + * validated the measurement (`power_valid === 1`) AND the row's unprefixed + * joules fields carry whole-deployment semantics (non-disagg rows always do; + * disagg rows only under `WHOLE_DEPLOYMENT_ENERGY_SCHEMA_VERSION`, see + * `benchmark-transform.ts`). + * - `legacy` — measured telemetry renders but predates the validation + * contract: no verdict at all, or a disagg verdict without the versioned + * whole-deployment energy semantics. + * + * Absent (`undefined`) means no measured telemetry survives field gating. + */ +export type PowerTier = 'certified' | 'legacy'; + +/** + * Resolve the certification tier for one entry's measured power telemetry. + * + * Legacy admission is deliberate: historical single-node rows were never run + * through the producer validator, and hiding them would vanish the bulk of + * the measured-power dataset. An explicit `power_valid === 0` verdict is + * authoritative the other way — its telemetry is scrubbed upstream, so the + * row carries no tier rather than a "legacy" one. + */ +export function resolvePowerTier(args: { + /** Producer verdict: 1 valid, 0 invalid, undefined for legacy rows. */ + powerValid: number | undefined; + /** + * Whether the unprefixed joules fields mean whole-deployment energy + * (`!disagg || power_metric_schema_version === WHOLE_DEPLOYMENT_ENERGY_SCHEMA_VERSION`). + */ + wholeDeploymentSemantics: boolean; + /** Whether any measured power/energy value survives field gating. */ + hasMeasuredTelemetry: boolean; +}): PowerTier | undefined { + if (!args.hasMeasuredTelemetry || args.powerValid === 0) return undefined; + return args.powerValid === 1 && args.wholeDeploymentSemantics ? 'certified' : 'legacy'; +} diff --git a/packages/app/src/lib/url-state.ts b/packages/app/src/lib/url-state.ts index a02601a65..c7f6c0354 100644 --- a/packages/app/src/lib/url-state.ts +++ b/packages/app/src/lib/url-state.ts @@ -51,12 +51,14 @@ const URL_STATE_KEYS = [ 'i_gradlabel', 'i_linelabel', 'i_active', - // Quick filters (vendor / framework / deployment / mtp-stp). + // Quick filters (vendor / framework / deployment / mtp-stp / power tier). // `i_disagg` keeps its historical name for shared-link compatibility. 'i_vendor', 'i_fw', 'i_disagg', 'i_spec', + // Measured-power certification tiers ('certified' / 'legacy', comma-joined). + 'i_power', // Exact serving-envelope pair behind an Overview 30-day comparison cell. 'i_overview_current', 'i_overview_baseline', @@ -159,6 +161,7 @@ export const PARAM_DEFAULTS: Record = { i_fw: '', i_disagg: '', i_spec: '', + i_power: '', i_overview_current: '', i_overview_baseline: '', e_rundate: '', From dd1b4e2af2c191c565e6bcc7f311b1a7506a05ed Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Thu, 27 Aug 2026 11:11:54 -0700 Subject: [PATCH 11/20] =?UTF-8?q?feat(inference):=20mark=20legacy=20measur?= =?UTF-8?q?ed-power=20points=20on=20Measured=20Energy=20axes=20|=20?= =?UTF-8?q?=E6=8E=A8=E7=90=86=E5=9B=BE=E8=A1=A8=EF=BC=9A=E5=9C=A8=E5=AE=9E?= =?UTF-8?q?=E6=B5=8B=E8=83=BD=E8=80=97=E5=9D=90=E6=A0=87=E8=BD=B4=E4=B8=8A?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E6=97=A7=E7=89=88=E5=8A=9F=E8=80=97=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the nine Measured Energy y-axes, draw a dotted ring around points whose telemetry lacks a producer validation verdict (official and overlay render paths), show a localized footer legend key when such points are on screen, state the tier in all three tooltip generators (EN/ZH), and append a tier note to the measured axis explanations. 在九个实测能耗 Y 轴上,为缺少生产端验证结论的数据点绘制虚线圆环 (官方与叠加渲染路径),当此类点可见时在图表底部显示本地化图例, 在三种工具提示中标注认证层级(中英文),并在实测指标说明中补充 层级说明。 --- .../inference/axis-metric-explanations.ts | 50 +++++++++++------- .../components/inference/metric-registry.ts | 39 ++++++++++---- .../components/inference/ui/ChartDisplay.tsx | 10 +++- .../inference/ui/LegacyPowerLegendKey.tsx | 51 +++++++++++++++++++ .../components/inference/ui/ScatterGraph.tsx | 29 ++++++++--- .../inference/utils/legacy-power-marker.ts | 34 +++++++++++++ .../inference/utils/tooltipUtils.ts | 23 ++++++++- 7 files changed, 198 insertions(+), 38 deletions(-) create mode 100644 packages/app/src/components/inference/ui/LegacyPowerLegendKey.tsx create mode 100644 packages/app/src/components/inference/utils/legacy-power-marker.ts diff --git a/packages/app/src/components/inference/axis-metric-explanations.ts b/packages/app/src/components/inference/axis-metric-explanations.ts index 14c5488b0..65f14cdd2 100644 --- a/packages/app/src/components/inference/axis-metric-explanations.ts +++ b/packages/app/src/components/inference/axis-metric-explanations.ts @@ -209,6 +209,18 @@ function provisionedJoules(tokenType: TokenType): MetricExplanation { }; } +/** + * Certification-tier note appended to every Measured Energy explanation: + * pairs the dotted legacy-power point ring and the Quick Filters → Measured + * Power pills with the footer text that explains them. + */ +const MEASURED_TIER_NOTE_EN = + ' Points without a producer validation verdict are legacy measurements — marked with a ' + + 'dotted ring on the chart and filterable via Quick Filters → Measured Power.'; +const MEASURED_TIER_NOTE_ZH = + '没有生产端验证结论的数据点属于旧版测量——在图表上以虚线圆环标记,' + + '并可通过快捷筛选中的“实测功耗”进行筛选。'; + type MeasuredPhase = 'run' | 'prefill' | 'decode'; const MEASURED_PHASE_EN: Record = { @@ -228,11 +240,13 @@ function measuredPower(phase: MeasuredPhase): MetricExplanation { description: { en: `Average per-chip accelerator power actually drawn during ${MEASURED_PHASE_EN[phase]}, ` + - 'read from runner telemetry. Unlike the all-in provisioned metrics, this reflects real ' + - 'measured draw, not the provisioned budget.', + `read from runner telemetry. Unlike the all-in provisioned metrics, this reflects real ` + + `measured draw, not the provisioned budget.${MEASURED_TIER_NOTE_EN}`, zh: `${MEASURED_PHASE_ZH[phase]}每块加速器芯片的实际平均功耗,来自运行器遥测数据。` + - '与全电源配置类指标不同,它反映的是真实实测功耗,而不是按配置计算的预算值。', + `与全电源配置类指标不同,它反映的是真实实测功耗,而不是按配置计算的预算值。${ + MEASURED_TIER_NOTE_ZH + }`, }, formula: { en: `W = mean of sampled per-chip accelerator power draw over ${MEASURED_PHASE_EN[phase]}`, @@ -248,10 +262,10 @@ function measuredJoulesPerToken(tokenType: TokenType): MetricExplanation { `Measured accelerator energy consumed per ${ tokenType === 'total' ? 'token (including prompt tokens)' : `${tokenType} token` }, from runner power telemetry integrated over the run. Lower means the system converts ` + - 'electricity into tokens more efficiently.', + `electricity into tokens more efficiently.${MEASURED_TIER_NOTE_EN}`, zh: `每个${TOKEN_TYPE_ZH[tokenType]}消耗的加速器实测能耗,由运行器功耗遥测在整个运行期间积分得到。` + - '数值越低,说明系统把电能转化为 token 的效率越高。', + `数值越低,说明系统把电能转化为 token 的效率越高。${MEASURED_TIER_NOTE_ZH}`, }, formula: { en: `J/tok = measured accelerator energy over the run ÷ ${TOKEN_TYPE_EN[tokenType]} processed`, @@ -353,12 +367,12 @@ export const METRIC_EXPLANATIONS: Record = { measuredJPerSuccessfulQuery: { description: { en: - 'Measured accelerator energy consumed per successfully completed request, from runner ' + - 'power telemetry. It charges the energy of the whole run only to requests that finished ' + - 'successfully.', + `Measured accelerator energy consumed per successfully completed request, from runner ` + + `power telemetry. It charges the energy of the whole run only to requests that finished ` + + `successfully.${MEASURED_TIER_NOTE_EN}`, zh: - '每个成功完成的请求消耗的加速器实测能耗,来自运行器功耗遥测。' + - '整个运行的能耗只计入成功完成的请求。', + `每个成功完成的请求消耗的加速器实测能耗,来自运行器功耗遥测。` + + `整个运行的能耗只计入成功完成的请求。${MEASURED_TIER_NOTE_ZH}`, }, formula: { en: 'J/query = measured accelerator energy over the run ÷ successfully completed requests', @@ -368,9 +382,9 @@ export const METRIC_EXPLANATIONS: Record = { measuredWhPerSuccessfulQuery: { description: { en: - 'The same measured energy per successful request expressed in watt-hours, a more ' + - 'familiar household unit (1 Wh = 3,600 J).', - zh: '与每次成功请求实测能耗相同的量,换算成更直观的瓦时单位(1 Wh = 3,600 J)。', + `The same measured energy per successful request expressed in watt-hours, a more ` + + `familiar household unit (1 Wh = 3,600 J).${MEASURED_TIER_NOTE_EN}`, + zh: `与每次成功请求实测能耗相同的量,换算成更直观的瓦时单位(1 Wh = 3,600 J)。${MEASURED_TIER_NOTE_ZH}`, }, formula: { en: 'Wh/query = measured J per successful query ÷ 3,600', @@ -380,11 +394,13 @@ export const METRIC_EXPLANATIONS: Record = { measuredPowerPercentTdp: { description: { en: - 'Measured average per-chip power as a share of the accelerator’s rated TDP. Values well ' + - 'below 100% suggest the workload leaves thermal or power headroom on the table.', + `Measured average per-chip power as a share of the accelerator’s rated TDP. Values well ` + + `below 100% suggest the workload leaves thermal or power headroom on the table.${ + MEASURED_TIER_NOTE_EN + }`, zh: - '每芯片实测平均功耗占加速器额定 TDP 的百分比。' + - '明显低于 100% 说明该工作负载没有用满散热或供电余量。', + `每芯片实测平均功耗占加速器额定 TDP 的百分比。` + + `明显低于 100% 说明该工作负载没有用满散热或供电余量。${MEASURED_TIER_NOTE_ZH}`, }, formula: { en: '% TDP = measured average power per chip (W) ÷ rated TDP (W) × 100', diff --git a/packages/app/src/components/inference/metric-registry.ts b/packages/app/src/components/inference/metric-registry.ts index f01b3c66c..2ee697e2e 100644 --- a/packages/app/src/components/inference/metric-registry.ts +++ b/packages/app/src/components/inference/metric-registry.ts @@ -491,6 +491,33 @@ export interface MetricControlGroup { gated?: boolean; } +/** + * The nine 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. + */ +export const MEASURED_ENERGY_METRIC_CONFIG_KEYS = [ + 'y_measuredPrefillAvgPower', + 'y_measuredDecodeAvgPower', + 'y_measuredAvgPower', + 'y_measuredJPerInputToken', + 'y_measuredJPerOutputToken', + 'y_measuredJPerTotalToken', + 'y_measuredJPerSuccessfulQuery', + 'y_measuredWhPerSuccessfulQuery', + 'y_measuredPowerPercentTdp', +] as const satisfies readonly MetricConfigKey[]; + +const MEASURED_ENERGY_METRIC_CONFIG_KEY_SET: ReadonlySet = new Set( + MEASURED_ENERGY_METRIC_CONFIG_KEYS, +); + +/** 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); +} + export const METRIC_CONTROL_GROUPS: readonly MetricControlGroup[] = [ { label: 'Throughput', @@ -562,17 +589,7 @@ export const METRIC_CONTROL_GROUPS: readonly MetricControlGroup[] = [ { label: 'Measured Energy', labelZh: '实测能耗', - metrics: [ - 'y_measuredPrefillAvgPower', - 'y_measuredDecodeAvgPower', - 'y_measuredAvgPower', - 'y_measuredJPerInputToken', - 'y_measuredJPerOutputToken', - 'y_measuredJPerTotalToken', - 'y_measuredJPerSuccessfulQuery', - 'y_measuredWhPerSuccessfulQuery', - 'y_measuredPowerPercentTdp', - ], + metrics: MEASURED_ENERGY_METRIC_CONFIG_KEYS, }, { label: 'Custom User Values', diff --git a/packages/app/src/components/inference/ui/ChartDisplay.tsx b/packages/app/src/components/inference/ui/ChartDisplay.tsx index 78be385f1..9adb6c8d0 100644 --- a/packages/app/src/components/inference/ui/ChartDisplay.tsx +++ b/packages/app/src/components/inference/ui/ChartDisplay.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { BarChart3, Table2 } from 'lucide-react'; import chartDefinitions, { + isMeasuredEnergyConfigKey, tokenMetricTypeForConfigKey, type MetricKey, } from '@/components/inference/metric-registry'; @@ -88,6 +89,7 @@ import { useLocale } from '@/lib/use-locale'; import { ATOM_FOOTNOTE_MARKER, AtomEngineFootnote } from '@/components/ui/atom-engine-footnote'; import { AgenticOptimizationNote } from '@/components/inference/ui/AgenticOptimizationNote'; import { OffloadHaloLegendKey } from '@/components/inference/ui/OffloadHaloLegendKey'; +import { LegacyPowerLegendKey } from '@/components/inference/ui/LegacyPowerLegendKey'; import AxisMetricFooter from './AxisMetricFooter'; import ChartControls from './ChartControls'; @@ -849,15 +851,21 @@ export default function ChartDisplay({ embedded = false }: { embedded?: boolean ...(footerOverlay?.clippedData ?? []).map((entry) => entry.point), ]; const hasOffloadHalo = footerPoints.some((point) => point.offload_mode === 'on'); + // Legacy-power rings render only on Measured Energy axes, so the + // key follows the same gate to never advertise an absent ring. + const hasLegacyPowerPoints = + isMeasuredEnergyConfigKey(selectedYAxisMetric) && + footerPoints.some((point) => point.power_tier === 'legacy'); const hasAtomSeries = footerPoints.some( (point) => point.framework !== undefined && getFrameworkLabel(point.framework).includes(ATOM_FOOTNOTE_MARKER), ); const footerNotices = - hasOffloadHalo || isAgenticSequence || hasAtomSeries ? ( + hasOffloadHalo || hasLegacyPowerPoints || isAgenticSequence || hasAtomSeries ? ( <> {hasOffloadHalo && } + {hasLegacyPowerPoints && } {isAgenticSequence && } {hasAtomSeries && } diff --git a/packages/app/src/components/inference/ui/LegacyPowerLegendKey.tsx b/packages/app/src/components/inference/ui/LegacyPowerLegendKey.tsx new file mode 100644 index 000000000..5f1826a68 --- /dev/null +++ b/packages/app/src/components/inference/ui/LegacyPowerLegendKey.tsx @@ -0,0 +1,51 @@ +import { POINT_SIZE } from '@/lib/chart-rendering'; +import { useLocale } from '@/lib/use-locale'; + +// Nested outside the KV-offload halo (POINT_SIZE + 4, '3 2' dashes) with a +// dotted dasharray so both decorations stay legible on one point. +export const LEGACY_POWER_RING_RADIUS = POINT_SIZE + 7; +export const LEGACY_POWER_RING_STROKE_WIDTH = 1.5; +export const LEGACY_POWER_RING_DASHARRAY = '1 3'; + +const STRINGS = { + en: 'Legacy power data (no validation verdict)', + zh: '旧版功耗数据(无验证结论)', +} as const; + +/** + * Key for the dotted ring drawn around measured-axis points whose power + * telemetry predates the producer validation contract (`power_tier === + * 'legacy'`). Rendered in the axis-metric info footer below the chart (the + * footer is `no-export`, so downloaded PNGs carry only the ring itself). + */ +export function LegacyPowerLegendKey() { + const locale = useLocale(); + + return ( +
+ + {STRINGS[locale]} +
+ ); +} diff --git a/packages/app/src/components/inference/ui/ScatterGraph.tsx b/packages/app/src/components/inference/ui/ScatterGraph.tsx index f3f96ae96..7d6311108 100644 --- a/packages/app/src/components/inference/ui/ScatterGraph.tsx +++ b/packages/app/src/components/inference/ui/ScatterGraph.tsx @@ -121,6 +121,8 @@ import { } from '@/components/inference/utils/point-identity'; import LegendPointsDialog from '@/components/inference/ui/LegendPointsDialog'; import { renderOffloadHalo } from '@/components/inference/utils/offload-halo'; +import { renderLegacyPowerRing } from '@/components/inference/utils/legacy-power-marker'; +import { isMeasuredEnergyConfigKey } from '@/components/inference/metric-registry'; import { buildLegendPointsRows } from '@/components/inference/utils/legend-points-table'; import { resolveScatterXAxisScale } from '@/components/inference/utils/x-axis-scale'; import { pointLabelText } from './point-label'; @@ -478,6 +480,9 @@ const ScatterGraph = React.memo( const ephemeralUrlState = useEphemeralUrlState(); const costLimit = chartDefinition.y_cost_limit ?? 0; const latencyLimit = chartDefinition.y_latency_limit ?? 0; + // Legacy-power rings decorate points only while a Measured Energy y-axis + // is selected (see legacy-power-marker.ts). + const isMeasuredEnergyAxis = isMeasuredEnergyConfigKey(selectedYAxisMetric); const { isUnofficialRun, @@ -2623,14 +2628,15 @@ const ScatterGraph = React.memo( overlayRunColor(overlayRunIndex(d.run_url ?? null, runIndexByUrl)), ); - // Match official points: KV offload is the only persistent - // point decoration. Decode method remains in the tooltip. + // Match official points: KV offload and the measured-axis + // legacy-power ring are the only persistent point decorations. + // Decode method remains in the tooltip. overlayPoints.each(function (d) { - renderOffloadHalo( - d3.select(this), - d, - overlayRunColor(overlayRunIndex(d.run_url ?? null, runIndexByUrl)), + const overlayStroke = overlayRunColor( + overlayRunIndex(d.run_url ?? null, runIndexByUrl), ); + renderOffloadHalo(d3.select(this), d, overlayStroke); + renderLegacyPowerRing(d3.select(this), d, isMeasuredEnergyAxis, overlayStroke); }); // Labels @@ -2995,6 +3001,7 @@ const ScatterGraph = React.memo( xLabel, yLabel, selectedYAxisMetric, + isMeasuredEnergyAxis, chartDefinition, locale, drawPerfRuler, @@ -3016,9 +3023,11 @@ const ScatterGraph = React.memo( // CSS transitions for smooth opacity animation on hw toggle zoomGroup.selectAll('.dot-group').style('transition', 'opacity 150ms ease'); - // Offload halo: dashed ring on every point that used KV offload (Pareto or not) + // Offload halo: dashed ring on every point that used KV offload (Pareto or not). + // Legacy-power ring: dotted ring on unvalidated telemetry, measured axes only. zoomGroup.selectAll('.dot-group').each(function (d) { renderOffloadHalo(d3.select(this), d, 'var(--foreground)'); + renderLegacyPowerRing(d3.select(this), d, isMeasuredEnergyAxis, 'var(--foreground)'); }); avoidPointLabelCollisions(zoomGroup); @@ -3048,6 +3057,9 @@ const ScatterGraph = React.memo( optimalPointKeys, getCssColor, resolveColor, + // A metric-only change must re-run the decoration pass so legacy-power + // rings appear/disappear with the Measured Energy axis selection. + isMeasuredEnergyAxis, ], ); @@ -3081,8 +3093,9 @@ const ScatterGraph = React.memo( color, ); // A precision toggle may replace and append the visible SVG shape. - // Keep the offload halo above that shape after the swap. + // Keep the decorations above that shape after the swap. point.selectAll('.offload-halo').raise(); + point.selectAll('.legacy-power-ring').raise(); }); // Overlay points keep their X marker and run-derived color. Only their diff --git a/packages/app/src/components/inference/utils/legacy-power-marker.ts b/packages/app/src/components/inference/utils/legacy-power-marker.ts new file mode 100644 index 000000000..b0a9ae92f --- /dev/null +++ b/packages/app/src/components/inference/utils/legacy-power-marker.ts @@ -0,0 +1,34 @@ +import type * as d3 from 'd3'; + +import type { InferenceData } from '@/components/inference/types'; +import { + LEGACY_POWER_RING_DASHARRAY, + LEGACY_POWER_RING_RADIUS, + LEGACY_POWER_RING_STROKE_WIDTH, +} from '@/components/inference/ui/LegacyPowerLegendKey'; + +/** + * Dotted ring flagging measured-power telemetry without a producer validation + * verdict (`power_tier === 'legacy'`). Drawn only while a Measured Energy + * y-axis is selected; the join-on-empty-data pattern removes the ring when the + * axis changes or the point's tier is not legacy. + */ +export function renderLegacyPowerRing( + group: d3.Selection, + point: InferenceData, + isMeasuredAxis: boolean, + stroke: string, +): void { + group + .selectAll('.legacy-power-ring') + .data(isMeasuredAxis && point.power_tier === 'legacy' ? [true] : []) + .join('circle') + .attr('class', 'legacy-power-ring') + .attr('r', LEGACY_POWER_RING_RADIUS) + .attr('fill', 'none') + .attr('stroke', stroke) + .attr('stroke-width', LEGACY_POWER_RING_STROKE_WIDTH) + .attr('stroke-dasharray', LEGACY_POWER_RING_DASHARRAY) + .attr('opacity', 0.9) + .attr('pointer-events', 'none'); +} diff --git a/packages/app/src/components/inference/utils/tooltipUtils.ts b/packages/app/src/components/inference/utils/tooltipUtils.ts index 731ec54b8..c016c74ab 100644 --- a/packages/app/src/components/inference/utils/tooltipUtils.ts +++ b/packages/app/src/components/inference/utils/tooltipUtils.ts @@ -6,6 +6,7 @@ import type { Locale } from '@/lib/i18n'; import { isKvOffloadEnabled } from '@/lib/kv-offload'; import type { HardwareConfig, InferenceData, OverlayData } from '@/components/inference/types'; +import { isMeasuredEnergyConfigKey } from '@/components/inference/metric-registry'; import { meaningfulParallelismSize, parallelismLabel, @@ -142,6 +143,9 @@ const TOOLTIP_STRINGS = { precision: 'Precision', inputTputPerChip: 'Input Token Throughput per Chip', outputTputPerChip: 'Output Token Throughput per Chip', + powerData: 'Power Data', + powerCertified: 'Certified (validated measurement)', + powerLegacy: 'Legacy (no validation verdict)', }, zh: { dismiss: '点击其他区域关闭', @@ -156,9 +160,23 @@ const TOOLTIP_STRINGS = { precision: '精度', inputTputPerChip: '每芯片输入 token 吞吐量', outputTputPerChip: '每芯片输出 token 吞吐量', + powerData: '功耗数据', + powerCertified: '已认证(通过验证的测量)', + powerLegacy: '旧版(无验证结论)', }, } as const; +/** + * Measured-power certification tier line. Rendered only while a Measured + * Energy y-axis is selected and the point carries a tier — non-measured axes + * and telemetry-free points stay unchanged. + */ +const powerTierHTML = (d: InferenceData, selectedYAxisMetric: string, locale: Locale): string => { + if (!isMeasuredEnergyConfigKey(selectedYAxisMetric) || !d.power_tier) return ''; + const t = TOOLTIP_STRINGS[locale]; + return tooltipLine(t.powerData, d.power_tier === 'certified' ? t.powerCertified : t.powerLegacy); +}; + const CACHE_STRINGS = { en: { offloadType: 'Offload Type', @@ -483,6 +501,7 @@ export const generateTooltipContent = (config: TooltipConfig): string => { ${tooltipLine(t.outputTputPerChip, `${fmt(d['outputTputPerGpu'].y)}`)}` : '' } + ${powerTierHTML(d, selectedYAxisMetric, locale)} ${tooltipLine(t.totalChips, d.tp)} ${generateParallelismHTML(d, locale)} ${tooltipLine(t.concurrency, `${d.conc}`)} @@ -503,7 +522,7 @@ export const generateTooltipContent = (config: TooltipConfig): string => { * @returns HTML string for the tooltip content */ export const generateOverlayTooltipContent = (config: OverlayTooltipConfig): string => { - const { data: d, isPinned, xLabel, yLabel, overlayData } = config; + const { data: d, isPinned, xLabel, yLabel, selectedYAxisMetric, overlayData } = config; const locale = config.locale ?? 'en'; const t = TOOLTIP_STRINGS[locale]; const hwConfig = overlayData.hardwareConfig[d.hwKey]; @@ -523,6 +542,7 @@ export const generateOverlayTooltipContent = (config: OverlayTooltipConfig): str ${tooltipLine(t.date, formatTooltipDate(d.actualDate ?? d.date, locale))} ${tooltipLine(xLabel, fmt(d.x))} ${tooltipLine(yLabel, fmt(d.y))} + ${powerTierHTML(d, selectedYAxisMetric, locale)} ${tooltipLine(t.totalChips, d.tp)} ${generateParallelismHTML(d, locale)} ${tooltipLine(t.concurrency, `${d.conc}`)} @@ -580,6 +600,7 @@ export const generateGPUGraphTooltipContent = (config: TooltipConfig): string => ${tooltipLine(t.outputTputPerChip, `${fmt(d['outputTputPerGpu'].y)}`)}` : '' } + ${powerTierHTML(d, selectedYAxisMetric, locale)} ${tooltipLine(t.totalChips, d.tp)} ${generateParallelismHTML(d, locale)} ${tooltipLine(t.concurrency, `${d.conc}`)} From 46a066887e069c3a7420ced4cdee09f9b0112b3d Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Thu, 27 Aug 2026 11:16:50 -0700 Subject: [PATCH 12/20] =?UTF-8?q?test(inference):=20cover=20measured-power?= =?UTF-8?q?=20tier=20derivation,=20filter,=20ring,=20and=20tooltips=20|=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=9A=E8=A6=86=E7=9B=96=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?=E5=8A=9F=E8=80=97=E5=B1=82=E7=BA=A7=E6=B4=BE=E7=94=9F=E3=80=81?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E3=80=81=E5=9C=86=E7=8E=AF=E4=B8=8E=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Table-driven resolvePowerTier cases, tier derivation through rowToAggDataEntry/transformBenchmarkRows, quick-filter matching and availability, measured-energy key classification, decoration rendering per axis, tier tooltip lines (EN/ZH), and the i_power URL default. 覆盖 resolvePowerTier 表驱动用例、转换层层级派生、快捷筛选匹配与 可用性、实测能耗指标判定、按坐标轴的圆环渲染、中英文工具提示层级 行以及 i_power URL 默认值。 --- .../inference/metric-registry.test.ts | 24 +++++ .../ui/ScatterGraph.decoration.test.tsx | 35 +++++++ .../ui/ScatterGraph.overlay.test.tsx | 2 +- .../inference/utils/quickFilters.test.ts | 46 ++++++++++ .../inference/utils/tooltip-utils.test.ts | 92 +++++++++++++++++++ .../app/src/lib/benchmark-transform.test.ts | 89 ++++++++++++++++++ packages/app/src/lib/power-tier.test.ts | 71 ++++++++++++++ packages/app/src/lib/url-state.test.ts | 5 + 8 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/lib/power-tier.test.ts diff --git a/packages/app/src/components/inference/metric-registry.test.ts b/packages/app/src/components/inference/metric-registry.test.ts index 3dd20c09d..7fa6a80c9 100644 --- a/packages/app/src/components/inference/metric-registry.test.ts +++ b/packages/app/src/components/inference/metric-registry.test.ts @@ -4,6 +4,8 @@ import { chartDefinitions, DEFAULT_METRIC_CONFIG_KEY, isBenchmarkMetricKey, + isMeasuredEnergyConfigKey, + MEASURED_ENERGY_METRIC_CONFIG_KEYS, METRIC_CONFIG_KEYS, METRIC_CONTROL_GROUPS, METRIC_REGISTRY, @@ -104,6 +106,28 @@ describe('metric registry', () => { expect(group.labelZh).toContain(' TCO '); } }); + + 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('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); + }); }); describe('metric compatibility', () => { diff --git a/packages/app/src/components/inference/ui/ScatterGraph.decoration.test.tsx b/packages/app/src/components/inference/ui/ScatterGraph.decoration.test.tsx index f70037913..cd77b5e09 100644 --- a/packages/app/src/components/inference/ui/ScatterGraph.decoration.test.tsx +++ b/packages/app/src/components/inference/ui/ScatterGraph.decoration.test.tsx @@ -127,6 +127,41 @@ describe('ScatterGraph toggle decoration', () => { unmount(); }); + it('rings legacy-power points only on Measured Energy axes', () => { + const legacy = { + ...point('h100', 'fp8', 10, 100, 1), + power_tier: 'legacy', + } as InferenceData; + const certified = { + ...point('h100', 'fp8', 20, 200, 2), + power_tier: 'certified', + } as InferenceData; + const tierless = point('h100', 'fp8', 40, 400, 4); + + inferenceState.current = { + ...baseInferenceState(), + selectedYAxisMetric: 'y_measuredAvgPower', + }; + const measured = mountChart({ data: [legacy, certified, tierless] }); + const groups = dotGroups(measured.container); + + expect(groups[0].querySelector('.legacy-power-ring')).not.toBeNull(); + expect(groups[1].querySelector('.legacy-power-ring')).toBeNull(); + expect(groups[2].querySelector('.legacy-power-ring')).toBeNull(); + // The legend key lives in ChartDisplay's axis-metric footer, not the chart. + expect(measured.container.querySelector('[data-testid="legacy-power-key"]')).toBeNull(); + measured.unmount(); + + // Non-measured axis: the same legacy point renders without a ring. + inferenceState.current = { + ...baseInferenceState(), + selectedYAxisMetric: 'y', + }; + const throughput = mountChart({ data: [legacy, certified, tierless] }); + expect(throughput.container.querySelectorAll('.legacy-power-ring')).toHaveLength(0); + throughput.unmount(); + }); + it('reads current trace availability without changing metric identity', () => { const agenticPoint = { ...point('h100', 'fp8', 20, 200, 2), diff --git a/packages/app/src/components/inference/ui/ScatterGraph.overlay.test.tsx b/packages/app/src/components/inference/ui/ScatterGraph.overlay.test.tsx index 889a4a82e..499d79bc3 100644 --- a/packages/app/src/components/inference/ui/ScatterGraph.overlay.test.tsx +++ b/packages/app/src/components/inference/ui/ScatterGraph.overlay.test.tsx @@ -213,7 +213,7 @@ describe('ScatterGraph unofficial overlays', () => { // exactly as it would the official points. inferenceState.current = { ...baseInferenceState(), - quickFilters: { vendors: ['AMD'], frameworks: [], deployment: [], spec: [] }, + quickFilters: { vendors: ['AMD'], frameworks: [], deployment: [], spec: [], power: [] }, }; const { container, unmount } = mountChart({ overlayData: { diff --git a/packages/app/src/components/inference/utils/quickFilters.test.ts b/packages/app/src/components/inference/utils/quickFilters.test.ts index 106fd2fb4..3c74d9b53 100644 --- a/packages/app/src/components/inference/utils/quickFilters.test.ts +++ b/packages/app/src/components/inference/utils/quickFilters.test.ts @@ -9,6 +9,7 @@ import { matchesQuickFilters, pointDeploymentMode, parseDeploymentModes, + parsePowerTiers, pointVendor, quickFiltersActive, type QuickFilters, @@ -67,6 +68,7 @@ describe('computeAvailableQuickFilters', () => { disagg: false, is_multinode: true, spec_decoding: 'none', + power_tier: 'certified', }), ]; expect(computeAvailableQuickFilters(points)).toEqual({ @@ -74,6 +76,7 @@ describe('computeAvailableQuickFilters', () => { frameworks: ['vllm', 'trt', 'atom'], deployment: ['single-node', 'multi-node', 'disagg'], spec: ['mtp', 'stp'], + power: ['certified'], }); }); @@ -86,15 +89,29 @@ describe('computeAvailableQuickFilters', () => { frameworks: ['vllm'], deployment: ['single-node'], spec: ['stp'], + power: [], }); }); + it('reports exactly the power tiers present, in display order', () => { + const legacy = point({ power_tier: 'legacy' }); + const certified = point({ power_tier: 'certified' }); + expect(computeAvailableQuickFilters([legacy]).power).toEqual(['legacy']); + expect(computeAvailableQuickFilters([certified]).power).toEqual(['certified']); + // Display order stays certified-first even when legacy points come first. + expect(computeAvailableQuickFilters([legacy, certified]).power).toEqual([ + 'certified', + 'legacy', + ]); + }); + it('returns all-empty for an empty point set', () => { expect(computeAvailableQuickFilters([])).toEqual({ vendors: [], frameworks: [], deployment: [], spec: [], + power: [], }); }); }); @@ -106,6 +123,17 @@ describe('quickFiltersActive', () => { expect(quickFiltersActive(filters({ frameworks: ['vllm'] }))).toBe(true); expect(quickFiltersActive(filters({ deployment: ['disagg'] }))).toBe(true); expect(quickFiltersActive(filters({ spec: ['mtp'] }))).toBe(true); + expect(quickFiltersActive(filters({ power: ['certified'] }))).toBe(true); + }); +}); + +describe('parsePowerTiers', () => { + it('keeps known tiers, removes duplicates, and drops unknown values', () => { + expect(parsePowerTiers(['certified', 'legacy', 'certified', 'bogus', ''])).toEqual([ + 'certified', + 'legacy', + ]); + expect(parsePowerTiers([])).toEqual([]); }); }); @@ -160,6 +188,24 @@ describe('matchesQuickFilters', () => { expect(matchesQuickFilters(point({ hwKey: 'h100_vllm' }), stpFilter)).toBe(true); }); + it('filters by measured-power certification tier', () => { + const certifiedOnly = filters({ power: ['certified'] }); + const bothTiers = filters({ power: ['certified', 'legacy'] }); + const certified = point({ power_tier: 'certified' }); + const legacy = point({ power_tier: 'legacy' }); + const tierless = point({}); + + expect(matchesQuickFilters(certified, certifiedOnly)).toBe(true); + expect(matchesQuickFilters(legacy, certifiedOnly)).toBe(false); + expect(matchesQuickFilters(tierless, certifiedOnly)).toBe(false); + // Both tiers still exclude points with no measured telemetry at all. + expect(matchesQuickFilters(certified, bothTiers)).toBe(true); + expect(matchesQuickFilters(legacy, bothTiers)).toBe(true); + expect(matchesQuickFilters(tierless, bothTiers)).toBe(false); + // No power constraint keeps tier-less points. + expect(matchesQuickFilters(tierless, EMPTY_QUICK_FILTERS)).toBe(true); + }); + it('treats non-standard spec methods (e.g. eagle) as spec-on (MTP), never STP', () => { const eagle = point({ hwKey: 'h200_trt_eagle', spec_decoding: 'eagle' }); // Standard (STP) means `none` only — a speculative method groups under MTP. 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 743942710..ee403c957 100644 --- a/packages/app/src/components/inference/utils/tooltip-utils.test.ts +++ b/packages/app/src/components/inference/utils/tooltip-utils.test.ts @@ -780,3 +780,95 @@ describe('generateGPUGraphTooltipContent', () => { ).not.toContain('data-action="view-charts"'); }); }); + +// =========================================================================== +// measured-power certification tier line (all three generators) +// =========================================================================== +describe('power tier tooltip line', () => { + it('states the tier for a legacy point on a measured axis', () => { + const html = generateTooltipContent( + tooltipConfig({ + selectedYAxisMetric: 'y_measuredJPerOutputToken', + data: pt({ power_tier: 'legacy' }), + }), + ); + expect(html).toContain('Power Data: Legacy (no validation verdict)'); + }); + + it('states the certified tier on a measured axis', () => { + const html = generateTooltipContent( + tooltipConfig({ + selectedYAxisMetric: 'y_measuredAvgPower', + data: pt({ power_tier: 'certified' }), + }), + ); + expect(html).toContain('Power Data: Certified (validated measurement)'); + }); + + it('omits the tier line on non-measured axes', () => { + const html = generateTooltipContent( + tooltipConfig({ + selectedYAxisMetric: 'y_tpPerGpu', + data: pt({ power_tier: 'legacy' }), + }), + ); + expect(html).not.toContain('Power Data'); + }); + + it('omits the tier line when the point carries no tier', () => { + const html = generateTooltipContent( + tooltipConfig({ selectedYAxisMetric: 'y_measuredAvgPower', data: pt() }), + ); + expect(html).not.toContain('Power Data'); + }); + + it('renders the ZH strings under the zh locale', () => { + const legacy = generateTooltipContent( + tooltipConfig({ + selectedYAxisMetric: 'y_measuredJPerOutputToken', + data: pt({ power_tier: 'legacy' }), + locale: 'zh', + }), + ); + const certified = generateTooltipContent( + tooltipConfig({ + selectedYAxisMetric: 'y_measuredJPerOutputToken', + data: pt({ power_tier: 'certified' }), + locale: 'zh', + }), + ); + expect(legacy).toContain('功耗数据: 旧版(无验证结论)'); + expect(certified).toContain('功耗数据: 已认证(通过验证的测量)'); + }); + + it('reaches overlay and GPU-graph tooltips through the same gate', () => { + const overlay = generateOverlayTooltipContent({ + ...tooltipConfig({ + selectedYAxisMetric: 'y_measuredAvgPower', + data: pt({ power_tier: 'legacy' }), + }), + overlayData: { + label: 'feature-branch', + hardwareConfig: mockHardwareConfig, + data: [], + runUrl: 'https://example.com', + } as any, + }); + const gpuGraph = generateGPUGraphTooltipContent( + tooltipConfig({ + selectedYAxisMetric: 'y_measuredAvgPower', + data: pt({ power_tier: 'legacy' }), + }), + ); + const gpuGraphOffAxis = generateGPUGraphTooltipContent( + tooltipConfig({ + selectedYAxisMetric: 'y_costh', + data: pt({ power_tier: 'legacy' }), + }), + ); + + expect(overlay).toContain('Power Data: Legacy (no validation verdict)'); + expect(gpuGraph).toContain('Power Data: Legacy (no validation verdict)'); + expect(gpuGraphOffAxis).not.toContain('Power Data'); + }); +}); diff --git a/packages/app/src/lib/benchmark-transform.test.ts b/packages/app/src/lib/benchmark-transform.test.ts index fac8734d8..a834c28f7 100644 --- a/packages/app/src/lib/benchmark-transform.test.ts +++ b/packages/app/src/lib/benchmark-transform.test.ts @@ -454,6 +454,95 @@ describe('rowToAggDataEntry', () => { expect(point.measuredPowerPercentTdp?.y).toBe(80); }); + it('certifies a validated non-disagg row (no schema version required)', () => { + const entry = rowToAggDataEntry( + makeRow({ + metrics: { + power_valid: 1, + avg_power_w: 685.5, + }, + }), + ); + expect(entry.power_tier).toBe('certified'); + }); + + it('certifies a validated disagg row stamped with schema v2', () => { + const entry = rowToAggDataEntry( + makeRow({ + disagg: true, + metrics: { + power_valid: 1, + power_metric_schema_version: 2, + avg_power_w: 650, + joules_per_output_token: 9.7, + }, + }), + ); + expect(entry.power_tier).toBe('certified'); + }); + + it('classifies a no-verdict row with telemetry as legacy', () => { + const entry = rowToAggDataEntry( + makeRow({ + metrics: { + avg_power_w: 560, + joules_per_successful_query: 1800, + }, + }), + ); + expect(entry.power_tier).toBe('legacy'); + // The tier is informational only — telemetry still renders (G2 is + // distinguish, not exclude). + expect(entry.avg_power_w).toBe(560); + }); + + it('classifies a validated disagg row without schema v2 as legacy', () => { + const entry = rowToAggDataEntry( + makeRow({ + disagg: true, + metrics: { + power_valid: 1, + avg_power_w: 650, + prefill_avg_power_w: 612.3, + joules_per_output_token: 9.7, + }, + }), + ); + // Role watts render, but their provenance predates the versioned + // whole-deployment energy contract. + expect(entry.power_tier).toBe('legacy'); + expect(entry.prefill_avg_power_w).toBe(612.3); + expect(entry.joules_per_output_token).toBeUndefined(); + }); + + it('leaves power_tier absent for explicit power_valid=0 and telemetry-free rows', () => { + const invalid = rowToAggDataEntry( + makeRow({ + metrics: { + power_valid: 0, + avg_power_w: 685.5, + joules_per_output_token: 8.4, + }, + }), + ); + expect(invalid.power_tier).toBeUndefined(); + + const noTelemetry = rowToAggDataEntry(makeRow({ metrics: {} })); + expect(noTelemetry.power_tier).toBeUndefined(); + }); + + it('carries power_tier onto transformed chart points', () => { + const legacyRow = makeRow({ id: 1, metrics: { avg_power_w: 560 } }); + const certifiedRow = makeRow({ + id: 2, + conc: 128, + metrics: { power_valid: 1, avg_power_w: 685.5 }, + }); + const { chartData } = transformBenchmarkRows([legacyRow, certifiedRow]); + const points = chartData.find((data) => data.length > 0)!; + expect(points.map((p) => p.power_tier)).toEqual(['legacy', 'certified']); + }); + it('passes through per-worker measured power array intact', () => { const workers = [ { diff --git a/packages/app/src/lib/power-tier.test.ts b/packages/app/src/lib/power-tier.test.ts new file mode 100644 index 000000000..5e694a0fe --- /dev/null +++ b/packages/app/src/lib/power-tier.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { resolvePowerTier, type PowerTier } from './power-tier'; + +describe('resolvePowerTier', () => { + const cases: { + name: string; + powerValid: number | undefined; + wholeDeploymentSemantics: boolean; + hasMeasuredTelemetry: boolean; + expected: PowerTier | undefined; + }[] = [ + { + name: 'certified for a validated row with whole-deployment semantics', + powerValid: 1, + wholeDeploymentSemantics: true, + hasMeasuredTelemetry: true, + expected: 'certified', + }, + { + name: 'legacy for telemetry without a producer verdict', + powerValid: undefined, + wholeDeploymentSemantics: true, + hasMeasuredTelemetry: true, + expected: 'legacy', + }, + { + name: 'legacy for a validated disagg row without schema v2 semantics', + powerValid: 1, + wholeDeploymentSemantics: false, + hasMeasuredTelemetry: true, + expected: 'legacy', + }, + { + name: 'absent for an explicit invalid verdict (telemetry scrubbed upstream)', + powerValid: 0, + wholeDeploymentSemantics: true, + hasMeasuredTelemetry: false, + expected: undefined, + }, + { + // Defensive: even if a caller passed surviving telemetry with pv=0, + // the explicit verdict stays authoritative. + name: 'absent for an explicit invalid verdict regardless of telemetry', + powerValid: 0, + wholeDeploymentSemantics: true, + hasMeasuredTelemetry: true, + expected: undefined, + }, + { + name: 'absent when the row carries no measured telemetry', + powerValid: undefined, + wholeDeploymentSemantics: true, + hasMeasuredTelemetry: false, + expected: undefined, + }, + { + name: 'absent for a validated row whose telemetry fields are all missing', + powerValid: 1, + wholeDeploymentSemantics: true, + hasMeasuredTelemetry: false, + expected: undefined, + }, + ]; + + for (const { name, expected, ...args } of cases) { + it(name, () => { + expect(resolvePowerTier(args)).toBe(expected); + }); + } +}); diff --git a/packages/app/src/lib/url-state.test.ts b/packages/app/src/lib/url-state.test.ts index bea16b8a0..25e0d3c1d 100644 --- a/packages/app/src/lib/url-state.test.ts +++ b/packages/app/src/lib/url-state.test.ts @@ -64,6 +64,11 @@ describe('PARAM_DEFAULTS', () => { expect(PARAM_DEFAULTS.e_bench).toBe(''); }); + it('has an empty default for the measured-power quick filter (filter off)', async () => { + const { PARAM_DEFAULTS } = await import('@/lib/url-state'); + expect(PARAM_DEFAULTS.i_power).toBe(''); + }); + it('has empty string default for i_gradlabel', async () => { const { PARAM_DEFAULTS } = await import('@/lib/url-state'); expect(PARAM_DEFAULTS.i_gradlabel).toBe(''); From e1014e6620a5b85e894f880f40b4dd02be3fbc8a Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Thu, 27 Aug 2026 11:24:56 -0700 Subject: [PATCH 13/20] =?UTF-8?q?test(e2e):=20certified-vs-legacy=20measur?= =?UTF-8?q?ed=20power=20filter=20spec=20|=20=E7=AB=AF=E5=88=B0=E7=AB=AF?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=9A=E5=B7=B2=E8=AE=A4=E8=AF=81=E4=B8=8E?= =?UTF-8?q?=E6=97=A7=E7=89=88=E5=AE=9E=E6=B5=8B=E5=8A=9F=E8=80=97=E7=AD=9B?= =?UTF-8?q?=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deterministic intercepted fixtures (one certified, one legacy config) drive the measured-axis ring, footer legend key, Certified/Legacy quick filter pills, clear-filters reset, and i_power share-link restore. 使用确定性的拦截数据(一个已认证、一个旧版配置)验证实测坐标轴 圆环、底部图例、已认证/旧版快捷筛选、清除筛选以及 i_power 分享 链接恢复。 --- .../cypress/e2e/certified-power-filter.cy.ts | 174 ++++++++++++++++++ packages/app/timings.json | 4 + 2 files changed, 178 insertions(+) create mode 100644 packages/app/cypress/e2e/certified-power-filter.cy.ts diff --git a/packages/app/cypress/e2e/certified-power-filter.cy.ts b/packages/app/cypress/e2e/certified-power-filter.cy.ts new file mode 100644 index 000000000..c2f35bfad --- /dev/null +++ b/packages/app/cypress/e2e/certified-power-filter.cy.ts @@ -0,0 +1,174 @@ +// Certified-vs-legacy 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 (Certified/Legacy pills, `i_power` share-link param). Fixture rows +// are intercepted so one config is certified (power_valid=1) and one is +// legacy (no verdict), keeping every assertion deterministic regardless of +// what the production dataset contains. + +const POWER_MODEL = 'dsv4'; +const POWER_DATE = '2026-08-20'; + +const powerPercentileLadder = (prefix: string, base: number): Record => ({ + [`median_${prefix}`]: base, + [`p75_${prefix}`]: base * 1.2, + [`p90_${prefix}`]: base * 1.5, + [`p95_${prefix}`]: base * 1.7, + [`p99_${prefix}`]: base * 2.2, + [`std_${prefix}`]: base * 0.3, +}); + +const powerMetrics = (conc: number): Record => { + const scale = conc / 16; + const itl = 0.011 * scale; + return { + ...powerPercentileLadder('ttft', 0.4 * scale), + ...powerPercentileLadder('tpot', 0.012 * scale), + ...powerPercentileLadder('itl', itl), + ...powerPercentileLadder('e2el', 8 * scale), + median_intvty: 1 / itl, + p75_intvty: 1 / (itl * 1.2), + p90_intvty: 1 / (itl * 1.5), + p99_intvty: 1 / (itl * 2.2), + std_intvty: (1 / itl) * 0.1, + tput_per_gpu: 950 / Math.sqrt(scale), + output_tput_per_gpu: 210, + input_tput_per_gpu: 740, + }; +}; + +const powerConfigs = [ + // Legacy telemetry: measured watts without a producer verdict. + { hardware: 'b200', framework: 'vllm', power: { avg_power_w: 560 } }, + // Certified telemetry: explicit power_valid=1 verdict. + { hardware: 'mi300x', framework: 'sglang', power: { power_valid: 1, avg_power_w: 685.5 } }, +]; + +const powerAvailability = powerConfigs.map((config) => ({ + model: POWER_MODEL, + isl: 8192, + osl: 1024, + precision: 'fp4', + hardware: config.hardware, + framework: config.framework, + spec_method: 'none', + disagg: false, + benchmark_type: 'single_turn', + date: POWER_DATE, +})); + +let powerBenchmarkId = 990000; +const powerBenchmarks = powerConfigs.flatMap((config) => + [16, 64, 128].map((conc) => ({ + id: powerBenchmarkId++, + hardware: config.hardware, + framework: config.framework, + model: POWER_MODEL, + precision: 'fp4', + spec_method: 'none', + disagg: false, + is_multinode: false, + prefill_tp: 8, + decode_tp: 8, + num_prefill_gpu: 8, + num_decode_gpu: 8, + isl: 8192, + osl: 1024, + conc, + offload_mode: 'off', + benchmark_type: 'single_turn', + image: `${config.framework}/server:test`, + metrics: { ...powerMetrics(conc), ...config.power }, + workers: null, + date: POWER_DATE, + run_url: null, + })), +); + +function visitCertifiedPowerChart(extraParams = '') { + cy.intercept('GET', '/api/v1/availability', { body: powerAvailability }).as('availability'); + cy.intercept('GET', '/api/v1/benchmarks*', { body: powerBenchmarks }).as('benchmarks'); + cy.visit(`/inference?g_model=DeepSeek-V4-Pro&i_seq=8k/1k&i_prec=fp4${extraParams}`, { + onBeforeLoad(win) { + win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now())); + }, + }); + cy.wait(['@availability', '@benchmarks']); + cy.get('[data-testid="inference-chart-display"]', { timeout: 30_000 }).should('exist'); + cy.get('[data-testid="chart-figure"]').should('have.length.at.least', 1); +} + +describe('Certified vs legacy 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() + .should('be.visible') + .click(); + cy.get('[data-slot="select-content"]').should('not.exist'); + + // 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'); + cy.get('[data-testid="quick-filter-power-legacy"]').should('be.enabled'); + + // 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'); + cy.get('.dot-group[data-hw-key^="mi300x"]').should('exist'); + cy.get('.legacy-power-ring').should('not.exist'); + cy.get('[data-testid="legacy-power-key"]').should('not.exist'); + 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( + 'have.attr', + 'aria-pressed', + 'false', + ); + cy.contains('button', 'Done').click(); + cy.get('.dot-group[data-hw-key^="b200"] .legacy-power-ring').should('exist'); + cy.get('[data-testid="legacy-power-key"]').should('be.visible'); + }); + + it('restores a shared i_power=certified link with the pill pre-selected', () => { + // Note: filter writes live in the in-memory share-link store (the address + // bar is deliberately stripped after load — see url-state.ts), so the + // durable observable behavior is the restore direction tested here. + visitCertifiedPowerChart('&i_metric=y_measuredAvgPower&i_power=certified'); + + cy.get('.dot-group[data-hw-key^="mi300x"]').should('exist'); + cy.get('.dot-group[data-hw-key^="b200"]').should('not.exist'); + cy.get('[data-testid="legacy-power-key"]').should('not.exist'); + + cy.get('[data-testid="scatter-quick-filters"]').click(); + cy.get('[data-testid="quick-filter-power-certified"]').should( + 'have.attr', + 'aria-pressed', + 'true', + ); + cy.get('[data-testid="quick-filters-selected-count"]').should('contain.text', '1 selected'); + }); +}); diff --git a/packages/app/timings.json b/packages/app/timings.json index 033c50b34..7e18e767a 100644 --- a/packages/app/timings.json +++ b/packages/app/timings.json @@ -52,6 +52,10 @@ "spec": "cypress/e2e/chart-header-toolbar-overlap.cy.ts", "duration": 4000 }, + { + "spec": "cypress/e2e/certified-power-filter.cy.ts", + "duration": 1000 + }, { "spec": "cypress/e2e/chart-overflow-continuation.cy.ts", "duration": 1955 From 6b2a06aa1a7bf4c3489028f410287ce2b0d790f0 Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Thu, 27 Aug 2026 11:49:56 -0700 Subject: [PATCH 14/20] =?UTF-8?q?fix(inference):=20count=20and=20clear=20t?= =?UTF-8?q?he=20power=20tier=20in=20the=20chart=20legend=20quick-filter=20?= =?UTF-8?q?reset=20|=20=E6=8E=A8=E7=90=86=E5=9B=BE=E8=A1=A8=EF=BC=9A?= =?UTF-8?q?=E5=9B=BE=E4=BE=8B=E5=BF=AB=E6=8D=B7=E7=AD=9B=E9=80=89=E8=AE=A1?= =?UTF-8?q?=E6=95=B0=E4=B8=8E=E9=87=8D=E7=BD=AE=E7=BA=B3=E5=85=A5=E5=AE=9E?= =?UTF-8?q?=E6=B5=8B=E5=8A=9F=E8=80=97=E5=B1=82=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScatterGraph keeps its own copy of the quick-filter badge count and the legend "Reset filter" handler; both omitted the new power category, so a Certified/Legacy selection was invisible in the legend badge and survived the reset click. Mirror the GPUGraph edit. --- packages/app/src/components/inference/ui/ScatterGraph.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/app/src/components/inference/ui/ScatterGraph.tsx b/packages/app/src/components/inference/ui/ScatterGraph.tsx index 7d6311108..126638693 100644 --- a/packages/app/src/components/inference/ui/ScatterGraph.tsx +++ b/packages/app/src/components/inference/ui/ScatterGraph.tsx @@ -474,6 +474,7 @@ const ScatterGraph = React.memo( setQuickFilterFrameworks, setQuickFilterDeployment, setQuickFilterSpec, + setQuickFilterPower, } = useInferenceActions(); const locale = useLocale(); const legendT = SCATTER_STRINGS[locale]; @@ -1088,17 +1089,20 @@ const ScatterGraph = React.memo( quickFilters.vendors.length + quickFilters.frameworks.length + quickFilters.deployment.length + + quickFilters.power.length + (selectedSequence === Sequence.AgenticTraces ? 0 : quickFilters.spec.length); const clearQuickFilters = useCallback(() => { setQuickFilterVendors([]); setQuickFilterFrameworks([]); setQuickFilterDeployment([]); setQuickFilterSpec([]); + setQuickFilterPower([]); }, [ setQuickFilterVendors, setQuickFilterFrameworks, setQuickFilterDeployment, setQuickFilterSpec, + setQuickFilterPower, ]); const pointsTable = useMemo(() => { From a5162adb647c24a459c224a794d8a4e3617e0b96 Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Mon, 31 Aug 2026 13:20:00 -0700 Subject: [PATCH 15/20] fix(inference): complete measured-power validation UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Draw historical-measurement rings in GPU comparisons, use clearer validated/historical copy with accessible help, expose visible coverage counts, and prevent singleton previews from hiding fuller curves. Add focused official, overlay, and end-to-end regressions.\n\n中文:在 GPU 对比图中绘制历史测量圆环,采用更清晰的“已验证/历史测量”文案并补充无障碍说明,显示当前可见数据覆盖率,同时避免单点预览遮蔽更完整的曲线。新增官方数据、非官方叠加与端到端回归测试。 --- .../app/cypress/component/gpu-graph.cy.tsx | 52 ++++++++ .../component/quick-filters-dialog.cy.tsx | 15 +++ .../cypress/e2e/certified-power-filter.cy.ts | 21 +++- .../inference/axis-metric-explanations.ts | 15 +-- .../src/components/inference/ui/GPUGraph.tsx | 43 ++++++- .../inference/ui/LegacyPowerLegendKey.tsx | 4 +- .../inference/ui/MeasuredPowerSummary.tsx | 117 ++++++++++++++++++ .../inference/ui/QuickFiltersDialog.tsx | 65 ++++++++-- .../ui/ScatterGraph.overlay.test.tsx | 32 +++++ .../components/inference/ui/ScatterGraph.tsx | 36 ++++++ .../utils/best-series-per-sku.test.ts | 28 +++++ .../inference/utils/best-series-per-sku.ts | 26 +++- .../inference/utils/tooltip-utils.test.ts | 24 ++-- .../inference/utils/tooltipUtils.ts | 12 +- 14 files changed, 443 insertions(+), 47 deletions(-) create mode 100644 packages/app/src/components/inference/ui/MeasuredPowerSummary.tsx diff --git a/packages/app/cypress/component/gpu-graph.cy.tsx b/packages/app/cypress/component/gpu-graph.cy.tsx index c41b986aa..0f70e13d3 100644 --- a/packages/app/cypress/component/gpu-graph.cy.tsx +++ b/packages/app/cypress/component/gpu-graph.cy.tsx @@ -162,6 +162,58 @@ describe('GPUGraph', () => { cy.get('[data-testid="gpu-graph"] svg .visible-shape').should('have.length.greaterThan', 0); }); + it('draws the historical-power ring and reports measured-point coverage', () => { + const data = [ + createMockInferenceData({ + hwKey: 'h100', + x: 32, + y: 2.1, + date: '2025-03-01', + precision: Precision.FP4, + power_tier: 'legacy', + }), + createMockInferenceData({ + hwKey: 'h100', + x: 64, + y: 1.8, + date: '2025-03-01', + precision: Precision.FP4, + power_tier: 'certified', + }), + ]; + + mountWithProviders( +
+ +
, + { + inference: { + hardwareConfig: hwConfig, + selectedGPUs: ['h100'], + selectedDates: ['2025-03-01'], + selectedDateRange: { startDate: '', endDate: '' }, + activeDates: new Set(['2025-03-01_h100']), + selectedPrecisions: [Precision.FP4], + selectedYAxisMetric: 'y_measuredJPerOutputToken', + hideNonOptimal: false, + }, + }, + ); + + cy.get('#test-gpu-measured-power svg .legacy-power-ring').should('have.length', 1); + cy.get('[data-testid="measured-power-summary"]') + .should('contain.text', 'Showing 2 of 2 measured points') + .and('contain.text', '1/1 validated') + .and('contain.text', '1/1 historical'); + }); + it('shows spec decoding only on hover while retaining the offload halo', () => { const data = [ createMockInferenceData({ diff --git a/packages/app/cypress/component/quick-filters-dialog.cy.tsx b/packages/app/cypress/component/quick-filters-dialog.cy.tsx index d1f42b81d..742dcc90f 100644 --- a/packages/app/cypress/component/quick-filters-dialog.cy.tsx +++ b/packages/app/cypress/component/quick-filters-dialog.cy.tsx @@ -22,6 +22,21 @@ describe('QuickFiltersDialog', () => { cy.get('[data-testid="quick-filter-framework-vllm"]').should('exist'); cy.get('[data-testid="quick-filter-deployment-disagg"]').should('exist'); cy.get('[data-testid="quick-filter-spec-mtp"]').should('exist'); + cy.get('[data-testid="quick-filter-power-certified"]').should('contain.text', 'Validated'); + cy.get('[data-testid="quick-filter-power-legacy"]').should('contain.text', 'Historical'); + }); + + it('explains validated and historical power measurements in plain language', () => { + mountWithProviders(, { + inference: { availableQuickFilters }, + }); + + cy.get('[data-testid="measured-power-help"]').click(); + cy.contains('Validated measurement').should('be.visible'); + cy.contains('passed checks for benchmark-window coverage').should('be.visible'); + cy.contains('Historical measurement').should('be.visible'); + cy.contains('This does not mean the measurement is wrong.').should('be.visible'); + cy.contains('Both are shown by default.').should('be.visible'); }); it('removes speculative decoding from agentic charts and toggles supported filters', () => { diff --git a/packages/app/cypress/e2e/certified-power-filter.cy.ts b/packages/app/cypress/e2e/certified-power-filter.cy.ts index c2f35bfad..da77736f1 100644 --- a/packages/app/cypress/e2e/certified-power-filter.cy.ts +++ b/packages/app/cypress/e2e/certified-power-filter.cy.ts @@ -1,9 +1,9 @@ -// Certified-vs-legacy measured power (PLAN-02 / gap G2): on the Measured +// 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 (Certified/Legacy pills, `i_power` share-link param). Fixture rows -// are intercepted so one config is certified (power_valid=1) and one is -// legacy (no verdict), keeping every assertion deterministic regardless of +// 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. const POWER_MODEL = 'dsv4'; @@ -98,7 +98,7 @@ function visitCertifiedPowerChart(extraParams = '') { cy.get('[data-testid="chart-figure"]').should('have.length.at.least', 1); } -describe('Certified vs legacy measured power', () => { +describe('Validated vs historical measured power', () => { it('rings legacy points on a measured axis and filters them via Quick Filters', () => { visitCertifiedPowerChart(); @@ -116,6 +116,12 @@ describe('Certified vs legacy measured power', () => { .click(); cy.get('[data-slot="select-content"]').should('not.exist'); + cy.get('[data-testid="measured-power-summary"]') + .should('contain.text', 'Showing 2 of 6 measured points') + .and('contain.text', '1/3 validated') + .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'); @@ -128,6 +134,11 @@ describe('Certified vs legacy measured power', () => { cy.get('[data-testid="quick-filters-dialog"]').should('be.visible'); cy.get('[data-testid="quick-filter-power-certified"]').should('be.enabled'); cy.get('[data-testid="quick-filter-power-legacy"]').should('be.enabled'); + cy.get('[data-testid="quick-filter-power-certified"]').should('contain.text', 'Validated'); + cy.get('[data-testid="quick-filter-power-legacy"]').should('contain.text', 'Historical'); + cy.get('[data-testid="measured-power-help"]').click(); + 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. diff --git a/packages/app/src/components/inference/axis-metric-explanations.ts b/packages/app/src/components/inference/axis-metric-explanations.ts index 65f14cdd2..e3a2189c8 100644 --- a/packages/app/src/components/inference/axis-metric-explanations.ts +++ b/packages/app/src/components/inference/axis-metric-explanations.ts @@ -209,17 +209,14 @@ function provisionedJoules(tokenType: TokenType): MetricExplanation { }; } -/** - * Certification-tier note appended to every Measured Energy explanation: - * pairs the dotted legacy-power point ring and the Quick Filters → Measured - * Power pills with the footer text that explains them. - */ +/** Validation-status note appended to every Measured Energy explanation. */ const MEASURED_TIER_NOTE_EN = - ' Points without a producer validation verdict are legacy measurements — marked with a ' + - 'dotted ring on the chart and filterable via Quick Filters → Measured Power.'; + ' Validated points passed the current PowerX telemetry checks. Historical points are real ' + + "older measurements but lack the information needed to confirm today's method; a dotted ring " + + 'marks them. Filter either status under Quick Filters → Measured Power.'; const MEASURED_TIER_NOTE_ZH = - '没有生产端验证结论的数据点属于旧版测量——在图表上以虚线圆环标记,' + - '并可通过快捷筛选中的“实测功耗”进行筛选。'; + '已验证数据点通过了当前 PowerX 遥测检查。历史数据点来自真实的旧版测量,但缺少按当前方法完成验证所需的信息;' + + '图表以虚线圆环标记这类数据点。可在快捷筛选的“实测功耗”中按测量状态筛选。'; type MeasuredPhase = 'run' | 'prefill' | 'decode'; diff --git a/packages/app/src/components/inference/ui/GPUGraph.tsx b/packages/app/src/components/inference/ui/GPUGraph.tsx index b520f4298..8f3abfb5c 100644 --- a/packages/app/src/components/inference/ui/GPUGraph.tsx +++ b/packages/app/src/components/inference/ui/GPUGraph.tsx @@ -67,6 +67,12 @@ import { } from '@/components/inference/utils/knownIssueAnnotations'; import { matchKnownConfigIssues, pointMatchesIssue } from '@/lib/known-issues'; import { renderOffloadHalo } from '@/components/inference/utils/offload-halo'; +import { renderLegacyPowerRing } from '@/components/inference/utils/legacy-power-marker'; +import { isMeasuredEnergyConfigKey } from '@/components/inference/metric-registry'; +import { + countPowerTiers, + MeasuredPowerSummary, +} from '@/components/inference/ui/MeasuredPowerSummary'; import { parallelismLabelBoxes, placeLineLabels, @@ -177,6 +183,7 @@ const GPUGraph = React.memo( } = useInferenceActions(); const locale = useLocale(); const legendT = GPU_STRINGS[locale]; + const isMeasuredEnergyAxis = isMeasuredEnergyConfigKey(selectedYAxisMetric); const ephemeralUrlState = useEphemeralUrlState(); const { resolvedTheme } = useTheme(); const chartRef = useRef(null); @@ -370,6 +377,16 @@ const GPUGraph = React.memo( return pts; }, [groupedData, activeDates, hideNonOptimal, optimalPointKeys]); + const powerTierCounts = useMemo( + () => ({ + total: countPowerTiers( + data.filter((point) => selectedPrecisions.includes(point.precision)), + ), + visible: countPowerTiers(filteredData), + }), + [data, filteredData, selectedPrecisions], + ); + // GPU comparison currently renders official DB-backed points only. Unofficial // overlays have no benchmark_results id or persisted trace, so they cannot // open the dedicated per-point charts route. @@ -737,7 +754,21 @@ const GPUGraph = React.memo( watermark={getChartWatermark()} testId="gpu-graph" grabCursor={true} - caption={caption} + caption={ + isMeasuredEnergyAxis ? ( + <> + {caption} + + + ) : ( + caption + ) + } xScale={{ type: 'linear', domain: xExtent, nice: true }} yScale={{ type: logScale ? 'log' : 'linear', domain: yDomain, nice: true }} xAxis={{ @@ -908,12 +939,18 @@ const GPUGraph = React.memo( .selectAll('.dot-group, .roofline-path') .style('transition', 'opacity 150ms ease'); - // The halo stays inside the point group, so normal zoom transforms - // carry it without a separate update pass. + // Decorations stay inside the point group, so normal zoom transforms + // carry them without a separate update pass. ctx.layout.zoomGroup .selectAll('.dot-group') .each(function (point) { renderOffloadHalo(d3.select(this), point, 'var(--foreground)'); + renderLegacyPowerRing( + d3.select(this), + point, + isMeasuredEnergyAxis, + 'var(--foreground)', + ); }); }} legendElement={ diff --git a/packages/app/src/components/inference/ui/LegacyPowerLegendKey.tsx b/packages/app/src/components/inference/ui/LegacyPowerLegendKey.tsx index 5f1826a68..6ce3ffd0b 100644 --- a/packages/app/src/components/inference/ui/LegacyPowerLegendKey.tsx +++ b/packages/app/src/components/inference/ui/LegacyPowerLegendKey.tsx @@ -8,8 +8,8 @@ export const LEGACY_POWER_RING_STROKE_WIDTH = 1.5; export const LEGACY_POWER_RING_DASHARRAY = '1 3'; const STRINGS = { - en: 'Legacy power data (no validation verdict)', - zh: '旧版功耗数据(无验证结论)', + en: 'Historical measurement (not validated under the current method)', + zh: '历史测量(尚未按当前方法验证)', } as const; /** diff --git a/packages/app/src/components/inference/ui/MeasuredPowerSummary.tsx b/packages/app/src/components/inference/ui/MeasuredPowerSummary.tsx new file mode 100644 index 000000000..dfbbf68c6 --- /dev/null +++ b/packages/app/src/components/inference/ui/MeasuredPowerSummary.tsx @@ -0,0 +1,117 @@ +'use client'; + +import type { InferenceData } from '@/components/inference/types'; +import { useLocale } from '@/lib/use-locale'; + +export interface PowerTierCounts { + certified: number; + legacy: number; +} + +export function countPowerTiers(points: readonly InferenceData[]): PowerTierCounts { + const counts: PowerTierCounts = { certified: 0, legacy: 0 }; + for (const point of points) { + if (point.power_tier === 'certified') counts.certified += 1; + else if (point.power_tier === 'legacy') counts.legacy += 1; + } + return counts; +} + +const STRINGS = { + en: { + validated: 'validated', + historical: 'historical', + single: (shown: number, total: number, tier: string) => + `Showing ${shown} of ${total} ${tier} measurements.`, + combined: ( + shown: number, + total: number, + validatedShown: number, + validatedTotal: number, + historicalShown: number, + historicalTotal: number, + ) => + `Showing ${shown} of ${total} measured points: ${validatedShown}/${validatedTotal} validated · ${historicalShown}/${historicalTotal} historical.`, + bestPerSku: 'Best per SKU', + optimalOnly: 'Optimal Only', + controlsEnabled: (controls: string) => + `${controls} ${controls.includes(' and ') ? 'are' : 'is'} enabled.`, + reducedBySelections: 'Chart selections are hiding some points.', + }, + zh: { + validated: '已验证', + historical: '历史', + single: (shown: number, total: number, tier: string) => + `当前显示 ${shown}/${total} 个${tier}测量数据点。`, + combined: ( + shown: number, + total: number, + validatedShown: number, + validatedTotal: number, + historicalShown: number, + historicalTotal: number, + ) => + `当前显示 ${shown}/${total} 个实测数据点:已验证 ${validatedShown}/${validatedTotal} · 历史 ${historicalShown}/${historicalTotal}。`, + bestPerSku: '每个 SKU 仅显示最佳配置', + optimalOnly: '仅最优', + controlsEnabled: (controls: string) => `已启用:${controls}。`, + reducedBySelections: '当前图表选项隐藏了部分数据点。', + }, +} as const; + +function joinControls(controls: string[], locale: 'en' | 'zh'): string { + if (controls.length < 2) return controls[0] ?? ''; + return locale === 'zh' ? controls.join('、') : controls.join(' and '); +} + +export function MeasuredPowerSummary({ + total, + visible, + bestPerSku, + optimalOnly, +}: { + total: PowerTierCounts; + visible: PowerTierCounts; + bestPerSku: boolean; + optimalOnly: boolean; +}) { + const locale = useLocale(); + const t = STRINGS[locale]; + const totalCount = total.certified + total.legacy; + const visibleCount = visible.certified + visible.legacy; + if (totalCount === 0) return null; + + const summary = + total.certified > 0 && total.legacy > 0 + ? t.combined( + visibleCount, + totalCount, + visible.certified, + total.certified, + visible.legacy, + total.legacy, + ) + : total.certified > 0 + ? t.single(visible.certified, total.certified, t.validated) + : t.single(visible.legacy, total.legacy, t.historical); + + const controls: string[] = []; + if (bestPerSku) controls.push(t.bestPerSku); + if (optimalOnly) controls.push(t.optimalOnly); + const reduction = + visibleCount >= totalCount + ? null + : controls.length > 0 + ? t.controlsEnabled(joinControls(controls, locale)) + : t.reducedBySelections; + + return ( +
+ {summary} + {reduction && {reduction}} +
+ ); +} diff --git a/packages/app/src/components/inference/ui/QuickFiltersDialog.tsx b/packages/app/src/components/inference/ui/QuickFiltersDialog.tsx index af577e296..ce2bf1a13 100644 --- a/packages/app/src/components/inference/ui/QuickFiltersDialog.tsx +++ b/packages/app/src/components/inference/ui/QuickFiltersDialog.tsx @@ -1,8 +1,9 @@ 'use client'; -import { ListFilter } from 'lucide-react'; +import { Info, ListFilter } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import type { DeploymentMode, SpecMode } from '@/components/inference/types'; import type { PowerTier } from '@/lib/power-tier'; import { FRAMEWORK_FAMILIES } from '@/components/inference/utils/quickFilters'; @@ -30,9 +31,9 @@ const STRINGS = { en: { title: 'Quick Filters', description: - 'Narrow the chart by chip vendor, serving framework, deployment mode, speculative decoding, and measured-power certification. Selecting none in a group shows all.', + 'Narrow the chart by chip vendor, serving framework, deployment mode, speculative decoding, and power-measurement status. Selecting none in a group shows all.', agenticDescription: - 'Narrow the chart by chip vendor, serving framework, deployment mode, and measured-power certification. Selecting none in a group shows all.', + 'Narrow the chart by chip vendor, serving framework, deployment mode, and power-measurement status. Selecting none in a group shows all.', selected: 'selected', bestPerSku: 'Best per SKU', bestPerSkuHint: 'Show only the best configuration for each chip', @@ -44,8 +45,17 @@ const STRINGS = { disaggregated: 'Disaggregated', specDecoding: 'Spec Decoding', power: 'Measured Power', - certified: 'Certified', - legacyTier: 'Legacy', + certified: 'Validated', + legacyTier: 'Historical', + powerHelpLabel: 'About validated and historical power measurements', + validatedTitle: 'Validated measurement', + validatedDescription: + 'GPU power was recorded from runner telemetry using the current PowerX method and passed checks for benchmark-window coverage, expected GPU count, sample quality, and the energy definition.', + historicalTitle: 'Historical measurement', + historicalDescription: + "Real GPU telemetry from an older run, but the record lacks information needed to confirm it meets today's method. This does not mean the measurement is wrong.", + powerDefault: + 'Both are shown by default. Measurements that failed validation are not displayed.', noData: 'No data for the current selection', clear: 'Clear filters', done: 'Done', @@ -53,9 +63,9 @@ const STRINGS = { zh: { title: '快捷筛选', description: - '按芯片厂商、推理框架、部署模式、投机解码和实测功耗认证筛选图表。某组不选则显示全部。', + '按芯片厂商、推理框架、部署模式、投机解码和功耗测量状态筛选图表。某组不选则显示全部。', agenticDescription: - '按芯片厂商、推理框架、部署模式和实测功耗认证筛选图表。某组不选则显示全部。', + '按芯片厂商、推理框架、部署模式和功耗测量状态筛选图表。某组不选则显示全部。', selected: '项已选', bestPerSku: '每个 SKU 仅显示最佳配置', bestPerSkuHint: '每款芯片只显示表现最佳的配置', @@ -67,8 +77,16 @@ const STRINGS = { disaggregated: '分离式', specDecoding: '投机解码', power: '实测功耗', - certified: '已认证', - legacyTier: '旧版', + certified: '已验证', + legacyTier: '历史测量', + powerHelpLabel: '了解已验证测量与历史测量', + validatedTitle: '已验证测量', + validatedDescription: + '采用当前 PowerX 方法记录运行节点的 GPU 遥测功耗,并通过了基准测试时段覆盖率、预期 GPU 数量、采样质量和能耗定义检查。', + historicalTitle: '历史测量', + historicalDescription: + '旧版运行产生的真实 GPU 遥测数据,但记录缺少按当前方法完成验证所需的信息。这并不表示测量结果有误。', + powerDefault: '默认同时显示两类测量;未通过验证的测量不会显示。', noData: '当前选择无可用数据', clear: '清除筛选', done: '完成', @@ -269,7 +287,34 @@ export function QuickFiltersDialog({ key={group.key} className="grid gap-2 px-3 py-3 sm:grid-cols-[7rem_1fr] sm:items-start" > -

{group.label}

+
+

{group.label}

+ {group.key === 'power' && ( + + + + + +
+

{t.validatedTitle}

+

{t.validatedDescription}

+
+
+

{t.historicalTitle}

+

{t.historicalDescription}

+
+

{t.powerDefault}

+
+
+ )} +
{group.options.map((option) => { const active = group.selected.includes(option.value); diff --git a/packages/app/src/components/inference/ui/ScatterGraph.overlay.test.tsx b/packages/app/src/components/inference/ui/ScatterGraph.overlay.test.tsx index 499d79bc3..f71be38e9 100644 --- a/packages/app/src/components/inference/ui/ScatterGraph.overlay.test.tsx +++ b/packages/app/src/components/inference/ui/ScatterGraph.overlay.test.tsx @@ -18,6 +18,38 @@ import { } from './ScatterGraph.test-harness'; describe('ScatterGraph unofficial overlays', () => { + it('includes unofficial measured points in the validated/historical coverage summary', () => { + const runUrl = 'https://github.com/o/r/actions/runs/123'; + const overlayPoints = [ + { ...point('h100', 'fp8', 30, 300, 2), power_tier: 'certified', run_url: runUrl }, + { ...point('h100', 'fp8', 35, 350, 4), power_tier: 'legacy', run_url: runUrl }, + ] as InferenceData[]; + inferenceState.current = { + ...baseInferenceState(), + selectedYAxisMetric: 'y_measuredJPerOutputToken', + }; + overlayState.current = { + ...baseOverlayState(), + isUnofficialRun: true, + activeOverlayHwTypes: new Set(['h100']), + allOverlayHwTypes: new Set(['h100']), + runIndexByUrl: { [runUrl]: 0 }, + unofficialRunInfos: [{ id: '123', branch: 'test-branch', url: runUrl }], + }; + + const { container, unmount } = mountChart({ + overlayData: { + data: overlayPoints, + hardwareConfig: HARDWARE_CONFIG, + } as unknown as Parameters[0]['overlayData'], + }); + + expect( + container.querySelector('[data-testid="measured-power-summary"]')?.textContent, + ).toContain('Showing 2 of 2 measured points: 1/1 validated · 1/1 historical.'); + unmount(); + }); + it('keeps unofficial-run overlay markers rendered through official toggles', () => { const overlayPoints = [point('h100', 'fp8', 30, 300, 2), point('h100', 'fp8', 35, 350, 4)].map( (p) => ({ ...p, run_url: 'https://github.com/o/r/actions/runs/123' }), diff --git a/packages/app/src/components/inference/ui/ScatterGraph.tsx b/packages/app/src/components/inference/ui/ScatterGraph.tsx index 126638693..867b42ca5 100644 --- a/packages/app/src/components/inference/ui/ScatterGraph.tsx +++ b/packages/app/src/components/inference/ui/ScatterGraph.tsx @@ -122,6 +122,10 @@ import { import LegendPointsDialog from '@/components/inference/ui/LegendPointsDialog'; import { renderOffloadHalo } from '@/components/inference/utils/offload-halo'; import { renderLegacyPowerRing } from '@/components/inference/utils/legacy-power-marker'; +import { + countPowerTiers, + MeasuredPowerSummary, +} from '@/components/inference/ui/MeasuredPowerSummary'; import { isMeasuredEnergyConfigKey } from '@/components/inference/metric-registry'; import { buildLegendPointsRows } from '@/components/inference/utils/legend-points-table'; import { resolveScatterXAxisScale } from '@/components/inference/utils/x-axis-scale'; @@ -1343,6 +1347,30 @@ const ScatterGraph = React.memo( [effectiveActiveHwTypes, selectedPrecisions, hideNonOptimal, optimalPointKeys], ); + const powerTierCounts = useMemo(() => { + const officialTotal = pointsData.filter((point) => + selectedPrecisions.includes(point.precision), + ); + const overlayTotal = processedOverlayData.filter((point) => + selectedPrecisions.includes(point.precision), + ); + const officialVisible = officialTotal.filter(isPointVisible); + const overlayVisible = overlayTotal.filter( + (point) => activeOverlayHwTypes.has(String(point.hwKey)) && isOverlayPointVisible(point), + ); + return { + total: countPowerTiers([...officialTotal, ...overlayTotal]), + visible: countPowerTiers([...officialVisible, ...overlayVisible]), + }; + }, [ + pointsData, + processedOverlayData, + selectedPrecisions, + isPointVisible, + activeOverlayHwTypes, + isOverlayPointVisible, + ]); + // --- Legend hover highlight --- const isRooflineVisible = useCallback( (el: SVGElement) => { @@ -3712,6 +3740,14 @@ const ScatterGraph = React.memo( /> } /> + {isMeasuredEnergyAxis && ( + + )} { expect(selected).toEqual(new Set(['b200_narrow'])); }); + it('does not let a singleton preview displace a complete curve', () => { + const selected = bestSeriesPerSku( + [ + point('B200-8', 'b200_complete', 10, 100), + point('B200-8', 'b200_complete', 20, 80), + point('B200-8', 'b200_complete', 30, 60), + point('B200-8', 'b200_complete', 40, 40), + point('B200-8', 'b200_singleton', 200, 1_000), + ], + 'upper_left', + ); + + expect(selected).toEqual(new Set(['b200_complete'])); + }); + + it('keeps a two-point preview from being hidden by a singleton', () => { + const selected = bestSeriesPerSku( + [ + point('B200-8', 'b200_two_points', 10, 100), + point('B200-8', 'b200_two_points', 20, 80), + point('B200-8', 'b200_singleton', 200, 1_000), + ], + 'upper_left', + ); + + expect(selected).toEqual(new Set(['b200_two_points'])); + }); + it('inverts the score for lower-is-better metrics', () => { const selected = bestSeriesPerSku( [ diff --git a/packages/app/src/components/inference/utils/best-series-per-sku.ts b/packages/app/src/components/inference/utils/best-series-per-sku.ts index 590a232af..6799b9631 100644 --- a/packages/app/src/components/inference/utils/best-series-per-sku.ts +++ b/packages/app/src/components/inference/utils/best-series-per-sku.ts @@ -7,6 +7,9 @@ type Direction = 'upper_right' | 'upper_left' | 'lower_left' | 'lower_right'; // Named separately so the sampling density is obvious in test failures and can // be changed without leaving a magic number in the scoring loop. const SAMPLE_COUNT = 9; +// A singleton can be a useful preview, but it should not displace a measured +// curve merely because its lone point scores well. +const MIN_CURVE_FRONTIER_POINTS = 2; /** The physical SKU portion shared by framework/speculative-decoding variants. */ export function baseSku(point: Pick): string { @@ -71,8 +74,25 @@ export function bestSeriesPerSku(points: InferenceData[], direction: Direction): continue; } - const commonMin = Math.max(...usable.map((candidate) => candidate.minX)); - const commonMax = Math.min(...usable.map((candidate) => candidate.maxX)); + const comparable = usable.filter( + (candidate) => candidate.points.length >= MIN_CURVE_FRONTIER_POINTS, + ); + // When every candidate is a singleton, retain all candidates for the + // existing score + deterministic key tie-break. + const scoringPool = + comparable.length > 0 + ? comparable + : usable.filter( + (candidate) => + candidate.points.length === Math.max(...usable.map((item) => item.points.length)), + ); + if (scoringPool.length === 1) { + selected.add(scoringPool[0].key); + continue; + } + + const commonMin = Math.max(...scoringPool.map((candidate) => candidate.minX)); + const commonMax = Math.min(...scoringPool.map((candidate) => candidate.maxX)); const hasCommonDomain = commonMin <= commonMax; const score = (candidate: ScoredSeries): number => { @@ -94,7 +114,7 @@ export function bestSeriesPerSku(points: InferenceData[], direction: Direction): return higherYIsBetter ? mean : -mean; }; - const winner = usable + const winner = scoringPool .map((candidate) => ({ candidate, score: score(candidate) })) .toSorted((a, b) => b.score - a.score || a.candidate.key.localeCompare(b.candidate.key))[0]; selected.add(winner.candidate.key); 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 ee403c957..1f33e02b1 100644 --- a/packages/app/src/components/inference/utils/tooltip-utils.test.ts +++ b/packages/app/src/components/inference/utils/tooltip-utils.test.ts @@ -792,7 +792,9 @@ describe('power tier tooltip line', () => { data: pt({ power_tier: 'legacy' }), }), ); - expect(html).toContain('Power Data: Legacy (no validation verdict)'); + expect(html).toContain( + 'Power Measurement: Historical (not validated under the current method)', + ); }); it('states the certified tier on a measured axis', () => { @@ -802,7 +804,7 @@ describe('power tier tooltip line', () => { data: pt({ power_tier: 'certified' }), }), ); - expect(html).toContain('Power Data: Certified (validated measurement)'); + expect(html).toContain('Power Measurement: Validated (current PowerX method)'); }); it('omits the tier line on non-measured axes', () => { @@ -812,14 +814,14 @@ describe('power tier tooltip line', () => { data: pt({ power_tier: 'legacy' }), }), ); - expect(html).not.toContain('Power Data'); + expect(html).not.toContain('Power Measurement'); }); it('omits the tier line when the point carries no tier', () => { const html = generateTooltipContent( tooltipConfig({ selectedYAxisMetric: 'y_measuredAvgPower', data: pt() }), ); - expect(html).not.toContain('Power Data'); + expect(html).not.toContain('Power Measurement'); }); it('renders the ZH strings under the zh locale', () => { @@ -837,8 +839,8 @@ describe('power tier tooltip line', () => { locale: 'zh', }), ); - expect(legacy).toContain('功耗数据: 旧版(无验证结论)'); - expect(certified).toContain('功耗数据: 已认证(通过验证的测量)'); + expect(legacy).toContain('功耗测量: 历史测量(尚未按当前方法验证)'); + expect(certified).toContain('功耗测量: 已验证(采用当前 PowerX 方法)'); }); it('reaches overlay and GPU-graph tooltips through the same gate', () => { @@ -867,8 +869,12 @@ describe('power tier tooltip line', () => { }), ); - expect(overlay).toContain('Power Data: Legacy (no validation verdict)'); - expect(gpuGraph).toContain('Power Data: Legacy (no validation verdict)'); - expect(gpuGraphOffAxis).not.toContain('Power Data'); + expect(overlay).toContain( + 'Power Measurement: Historical (not validated under the current method)', + ); + expect(gpuGraph).toContain( + 'Power Measurement: Historical (not validated under the current method)', + ); + expect(gpuGraphOffAxis).not.toContain('Power Measurement'); }); }); diff --git a/packages/app/src/components/inference/utils/tooltipUtils.ts b/packages/app/src/components/inference/utils/tooltipUtils.ts index c016c74ab..568fa84ce 100644 --- a/packages/app/src/components/inference/utils/tooltipUtils.ts +++ b/packages/app/src/components/inference/utils/tooltipUtils.ts @@ -143,9 +143,9 @@ const TOOLTIP_STRINGS = { precision: 'Precision', inputTputPerChip: 'Input Token Throughput per Chip', outputTputPerChip: 'Output Token Throughput per Chip', - powerData: 'Power Data', - powerCertified: 'Certified (validated measurement)', - powerLegacy: 'Legacy (no validation verdict)', + powerData: 'Power Measurement', + powerCertified: 'Validated (current PowerX method)', + powerLegacy: 'Historical (not validated under the current method)', }, zh: { dismiss: '点击其他区域关闭', @@ -160,9 +160,9 @@ const TOOLTIP_STRINGS = { precision: '精度', inputTputPerChip: '每芯片输入 token 吞吐量', outputTputPerChip: '每芯片输出 token 吞吐量', - powerData: '功耗数据', - powerCertified: '已认证(通过验证的测量)', - powerLegacy: '旧版(无验证结论)', + powerData: '功耗测量', + powerCertified: '已验证(采用当前 PowerX 方法)', + powerLegacy: '历史测量(尚未按当前方法验证)', }, } as const; From e5e04d395aa85878317e4e0e84a6733769ccf889 Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Mon, 31 Aug 2026 13:41:48 -0700 Subject: [PATCH 16/20] test: remove synthetic AgentX power E2E MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中文:移除使用合成数据的 AgentX 功耗端到端测试 --- .../cypress/e2e/agentic-measured-power.cy.ts | 166 ------------------ packages/app/timings.json | 4 - 2 files changed, 170 deletions(-) delete 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 deleted file mode 100644 index 411173a03..000000000 --- a/packages/app/cypress/e2e/agentic-measured-power.cy.ts +++ /dev/null @@ -1,166 +0,0 @@ -// 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 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'); - }); - - 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/timings.json b/packages/app/timings.json index cd35ff32d..7e18e767a 100644 --- a/packages/app/timings.json +++ b/packages/app/timings.json @@ -8,10 +8,6 @@ "spec": "cypress/e2e/agentic-detail-back-nav.cy.ts", "duration": 7274 }, - { - "spec": "cypress/e2e/agentic-measured-power.cy.ts", - "duration": 1500 - }, { "spec": "cypress/e2e/agentic-point-coach-mark.cy.ts", "duration": 12729 From c760e8bb0e355dd66d0091d29d021e597734d915 Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Mon, 31 Aug 2026 13:43:28 -0700 Subject: [PATCH 17/20] docs: replace internal PowerX planning labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中文:将 PowerX 内部规划编号改为公开可读的技术说明 --- packages/app/cypress/e2e/certified-power-filter.cy.ts | 4 ++-- packages/app/src/components/inference/utils/tooltipUtils.ts | 2 +- packages/app/src/lib/benchmark-transform.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/app/cypress/e2e/certified-power-filter.cy.ts b/packages/app/cypress/e2e/certified-power-filter.cy.ts index da77736f1..7b6574ba5 100644 --- a/packages/app/cypress/e2e/certified-power-filter.cy.ts +++ b/packages/app/cypress/e2e/certified-power-filter.cy.ts @@ -1,5 +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 +// Validated-vs-historical measured power: 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 diff --git a/packages/app/src/components/inference/utils/tooltipUtils.ts b/packages/app/src/components/inference/utils/tooltipUtils.ts index d80658694..93d646113 100644 --- a/packages/app/src/components/inference/utils/tooltipUtils.ts +++ b/packages/app/src/components/inference/utils/tooltipUtils.ts @@ -227,7 +227,7 @@ 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. + // the _mb suffix), so divide by 1024 when displaying GiB. 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(' · ')}
`; diff --git a/packages/app/src/lib/benchmark-transform.test.ts b/packages/app/src/lib/benchmark-transform.test.ts index 8cc155fbd..74cd513fb 100644 --- a/packages/app/src/lib/benchmark-transform.test.ts +++ b/packages/app/src/lib/benchmark-transform.test.ts @@ -491,8 +491,8 @@ describe('rowToAggDataEntry', () => { }), ); expect(entry.power_tier).toBe('legacy'); - // The tier is informational only — telemetry still renders (G2 is - // distinguish, not exclude). + // The tier is informational only: historical telemetry remains visible + // so users can distinguish measurement status without losing older data. expect(entry.avg_power_w).toBe(560); }); From 0c5c96fb834c2be7098eaacf72d1eec580a50edd Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Mon, 31 Aug 2026 13:50:56 -0700 Subject: [PATCH 18/20] docs: simplify PowerX implementation comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中文:精简 PowerX 实现中的重复测试说明和易过时注释 --- .../cypress/e2e/certified-power-filter.cy.ts | 19 ++----------------- .../inference/utils/tooltip-utils.test.ts | 5 ----- .../inference/utils/tooltipUtils.ts | 7 ++----- 3 files changed, 4 insertions(+), 27 deletions(-) diff --git a/packages/app/cypress/e2e/certified-power-filter.cy.ts b/packages/app/cypress/e2e/certified-power-filter.cy.ts index 7b6574ba5..bf57c1f38 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: 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/src/components/inference/utils/tooltip-utils.test.ts b/packages/app/src/components/inference/utils/tooltip-utils.test.ts index 71052cf95..3a122f3fe 100644 --- a/packages/app/src/components/inference/utils/tooltip-utils.test.ts +++ b/packages/app/src/components/inference/utils/tooltip-utils.test.ts @@ -781,9 +781,6 @@ 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 }, @@ -830,11 +827,9 @@ describe('worker power drilldown', () => { 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('%'); diff --git a/packages/app/src/components/inference/utils/tooltipUtils.ts b/packages/app/src/components/inference/utils/tooltipUtils.ts index 93d646113..297d3f470 100644 --- a/packages/app/src/components/inference/utils/tooltipUtils.ts +++ b/packages/app/src/components/inference/utils/tooltipUtils.ts @@ -200,14 +200,11 @@ const WORKER_POWER_STRINGS = { }, } 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. + * Render worker telemetry only in pinned tooltips so hover tooltips stay lean. + * Missing or empty worker payloads produce no section. */ const generateWorkerPowerHTML = (d: InferenceData, isPinned: boolean, locale: Locale): string => { if (!isPinned || !Array.isArray(d.workers) || d.workers.length === 0) return ''; From 3c80df4e590cb2576fc27cd7d0bb8daafb5e3c18 Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Mon, 31 Aug 2026 13:52:26 -0700 Subject: [PATCH 19/20] docs: remove redundant PowerX test narration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中文:移除 PowerX 测试中的重复分段与断言说明 --- .../app/src/components/inference/utils/quickFilters.test.ts | 3 --- .../app/src/components/inference/utils/tooltip-utils.test.ts | 3 --- packages/app/src/lib/benchmark-transform.ts | 4 +--- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/app/src/components/inference/utils/quickFilters.test.ts b/packages/app/src/components/inference/utils/quickFilters.test.ts index 3c74d9b53..7a97fdc5a 100644 --- a/packages/app/src/components/inference/utils/quickFilters.test.ts +++ b/packages/app/src/components/inference/utils/quickFilters.test.ts @@ -98,7 +98,6 @@ describe('computeAvailableQuickFilters', () => { const certified = point({ power_tier: 'certified' }); expect(computeAvailableQuickFilters([legacy]).power).toEqual(['legacy']); expect(computeAvailableQuickFilters([certified]).power).toEqual(['certified']); - // Display order stays certified-first even when legacy points come first. expect(computeAvailableQuickFilters([legacy, certified]).power).toEqual([ 'certified', 'legacy', @@ -198,11 +197,9 @@ describe('matchesQuickFilters', () => { expect(matchesQuickFilters(certified, certifiedOnly)).toBe(true); expect(matchesQuickFilters(legacy, certifiedOnly)).toBe(false); expect(matchesQuickFilters(tierless, certifiedOnly)).toBe(false); - // Both tiers still exclude points with no measured telemetry at all. expect(matchesQuickFilters(certified, bothTiers)).toBe(true); expect(matchesQuickFilters(legacy, bothTiers)).toBe(true); expect(matchesQuickFilters(tierless, bothTiers)).toBe(false); - // No power constraint keeps tier-less points. expect(matchesQuickFilters(tierless, EMPTY_QUICK_FILTERS)).toBe(true); }); 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 3a122f3fe..9595958c5 100644 --- a/packages/app/src/components/inference/utils/tooltip-utils.test.ts +++ b/packages/app/src/components/inference/utils/tooltip-utils.test.ts @@ -907,9 +907,6 @@ describe('worker power drilldown', () => { }); }); -// =========================================================================== -// measured-power certification tier line (all three generators) -// =========================================================================== describe('power tier tooltip line', () => { it('states the tier for a legacy point on a measured axis', () => { const html = generateTooltipContent( diff --git a/packages/app/src/lib/benchmark-transform.ts b/packages/app/src/lib/benchmark-transform.ts index 150831be6..48ba55c1d 100644 --- a/packages/app/src/lib/benchmark-transform.ts +++ b/packages/app/src/lib/benchmark-transform.ts @@ -119,9 +119,7 @@ export function rowToAggDataEntry(row: BenchmarkRow): AggDataEntry { // failure that made versioning necessary in the first place. const hasWholeDeploymentEnergySemantics = !row.disagg || m.power_metric_schema_version === WHOLE_DEPLOYMENT_ENERGY_SCHEMA_VERSION; - // Gated measured power values, hoisted so the tier below can see exactly - // what the chart will render (behavior identical to the previous inline - // conditionals in the returned object). + // Tier derivation must see exactly the measured values the chart can render. const avgPowerW = measuredPowerValid ? m.avg_power_w : undefined; const prefillAvgPowerW = measuredPowerValid ? m.prefill_avg_power_w : undefined; const decodeAvgPowerW = measuredPowerValid ? m.decode_avg_power_w : undefined; From 62095602bf3859dab47b8e10cb56203a5b424743 Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Mon, 31 Aug 2026 15:22:38 -0700 Subject: [PATCH 20/20] fix: explain unavailable role-local energy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中文:当所选数据集未提供 Prefill/Decode 分角色能耗时,在普通图表和 GPU 对比图中显示明确的双语空状态,并补充组件回归测试。 --- .../app/cypress/component/gpu-graph.cy.tsx | 30 ++++++++++ .../cypress/component/scatter-graph.cy.tsx | 60 +++++++++++++++++++ .../inference/metric-registry.test.ts | 8 +++ .../components/inference/metric-registry.ts | 10 ++++ .../src/components/inference/ui/GPUGraph.tsx | 14 ++++- .../components/inference/ui/ScatterGraph.tsx | 14 ++++- 6 files changed, 132 insertions(+), 4 deletions(-) diff --git a/packages/app/cypress/component/gpu-graph.cy.tsx b/packages/app/cypress/component/gpu-graph.cy.tsx index 0f70e13d3..917f27217 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 372988647..ab3c3b08d 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/src/components/inference/metric-registry.test.ts b/packages/app/src/components/inference/metric-registry.test.ts index 9a8ef3614..7c784b85f 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, @@ -77,6 +78,13 @@ describe('metric registry', () => { 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); diff --git a/packages/app/src/components/inference/metric-registry.ts b/packages/app/src/components/inference/metric-registry.ts index 816ade061..43fa6507c 100644 --- a/packages/app/src/components/inference/metric-registry.ts +++ b/packages/app/src/components/inference/metric-registry.ts @@ -531,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/ui/GPUGraph.tsx b/packages/app/src/components/inference/ui/GPUGraph.tsx index 8f3abfb5c..4e2f48aba 100644 --- a/packages/app/src/components/inference/ui/GPUGraph.tsx +++ b/packages/app/src/components/inference/ui/GPUGraph.tsx @@ -68,7 +68,10 @@ import { import { matchKnownConfigIssues, pointMatchesIssue } from '@/lib/known-issues'; import { renderOffloadHalo } from '@/components/inference/utils/offload-halo'; import { renderLegacyPowerRing } from '@/components/inference/utils/legacy-power-marker'; -import { isMeasuredEnergyConfigKey } from '@/components/inference/metric-registry'; +import { + isMeasuredEnergyConfigKey, + isRoleLocalMeasuredEnergyConfigKey, +} from '@/components/inference/metric-registry'; import { countPowerTiers, MeasuredPowerSummary, @@ -114,6 +117,8 @@ const GPU_STRINGS = { quickFilters: (count: number) => (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}