From f92918e92db3bb1ce2cceefde4e883af2a5d8822 Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:54:41 +0000 Subject: [PATCH 01/13] feat(inference): add theoretical prefix and uncached input token rows to point overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For agentic points, derive the theoretical prefix tokens for each point as the sum of every prompt prefix the trace has already seen: the harness reports that sum as the infinite-cache theoretical_cache_hit_rate over served prompt tokens, so multiplying it back with total_prompt_tokens recovers the token sum. Add two new point-overlay rows: - Theoretical Prefix Tokens - Input Tokens w/o Prefix Caching (prompt total minus theoretical prefix) This is deliberately the theoretical prefix (what could be cached given the trace), not server-observed cache hits, so systems with better real caching are not penalized on the derived uncached-input view. 中文:为 agentic 数据点的 point overlay 新增两行指标:理论 prefix token 数 (按该点在 trace 中已出现的全部 prefix 之和计算,即无限 cache 理论命中率 乘以 prompt token 总数)以及无 prefix cache 的输入 token 数(prompt 总数 减去理论 prefix)。刻意采用理论 prefix 而非服务端实际命中,避免 cache 能力更强的系统在该派生指标上被低估。 --- .../inference/utils/tooltip-utils.test.ts | 94 +++++++++++++++++++ .../inference/utils/tooltipUtils.ts | 63 ++++++++++++- 2 files changed, 155 insertions(+), 2 deletions(-) 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 cd469a0c..47a982b2 100644 --- a/packages/app/src/components/inference/utils/tooltip-utils.test.ts +++ b/packages/app/src/components/inference/utils/tooltip-utils.test.ts @@ -6,6 +6,8 @@ import { generateTooltipContent, generateOverlayTooltipContent, generateGPUGraphTooltipContent, + theoreticalPrefixTokens, + uncachedInputTokens, type TooltipConfig, type OverlayTooltipConfig, } from '@/components/inference/utils/tooltipUtils'; @@ -332,6 +334,98 @@ describe('generateTooltipContent', () => { expect(html).toContain('FP8'); }); + it('shows theoretical prefix and uncached input token rows for agentic points', () => { + const html = generateTooltipContent( + tooltipConfig({ + data: pt({ + benchmark_type: 'agentic_traces', + total_prompt_tokens: 1_000_000, + theoretical_cache_hit_rate: 0.92, + }), + }), + ); + expect(html).toContain('Theoretical Prefix Tokens: 920,000'); + expect(html).toContain('Input Tokens w/o Prefix Caching: 80,000'); + }); + + it('omits the prefix token rows when the theoretical hit rate is missing', () => { + const html = generateTooltipContent( + tooltipConfig({ + data: pt({ + benchmark_type: 'agentic_traces', + total_prompt_tokens: 1_000_000, + theoretical_cache_hit_rate: undefined, + }), + }), + ); + expect(html).toContain('Prompt Tokens: 1,000,000'); + expect(html).not.toContain('Theoretical Prefix Tokens'); + expect(html).not.toContain('Input Tokens w/o Prefix Caching'); + }); + + it('never shows prefix token rows on fixed-sequence points', () => { + const html = generateTooltipContent( + tooltipConfig({ + data: pt({ + benchmark_type: 'single_turn', + total_prompt_tokens: 1_000_000, + theoretical_cache_hit_rate: 0.92, + }), + }), + ); + expect(html).not.toContain('Theoretical Prefix Tokens'); + expect(html).not.toContain('Input Tokens w/o Prefix Caching'); + }); + + it('uses Chinese labels for the prefix token rows on /zh surfaces', () => { + const html = generateTooltipContent( + tooltipConfig({ + locale: 'zh', + data: pt({ + benchmark_type: 'agentic_traces', + total_prompt_tokens: 500_000, + theoretical_cache_hit_rate: 0.9, + }), + }), + ); + expect(html).toContain('理论 prefix token 数: 450,000'); + expect(html).toContain('无 prefix cache 的输入 token 数: 50,000'); + }); +}); + +describe('theoreticalPrefixTokens / uncachedInputTokens', () => { + it('recovers the prefix token sum from the trace hit rate', () => { + const d = pt({ total_prompt_tokens: 1_000_000, theoretical_cache_hit_rate: 0.925 }); + expect(theoreticalPrefixTokens(d)).toBe(925_000); + expect(uncachedInputTokens(d)).toBe(75_000); + }); + + it('returns undefined when either input is missing', () => { + expect(theoreticalPrefixTokens(pt({ total_prompt_tokens: 100 }))).toBeUndefined(); + expect(theoreticalPrefixTokens(pt({ theoretical_cache_hit_rate: 0.5 }))).toBeUndefined(); + expect(uncachedInputTokens(pt({ total_prompt_tokens: 100 }))).toBeUndefined(); + }); + + it('rejects out-of-range or NaN hit rates', () => { + expect( + theoreticalPrefixTokens(pt({ total_prompt_tokens: 100, theoretical_cache_hit_rate: 1.2 })), + ).toBeUndefined(); + expect( + theoreticalPrefixTokens(pt({ total_prompt_tokens: 100, theoretical_cache_hit_rate: -0.1 })), + ).toBeUndefined(); + expect( + theoreticalPrefixTokens(pt({ total_prompt_tokens: 100, theoretical_cache_hit_rate: NaN })), + ).toBeUndefined(); + }); + + it('clamps rounding so uncached input never goes negative', () => { + const d = pt({ total_prompt_tokens: 3, theoretical_cache_hit_rate: 1 }); + expect(theoreticalPrefixTokens(d)).toBe(3); + expect(uncachedInputTokens(d)).toBe(0); + }); +}); + +describe('generateTooltipContent cache metadata', () => { it('shows offload type, backend, and version instead of the binary offload mode', () => { const html = generateTooltipContent( tooltipConfig({ diff --git a/packages/app/src/components/inference/utils/tooltipUtils.ts b/packages/app/src/components/inference/utils/tooltipUtils.ts index ee740c4f..6e76cd6c 100644 --- a/packages/app/src/components/inference/utils/tooltipUtils.ts +++ b/packages/app/src/components/inference/utils/tooltipUtils.ts @@ -220,10 +220,59 @@ const generateCacheMetadataHTML = (d: InferenceData, locale: Locale): string => * separately because fixed-sequence rows can carry it too. */ const AGENTIC_STRINGS = { - en: { speculativeDecoding: 'Speculative Decoding', off: 'Off' }, - zh: { speculativeDecoding: '投机解码', off: '关闭' }, + en: { + speculativeDecoding: 'Speculative Decoding', + off: 'Off', + theoreticalPrefixTokens: 'Theoretical Prefix Tokens', + uncachedInputTokens: 'Input Tokens w/o Prefix Caching', + }, + zh: { + speculativeDecoding: '投机解码', + off: '关闭', + theoreticalPrefixTokens: '理论 prefix token 数', + uncachedInputTokens: '无 prefix cache 的输入 token 数', + }, } as const; +/** + * Theoretical prefix tokens for a point: the sum of every prompt prefix the + * workload has already seen, under an infinite cache. The harness reports that + * sum as a rate over served prompt tokens (`theoretical_cache_hit_rate`, the + * infinite-cache hit rate computed from the trace), so multiplying it back + * with `total_prompt_tokens` recovers the token sum. This is deliberately the + * THEORETICAL prefix — what could have been cached given the trace — not the + * server-observed cache hits, so systems with better real caching are not + * penalized on derived uncached-input metrics. + */ +export const theoreticalPrefixTokens = (d: InferenceData): number | undefined => { + const rate = d.theoretical_cache_hit_rate; + const prompt = d.total_prompt_tokens; + if ( + prompt === undefined || + prompt === null || + rate === undefined || + rate === null || + Number.isNaN(rate) || + rate < 0 || + rate > 1 + ) { + return undefined; + } + return Math.round(prompt * rate); +}; + +/** + * Input tokens without prefix caching: served prompt tokens minus the + * theoretical prefix ({@link theoreticalPrefixTokens}). Approximates the + * prompt tokens that must be prefilled even by a system with an infinite + * prefix cache. + */ +export const uncachedInputTokens = (d: InferenceData): number | undefined => { + const prefix = theoreticalPrefixTokens(d); + if (prefix === undefined || d.total_prompt_tokens === undefined) return undefined; + return Math.max(0, d.total_prompt_tokens - prefix); +}; + const generateAgenticHTML = (d: InferenceData, locale: Locale): string => { if (d.benchmark_type !== 'agentic_traces') return ''; @@ -255,6 +304,16 @@ const generateAgenticHTML = (d: InferenceData, locale: Locale): string => { if (d.total_prompt_tokens !== undefined) { parts.push(tooltipLine('Prompt Tokens', formatNumber(d.total_prompt_tokens))); } + + const theoreticalPrefix = theoreticalPrefixTokens(d); + if (theoreticalPrefix !== undefined) { + parts.push(tooltipLine(t.theoreticalPrefixTokens, formatNumber(theoreticalPrefix))); + } + const uncachedInput = uncachedInputTokens(d); + if (uncachedInput !== undefined) { + parts.push(tooltipLine(t.uncachedInputTokens, formatNumber(uncachedInput))); + } + if (d.total_generation_tokens !== undefined) { parts.push(tooltipLine('Generated Tokens', formatNumber(d.total_generation_tokens))); } From 1276c731ffaf72188bb7c36adb45879c3bbbbf3f Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:05:23 +0000 Subject: [PATCH 02/13] feat(inference): add theoretical uncached throughput y-axis metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two Throughput y-axis options derived from the trace-level theoretical (infinite-cache) prefix cache hit rate: - Uncached Token Throughput per Chip: total throughput minus the theoretical prefix share of input throughput - Uncached Input Token Throughput per Chip: input throughput x (1 - rate) Both deliberately use the theoretical rate rather than server-observed cache hits so systems with good cache storage aren't penalized. Only agentic trace points carry the rate; fixed-sequence points omit the fields and drop off the chart for these metrics. 中文:新增两个基于 trace 级理论(无限 cache)prefix cache 命中率的吞吐量 Y 轴指标: - 每芯片无 prefix cache token 吞吐量:总吞吐量减去输入吞吐量中的理论 prefix 部分 - 每芯片无 prefix cache 输入 token 吞吐量:输入吞吐量 x(1 - 命中率) 两者刻意采用理论命中率而非服务端实际 cache 命中,避免惩罚 cache 存储能力强的系统。 只有 agentic trace 点携带该命中率;固定序列点省略这些字段,在这两个指标下不显示。 --- .../components/inference/metric-registry.ts | 25 +++++++++++ .../app/src/components/inference/types.ts | 4 ++ packages/app/src/lib/chart-utils.test.ts | 43 +++++++++++++++++++ packages/app/src/lib/chart-utils.ts | 19 ++++++++ 4 files changed, 91 insertions(+) diff --git a/packages/app/src/components/inference/metric-registry.ts b/packages/app/src/components/inference/metric-registry.ts index 046eb0d3..4a859e37 100644 --- a/packages/app/src/components/inference/metric-registry.ts +++ b/packages/app/src/components/inference/metric-registry.ts @@ -43,6 +43,29 @@ export const METRIC_REGISTRY = { titleZh: '每芯片输出 token 吞吐量', polarity: 'higher', }, + // Theoretical uncached throughput pair: total / input throughput minus the + // infinite-cache theoretical prefix share (trace-derived, agentic only). + // Deliberately based on the theoretical rate rather than server-observed + // cache hits so systems with good cache storage aren't penalized. + uncachedTputPerGpu: { + field: 'uncachedTputPerGpu.y', + label: 'Uncached Token Throughput per Chip (tok/s/chip)', + labelZh: '每芯片无 prefix cache token 吞吐量(tok/s/chip)', + title: 'Uncached Token Throughput per Chip', + titleZh: '每芯片无 prefix cache token 吞吐量', + polarity: 'higher', + }, + uncachedInputTputPerGpu: { + field: 'uncachedInputTputPerGpu.y', + label: 'Uncached Input Token Throughput per Chip (tok/s/chip)', + labelZh: '每芯片无 prefix cache 输入 token 吞吐量(tok/s/chip)', + title: 'Uncached Input Token Throughput per Chip', + titleZh: '每芯片无 prefix cache 输入 token 吞吐量', + polarity: 'higher', + x: 'p90_ttft', + xLabel: 'P90 Time To First Token (s)', + heading: 'vs. P90 Time To First Token', + }, tpPerMw: { field: 'tpPerMw.y', label: 'Token Throughput per All in Utility MW (tok/s/MW)', @@ -473,6 +496,8 @@ export const METRIC_CONTROL_GROUPS: readonly MetricControlGroup[] = [ 'y_tpPerGpu', 'y_inputTputPerGpu', 'y_outputTputPerGpu', + 'y_uncachedTputPerGpu', + 'y_uncachedInputTputPerGpu', 'y_tpPerMw', 'y_inputTputPerMw', 'y_outputTputPerMw', diff --git a/packages/app/src/components/inference/types.ts b/packages/app/src/components/inference/types.ts index 124483cc..88ed17b8 100644 --- a/packages/app/src/components/inference/types.ts +++ b/packages/app/src/components/inference/types.ts @@ -274,6 +274,10 @@ export interface InferenceData extends Partial { expect(point.inputTputPerGpu).toEqual({ y: 300, roof: false }); }); + it('computes theoretical uncached throughput fields when the rate is valid', () => { + const e = entry({ + tput_per_gpu: 900, + output_tput_per_gpu: 600, + input_tput_per_gpu: 300, + theoretical_cache_hit_rate: 0.8, + }); + const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100'); + // uncached input = 300 x (1 - 0.8); uncached total = 900 - 300 x 0.8 + expect(point.uncachedInputTputPerGpu?.y).toBeCloseTo(60); + expect(point.uncachedTputPerGpu?.y).toBeCloseTo(660); + }); + + it('omits theoretical uncached throughput fields when the rate is missing', () => { + const e = entry({ tput_per_gpu: 900, input_tput_per_gpu: 300 }); + const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100'); + expect(point.uncachedInputTputPerGpu).toBeUndefined(); + expect(point.uncachedTputPerGpu).toBeUndefined(); + }); + + it('omits theoretical uncached throughput fields when the rate is out of range', () => { + const e = entry({ + tput_per_gpu: 900, + input_tput_per_gpu: 300, + theoretical_cache_hit_rate: 1.2, + }); + const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100'); + expect(point.uncachedInputTputPerGpu).toBeUndefined(); + expect(point.uncachedTputPerGpu).toBeUndefined(); + }); + + it('reports zero uncached input and output-only total at a full theoretical hit rate', () => { + const e = entry({ + tput_per_gpu: 900, + output_tput_per_gpu: 600, + input_tput_per_gpu: 300, + theoretical_cache_hit_rate: 1, + }); + const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100'); + expect(point.uncachedInputTputPerGpu?.y).toBe(0); + expect(point.uncachedTputPerGpu?.y).toBeCloseTo(600); + }); + it('computes tpPerMw from throughput and hardware power', () => { // tpPerMw = (tput_per_gpu * 1000) / power = (1000 * 1000) / 700 const e = entry({ tput_per_gpu: 1000 }); diff --git a/packages/app/src/lib/chart-utils.ts b/packages/app/src/lib/chart-utils.ts index 5f49e6b9..fef68ed4 100644 --- a/packages/app/src/lib/chart-utils.ts +++ b/packages/app/src/lib/chart-utils.ts @@ -327,6 +327,25 @@ export function buildDerivedChartFields( if (wants('inputTputPerGpu') && inputTputPerGpu) { fields.inputTputPerGpu = chartMetric(inputTputPerGpu); } + // Theoretical uncached throughput: subtract the infinite-cache theoretical + // prefix share of input throughput (rate derived from the trace by the + // harness). Uses the theoretical rate rather than server-observed cache + // hits so systems with good cache storage aren't penalized — only agentic + // trace points carry the rate, so fixed-sequence points omit these fields. + const theoreticalHitRate = entry.theoretical_cache_hit_rate; + const hasTheoreticalHitRate = + typeof theoreticalHitRate === 'number' && + Number.isFinite(theoreticalHitRate) && + theoreticalHitRate >= 0 && + theoreticalHitRate <= 1; + if (wants('uncachedTputPerGpu') && hasTheoreticalHitRate && tputPerGpu) { + fields.uncachedTputPerGpu = chartMetric( + Math.max(0, tputPerGpu - inputTputPerGpu * theoreticalHitRate), + ); + } + if (wants('uncachedInputTputPerGpu') && hasTheoreticalHitRate && inputTputPerGpu) { + fields.uncachedInputTputPerGpu = chartMetric(inputTputPerGpu * (1 - theoreticalHitRate)); + } if (wants('tpPerMw')) fields.tpPerMw = chartMetric((tputPerGpu * 1000) / hardwarePower); if (wants('inputTputPerMw') && inputTputPerGpu) { fields.inputTputPerMw = chartMetric( From 5f37ca41d82ebd8723443799634f3cbcedb41f9a Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:11:45 +0000 Subject: [PATCH 03/13] test(e2e): expect three y-axis options for input-token-throughput search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new Uncached Input Token Throughput per Chip metric also matches the "input token throughput" search, so the filtered option count is now three. 中文:新增的每芯片无 prefix cache 输入 token 吞吐量指标同样匹配 "input token throughput" 搜索,过滤后的选项数量变为三个。 --- packages/app/cypress/e2e/speed-overlay.cy.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/app/cypress/e2e/speed-overlay.cy.ts b/packages/app/cypress/e2e/speed-overlay.cy.ts index 5102a6a1..e0c7299b 100644 --- a/packages/app/cypress/e2e/speed-overlay.cy.ts +++ b/packages/app/cypress/e2e/speed-overlay.cy.ts @@ -188,7 +188,9 @@ describe('Y-Axis Metric Search', () => { cy.get('[data-slot="select-content"]') .find('input[placeholder="Search..."]') .type('input token throughput'); - cy.get('[data-slot="select-content"]').find('[role="option"]').should('have.length', 2); + // Matches Input Token Throughput per Chip, Uncached Input Token Throughput + // per Chip, and Input Token Throughput per All in Utility MW. + cy.get('[data-slot="select-content"]').find('[role="option"]').should('have.length', 3); cy.get('[data-slot="select-content"]') .find('[role="option"]') .first() From 92488c5aab84d6e70233c273bef0115afa127125 Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:18:34 +0000 Subject: [PATCH 04/13] feat(inference): rename uncached throughput metrics to prompt suffix naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per thread discussion, name the metric after what it measures: the prompt suffix (theoretically uncachable input) plus output tokens. Like MFU vs HFU, actual (uncached + output) throughput is always at least the theoretical (uncachable suffix + output) throughput. - Uncached Token Throughput per Chip -> Prompt Suffix + Output Token Throughput per Chip (key y_promptSuffixOutputTputPerGpu) - Uncached Input Token Throughput per Chip -> Prompt Suffix Token Throughput per Chip (key y_promptSuffixTputPerGpu) 中文:按讨论将指标以其实际度量对象命名:prompt suffix(理论上无法缓存的输入) 加输出 token。类似 MFU 与 HFU 的关系,实际(未缓存 + 输出)吞吐量总是不低于 理论(不可缓存 suffix + 输出)吞吐量。 - 每芯片无 prefix cache token 吞吐量 -> 每芯片 prompt suffix + 输出 token 吞吐量 - 每芯片无 prefix cache 输入 token 吞吐量 -> 每芯片 prompt suffix token 吞吐量 --- packages/app/cypress/e2e/speed-overlay.cy.ts | 4 +-- .../components/inference/metric-registry.ts | 34 ++++++++++--------- .../app/src/components/inference/types.ts | 6 ++-- packages/app/src/lib/chart-utils.test.ts | 26 +++++++------- packages/app/src/lib/chart-utils.ts | 18 +++++----- 5 files changed, 44 insertions(+), 44 deletions(-) diff --git a/packages/app/cypress/e2e/speed-overlay.cy.ts b/packages/app/cypress/e2e/speed-overlay.cy.ts index e0c7299b..5102a6a1 100644 --- a/packages/app/cypress/e2e/speed-overlay.cy.ts +++ b/packages/app/cypress/e2e/speed-overlay.cy.ts @@ -188,9 +188,7 @@ describe('Y-Axis Metric Search', () => { cy.get('[data-slot="select-content"]') .find('input[placeholder="Search..."]') .type('input token throughput'); - // Matches Input Token Throughput per Chip, Uncached Input Token Throughput - // per Chip, and Input Token Throughput per All in Utility MW. - cy.get('[data-slot="select-content"]').find('[role="option"]').should('have.length', 3); + cy.get('[data-slot="select-content"]').find('[role="option"]').should('have.length', 2); cy.get('[data-slot="select-content"]') .find('[role="option"]') .first() diff --git a/packages/app/src/components/inference/metric-registry.ts b/packages/app/src/components/inference/metric-registry.ts index 4a859e37..706130f7 100644 --- a/packages/app/src/components/inference/metric-registry.ts +++ b/packages/app/src/components/inference/metric-registry.ts @@ -43,24 +43,26 @@ export const METRIC_REGISTRY = { titleZh: '每芯片输出 token 吞吐量', polarity: 'higher', }, - // Theoretical uncached throughput pair: total / input throughput minus the + // Prompt-suffix throughput pair: total / input throughput minus the // infinite-cache theoretical prefix share (trace-derived, agentic only). // Deliberately based on the theoretical rate rather than server-observed - // cache hits so systems with good cache storage aren't penalized. - uncachedTputPerGpu: { - field: 'uncachedTputPerGpu.y', - label: 'Uncached Token Throughput per Chip (tok/s/chip)', - labelZh: '每芯片无 prefix cache token 吞吐量(tok/s/chip)', - title: 'Uncached Token Throughput per Chip', - titleZh: '每芯片无 prefix cache token 吞吐量', + // cache hits so systems with good cache storage aren't penalized — like + // MFU vs HFU, actual (uncached + output) throughput is always at least the + // theoretical (uncachable suffix + output) throughput. + promptSuffixOutputTputPerGpu: { + field: 'promptSuffixOutputTputPerGpu.y', + label: 'Prompt Suffix + Output Token Throughput per Chip (tok/s/chip)', + labelZh: '每芯片 prompt suffix + 输出 token 吞吐量(tok/s/chip)', + title: 'Prompt Suffix + Output Token Throughput per Chip', + titleZh: '每芯片 prompt suffix + 输出 token 吞吐量', polarity: 'higher', }, - uncachedInputTputPerGpu: { - field: 'uncachedInputTputPerGpu.y', - label: 'Uncached Input Token Throughput per Chip (tok/s/chip)', - labelZh: '每芯片无 prefix cache 输入 token 吞吐量(tok/s/chip)', - title: 'Uncached Input Token Throughput per Chip', - titleZh: '每芯片无 prefix cache 输入 token 吞吐量', + promptSuffixTputPerGpu: { + field: 'promptSuffixTputPerGpu.y', + label: 'Prompt Suffix Token Throughput per Chip (tok/s/chip)', + labelZh: '每芯片 prompt suffix token 吞吐量(tok/s/chip)', + title: 'Prompt Suffix Token Throughput per Chip', + titleZh: '每芯片 prompt suffix token 吞吐量', polarity: 'higher', x: 'p90_ttft', xLabel: 'P90 Time To First Token (s)', @@ -496,8 +498,8 @@ export const METRIC_CONTROL_GROUPS: readonly MetricControlGroup[] = [ 'y_tpPerGpu', 'y_inputTputPerGpu', 'y_outputTputPerGpu', - 'y_uncachedTputPerGpu', - 'y_uncachedInputTputPerGpu', + 'y_promptSuffixOutputTputPerGpu', + 'y_promptSuffixTputPerGpu', 'y_tpPerMw', 'y_inputTputPerMw', 'y_outputTputPerMw', diff --git a/packages/app/src/components/inference/types.ts b/packages/app/src/components/inference/types.ts index 88ed17b8..43c68fe9 100644 --- a/packages/app/src/components/inference/types.ts +++ b/packages/app/src/components/inference/types.ts @@ -274,10 +274,10 @@ export interface InferenceData extends Partial { expect(point.inputTputPerGpu).toEqual({ y: 300, roof: false }); }); - it('computes theoretical uncached throughput fields when the rate is valid', () => { + it('computes prompt-suffix throughput fields when the rate is valid', () => { const e = entry({ tput_per_gpu: 900, output_tput_per_gpu: 600, @@ -702,30 +702,30 @@ describe('createChartDataPoint', () => { theoretical_cache_hit_rate: 0.8, }); const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100'); - // uncached input = 300 x (1 - 0.8); uncached total = 900 - 300 x 0.8 - expect(point.uncachedInputTputPerGpu?.y).toBeCloseTo(60); - expect(point.uncachedTputPerGpu?.y).toBeCloseTo(660); + // prompt suffix = 300 x (1 - 0.8); suffix + output = 900 - 300 x 0.8 + expect(point.promptSuffixTputPerGpu?.y).toBeCloseTo(60); + expect(point.promptSuffixOutputTputPerGpu?.y).toBeCloseTo(660); }); - it('omits theoretical uncached throughput fields when the rate is missing', () => { + it('omits prompt-suffix throughput fields when the rate is missing', () => { const e = entry({ tput_per_gpu: 900, input_tput_per_gpu: 300 }); const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100'); - expect(point.uncachedInputTputPerGpu).toBeUndefined(); - expect(point.uncachedTputPerGpu).toBeUndefined(); + expect(point.promptSuffixTputPerGpu).toBeUndefined(); + expect(point.promptSuffixOutputTputPerGpu).toBeUndefined(); }); - it('omits theoretical uncached throughput fields when the rate is out of range', () => { + it('omits prompt-suffix throughput fields when the rate is out of range', () => { const e = entry({ tput_per_gpu: 900, input_tput_per_gpu: 300, theoretical_cache_hit_rate: 1.2, }); const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100'); - expect(point.uncachedInputTputPerGpu).toBeUndefined(); - expect(point.uncachedTputPerGpu).toBeUndefined(); + expect(point.promptSuffixTputPerGpu).toBeUndefined(); + expect(point.promptSuffixOutputTputPerGpu).toBeUndefined(); }); - it('reports zero uncached input and output-only total at a full theoretical hit rate', () => { + it('reports zero prompt suffix and output-only suffix+output at a full theoretical hit rate', () => { const e = entry({ tput_per_gpu: 900, output_tput_per_gpu: 600, @@ -733,8 +733,8 @@ describe('createChartDataPoint', () => { theoretical_cache_hit_rate: 1, }); const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100'); - expect(point.uncachedInputTputPerGpu?.y).toBe(0); - expect(point.uncachedTputPerGpu?.y).toBeCloseTo(600); + expect(point.promptSuffixTputPerGpu?.y).toBe(0); + expect(point.promptSuffixOutputTputPerGpu?.y).toBeCloseTo(600); }); it('computes tpPerMw from throughput and hardware power', () => { diff --git a/packages/app/src/lib/chart-utils.ts b/packages/app/src/lib/chart-utils.ts index fef68ed4..544acd72 100644 --- a/packages/app/src/lib/chart-utils.ts +++ b/packages/app/src/lib/chart-utils.ts @@ -327,24 +327,24 @@ export function buildDerivedChartFields( if (wants('inputTputPerGpu') && inputTputPerGpu) { fields.inputTputPerGpu = chartMetric(inputTputPerGpu); } - // Theoretical uncached throughput: subtract the infinite-cache theoretical - // prefix share of input throughput (rate derived from the trace by the - // harness). Uses the theoretical rate rather than server-observed cache - // hits so systems with good cache storage aren't penalized — only agentic - // trace points carry the rate, so fixed-sequence points omit these fields. + // Prompt-suffix throughput: subtract the infinite-cache theoretical prefix + // share of input throughput (rate derived from the trace by the harness). + // Uses the theoretical rate rather than server-observed cache hits so + // systems with good cache storage aren't penalized — only agentic trace + // points carry the rate, so fixed-sequence points omit these fields. const theoreticalHitRate = entry.theoretical_cache_hit_rate; const hasTheoreticalHitRate = typeof theoreticalHitRate === 'number' && Number.isFinite(theoreticalHitRate) && theoreticalHitRate >= 0 && theoreticalHitRate <= 1; - if (wants('uncachedTputPerGpu') && hasTheoreticalHitRate && tputPerGpu) { - fields.uncachedTputPerGpu = chartMetric( + if (wants('promptSuffixOutputTputPerGpu') && hasTheoreticalHitRate && tputPerGpu) { + fields.promptSuffixOutputTputPerGpu = chartMetric( Math.max(0, tputPerGpu - inputTputPerGpu * theoreticalHitRate), ); } - if (wants('uncachedInputTputPerGpu') && hasTheoreticalHitRate && inputTputPerGpu) { - fields.uncachedInputTputPerGpu = chartMetric(inputTputPerGpu * (1 - theoreticalHitRate)); + if (wants('promptSuffixTputPerGpu') && hasTheoreticalHitRate && inputTputPerGpu) { + fields.promptSuffixTputPerGpu = chartMetric(inputTputPerGpu * (1 - theoreticalHitRate)); } if (wants('tpPerMw')) fields.tpPerMw = chartMetric((tputPerGpu * 1000) / hardwarePower); if (wants('inputTputPerMw') && inputTputPerGpu) { From 55ef345d237a902306ec4270d43fb5da5371115b Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:28:07 +0000 Subject: [PATCH 05/13] feat(inference): rename metrics to new input suffix naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per thread, call the metric "New Input Suffix + Output Token Throughput": the new (theoretically uncachable) input suffix plus output tokens. - Prompt Suffix + Output Token Throughput per Chip -> New Input Suffix + Output Token Throughput per Chip (key y_newInputSuffixOutputTputPerGpu) - Prompt Suffix Token Throughput per Chip -> New Input Suffix Token Throughput per Chip (key y_newInputSuffixTputPerGpu) 中文:按讨论将指标更名为 "New Input Suffix + Output Token Throughput": 新增(理论上无法缓存的)输入 suffix 加输出 token。 - 每芯片 prompt suffix + 输出 token 吞吐量 -> 每芯片新输入 suffix + 输出 token 吞吐量 - 每芯片 prompt suffix token 吞吐量 -> 每芯片新输入 suffix token 吞吐量 --- .../components/inference/metric-registry.ts | 30 +++++++++---------- .../app/src/components/inference/types.ts | 6 ++-- packages/app/src/lib/chart-utils.test.ts | 26 ++++++++-------- packages/app/src/lib/chart-utils.ts | 10 +++---- 4 files changed, 36 insertions(+), 36 deletions(-) diff --git a/packages/app/src/components/inference/metric-registry.ts b/packages/app/src/components/inference/metric-registry.ts index 706130f7..65ff5d6b 100644 --- a/packages/app/src/components/inference/metric-registry.ts +++ b/packages/app/src/components/inference/metric-registry.ts @@ -43,26 +43,26 @@ export const METRIC_REGISTRY = { titleZh: '每芯片输出 token 吞吐量', polarity: 'higher', }, - // Prompt-suffix throughput pair: total / input throughput minus the + // New-input-suffix throughput pair: total / input throughput minus the // infinite-cache theoretical prefix share (trace-derived, agentic only). // Deliberately based on the theoretical rate rather than server-observed // cache hits so systems with good cache storage aren't penalized — like // MFU vs HFU, actual (uncached + output) throughput is always at least the // theoretical (uncachable suffix + output) throughput. - promptSuffixOutputTputPerGpu: { - field: 'promptSuffixOutputTputPerGpu.y', - label: 'Prompt Suffix + Output Token Throughput per Chip (tok/s/chip)', - labelZh: '每芯片 prompt suffix + 输出 token 吞吐量(tok/s/chip)', - title: 'Prompt Suffix + Output Token Throughput per Chip', - titleZh: '每芯片 prompt suffix + 输出 token 吞吐量', + newInputSuffixOutputTputPerGpu: { + field: 'newInputSuffixOutputTputPerGpu.y', + label: 'New Input Suffix + Output Token Throughput per Chip (tok/s/chip)', + labelZh: '每芯片新输入 suffix + 输出 token 吞吐量(tok/s/chip)', + title: 'New Input Suffix + Output Token Throughput per Chip', + titleZh: '每芯片新输入 suffix + 输出 token 吞吐量', polarity: 'higher', }, - promptSuffixTputPerGpu: { - field: 'promptSuffixTputPerGpu.y', - label: 'Prompt Suffix Token Throughput per Chip (tok/s/chip)', - labelZh: '每芯片 prompt suffix token 吞吐量(tok/s/chip)', - title: 'Prompt Suffix Token Throughput per Chip', - titleZh: '每芯片 prompt suffix token 吞吐量', + newInputSuffixTputPerGpu: { + field: 'newInputSuffixTputPerGpu.y', + label: 'New Input Suffix Token Throughput per Chip (tok/s/chip)', + labelZh: '每芯片新输入 suffix token 吞吐量(tok/s/chip)', + title: 'New Input Suffix Token Throughput per Chip', + titleZh: '每芯片新输入 suffix token 吞吐量', polarity: 'higher', x: 'p90_ttft', xLabel: 'P90 Time To First Token (s)', @@ -498,8 +498,8 @@ export const METRIC_CONTROL_GROUPS: readonly MetricControlGroup[] = [ 'y_tpPerGpu', 'y_inputTputPerGpu', 'y_outputTputPerGpu', - 'y_promptSuffixOutputTputPerGpu', - 'y_promptSuffixTputPerGpu', + 'y_newInputSuffixOutputTputPerGpu', + 'y_newInputSuffixTputPerGpu', 'y_tpPerMw', 'y_inputTputPerMw', 'y_outputTputPerMw', diff --git a/packages/app/src/components/inference/types.ts b/packages/app/src/components/inference/types.ts index 43c68fe9..af1f214f 100644 --- a/packages/app/src/components/inference/types.ts +++ b/packages/app/src/components/inference/types.ts @@ -274,10 +274,10 @@ export interface InferenceData extends Partial { expect(point.inputTputPerGpu).toEqual({ y: 300, roof: false }); }); - it('computes prompt-suffix throughput fields when the rate is valid', () => { + it('computes new-input-suffix throughput fields when the rate is valid', () => { const e = entry({ tput_per_gpu: 900, output_tput_per_gpu: 600, @@ -702,30 +702,30 @@ describe('createChartDataPoint', () => { theoretical_cache_hit_rate: 0.8, }); const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100'); - // prompt suffix = 300 x (1 - 0.8); suffix + output = 900 - 300 x 0.8 - expect(point.promptSuffixTputPerGpu?.y).toBeCloseTo(60); - expect(point.promptSuffixOutputTputPerGpu?.y).toBeCloseTo(660); + // input suffix = 300 x (1 - 0.8); suffix + output = 900 - 300 x 0.8 + expect(point.newInputSuffixTputPerGpu?.y).toBeCloseTo(60); + expect(point.newInputSuffixOutputTputPerGpu?.y).toBeCloseTo(660); }); - it('omits prompt-suffix throughput fields when the rate is missing', () => { + it('omits new-input-suffix throughput fields when the rate is missing', () => { const e = entry({ tput_per_gpu: 900, input_tput_per_gpu: 300 }); const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100'); - expect(point.promptSuffixTputPerGpu).toBeUndefined(); - expect(point.promptSuffixOutputTputPerGpu).toBeUndefined(); + expect(point.newInputSuffixTputPerGpu).toBeUndefined(); + expect(point.newInputSuffixOutputTputPerGpu).toBeUndefined(); }); - it('omits prompt-suffix throughput fields when the rate is out of range', () => { + it('omits new-input-suffix throughput fields when the rate is out of range', () => { const e = entry({ tput_per_gpu: 900, input_tput_per_gpu: 300, theoretical_cache_hit_rate: 1.2, }); const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100'); - expect(point.promptSuffixTputPerGpu).toBeUndefined(); - expect(point.promptSuffixOutputTputPerGpu).toBeUndefined(); + expect(point.newInputSuffixTputPerGpu).toBeUndefined(); + expect(point.newInputSuffixOutputTputPerGpu).toBeUndefined(); }); - it('reports zero prompt suffix and output-only suffix+output at a full theoretical hit rate', () => { + it('reports zero input suffix and output-only suffix+output at a full theoretical hit rate', () => { const e = entry({ tput_per_gpu: 900, output_tput_per_gpu: 600, @@ -733,8 +733,8 @@ describe('createChartDataPoint', () => { theoretical_cache_hit_rate: 1, }); const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100'); - expect(point.promptSuffixTputPerGpu?.y).toBe(0); - expect(point.promptSuffixOutputTputPerGpu?.y).toBeCloseTo(600); + expect(point.newInputSuffixTputPerGpu?.y).toBe(0); + expect(point.newInputSuffixOutputTputPerGpu?.y).toBeCloseTo(600); }); it('computes tpPerMw from throughput and hardware power', () => { diff --git a/packages/app/src/lib/chart-utils.ts b/packages/app/src/lib/chart-utils.ts index 544acd72..45586727 100644 --- a/packages/app/src/lib/chart-utils.ts +++ b/packages/app/src/lib/chart-utils.ts @@ -327,7 +327,7 @@ export function buildDerivedChartFields( if (wants('inputTputPerGpu') && inputTputPerGpu) { fields.inputTputPerGpu = chartMetric(inputTputPerGpu); } - // Prompt-suffix throughput: subtract the infinite-cache theoretical prefix + // New-input-suffix throughput: subtract the infinite-cache theoretical prefix // share of input throughput (rate derived from the trace by the harness). // Uses the theoretical rate rather than server-observed cache hits so // systems with good cache storage aren't penalized — only agentic trace @@ -338,13 +338,13 @@ export function buildDerivedChartFields( Number.isFinite(theoreticalHitRate) && theoreticalHitRate >= 0 && theoreticalHitRate <= 1; - if (wants('promptSuffixOutputTputPerGpu') && hasTheoreticalHitRate && tputPerGpu) { - fields.promptSuffixOutputTputPerGpu = chartMetric( + if (wants('newInputSuffixOutputTputPerGpu') && hasTheoreticalHitRate && tputPerGpu) { + fields.newInputSuffixOutputTputPerGpu = chartMetric( Math.max(0, tputPerGpu - inputTputPerGpu * theoreticalHitRate), ); } - if (wants('promptSuffixTputPerGpu') && hasTheoreticalHitRate && inputTputPerGpu) { - fields.promptSuffixTputPerGpu = chartMetric(inputTputPerGpu * (1 - theoreticalHitRate)); + if (wants('newInputSuffixTputPerGpu') && hasTheoreticalHitRate && inputTputPerGpu) { + fields.newInputSuffixTputPerGpu = chartMetric(inputTputPerGpu * (1 - theoreticalHitRate)); } if (wants('tpPerMw')) fields.tpPerMw = chartMetric((tputPerGpu * 1000) / hardwarePower); if (wants('inputTputPerMw') && inputTputPerGpu) { From cc252f0a8dac28a27b41d369a419d83661ee0573 Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:39:43 +0000 Subject: [PATCH 06/13] fix(inference): require input throughput for suffix throughput metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugbot: missing input throughput is normalized to 0 upstream, so the suffix+output metric would silently equal total throughput. Omit both fields when input throughput is absent. 中文:Bugbot 发现上游会将缺失的输入吞吐量归一化为 0,导致 suffix + 输出 指标退化为总吞吐量。现在输入吞吐量缺失时两个字段均省略。 --- packages/app/src/lib/chart-utils.test.ts | 11 +++++++++++ packages/app/src/lib/chart-utils.ts | 9 ++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/app/src/lib/chart-utils.test.ts b/packages/app/src/lib/chart-utils.test.ts index cbaea2ac..9daf22eb 100644 --- a/packages/app/src/lib/chart-utils.test.ts +++ b/packages/app/src/lib/chart-utils.test.ts @@ -714,6 +714,17 @@ describe('createChartDataPoint', () => { expect(point.newInputSuffixOutputTputPerGpu).toBeUndefined(); }); + it('omits new-input-suffix throughput fields when input throughput is missing', () => { + const e = entry({ + tput_per_gpu: 900, + input_tput_per_gpu: 0, + theoretical_cache_hit_rate: 0.8, + }); + const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100'); + expect(point.newInputSuffixTputPerGpu).toBeUndefined(); + expect(point.newInputSuffixOutputTputPerGpu).toBeUndefined(); + }); + it('omits new-input-suffix throughput fields when the rate is out of range', () => { const e = entry({ tput_per_gpu: 900, diff --git a/packages/app/src/lib/chart-utils.ts b/packages/app/src/lib/chart-utils.ts index 45586727..74c6d249 100644 --- a/packages/app/src/lib/chart-utils.ts +++ b/packages/app/src/lib/chart-utils.ts @@ -338,7 +338,14 @@ export function buildDerivedChartFields( Number.isFinite(theoreticalHitRate) && theoreticalHitRate >= 0 && theoreticalHitRate <= 1; - if (wants('newInputSuffixOutputTputPerGpu') && hasTheoreticalHitRate && tputPerGpu) { + // Both metrics require input throughput: without it the prefix share can't + // be subtracted and suffix+output would silently equal total throughput. + if ( + wants('newInputSuffixOutputTputPerGpu') && + hasTheoreticalHitRate && + tputPerGpu && + inputTputPerGpu + ) { fields.newInputSuffixOutputTputPerGpu = chartMetric( Math.max(0, tputPerGpu - inputTputPerGpu * theoreticalHitRate), ); From 9f749c816c2779d5769d3dfed9d2ccb59bf67714 Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:01:36 +0000 Subject: [PATCH 07/13] feat(inference): new input suffix + output TFLOP/s per chip y-metric MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Achieved model TFLOP/s on the theoretically necessary tokens only: FLOPs/token = 2 x active params (GEMM-only Kaplan/PaLM convention), times the new-input-suffix + output token throughput, so cached prefix tokens contribute no compute credit. Attention-score FLOPs are excluded: they depend on per-request context lengths (only aggregate token counts reach the chart layer) and on the attention implementation (MHA/GQA/MLA/linear), so 2N_active is the comparable cross-model lower bound - same spirit as MFU counting only theoretically required work. Active params come from the model-architectures registry; points whose model lacks an entry omit the field. 中文:新增每芯片新输入 suffix + 输出 TFLOP/s 指标,仅统计理论上必需的 token:FLOPs/token = 2 x 激活参数量(GEMM-only Kaplan/PaLM 口径), 乘以新输入 suffix + 输出 token 吞吐量,缓存的 prefix token 不计入算力。 不含 attention score FLOPs:其依赖每个请求的上下文长度(图表层只有 聚合 token 数)及 attention 实现(MHA/GQA/MLA/linear),2N_active 是 可跨模型比较的下界,与 MFU 只统计理论必需计算量的思路一致。激活参数量 取自 model-architectures 注册表;缺少架构条目的模型省略该字段。 --- .../components/inference/metric-registry.ts | 17 +++++++++++++ .../app/src/components/inference/types.ts | 2 ++ packages/app/src/lib/chart-utils.test.ts | 24 +++++++++++++++++++ packages/app/src/lib/chart-utils.ts | 20 ++++++++++++++++ 4 files changed, 63 insertions(+) diff --git a/packages/app/src/components/inference/metric-registry.ts b/packages/app/src/components/inference/metric-registry.ts index 65ff5d6b..144f59d5 100644 --- a/packages/app/src/components/inference/metric-registry.ts +++ b/packages/app/src/components/inference/metric-registry.ts @@ -68,6 +68,22 @@ export const METRIC_REGISTRY = { xLabel: 'P90 Time To First Token (s)', heading: 'vs. P90 Time To First Token', }, + // Achieved model TFLOP/s on the theoretically necessary tokens only: + // 2 × active params per token (GEMM-only Kaplan/PaLM convention) times the + // new-input-suffix + output token throughput above. Attention-score FLOPs + // are deliberately excluded — they depend on each request's context length + // (only aggregate token counts reach the chart layer) and on the attention + // implementation (MHA/GQA/MLA/linear), so 2N_active is the comparable + // cross-model lower bound, in the same spirit as MFU counting only + // theoretically required work. + newInputSuffixOutputTflopsPerGpu: { + field: 'newInputSuffixOutputTflopsPerGpu.y', + label: 'New Input Suffix + Output TFLOP/s per Chip (TFLOP/s/chip)', + labelZh: '每芯片新输入 suffix + 输出 TFLOP/s(TFLOP/s/chip)', + title: 'New Input Suffix + Output TFLOP/s per Chip', + titleZh: '每芯片新输入 suffix + 输出 TFLOP/s', + polarity: 'higher', + }, tpPerMw: { field: 'tpPerMw.y', label: 'Token Throughput per All in Utility MW (tok/s/MW)', @@ -500,6 +516,7 @@ export const METRIC_CONTROL_GROUPS: readonly MetricControlGroup[] = [ 'y_outputTputPerGpu', 'y_newInputSuffixOutputTputPerGpu', 'y_newInputSuffixTputPerGpu', + 'y_newInputSuffixOutputTflopsPerGpu', 'y_tpPerMw', 'y_inputTputPerMw', 'y_outputTputPerMw', diff --git a/packages/app/src/components/inference/types.ts b/packages/app/src/components/inference/types.ts index af1f214f..b7010759 100644 --- a/packages/app/src/components/inference/types.ts +++ b/packages/app/src/components/inference/types.ts @@ -278,6 +278,8 @@ export interface InferenceData extends Partial { expect(point.newInputSuffixOutputTputPerGpu).toBeUndefined(); }); + it('derives suffix+output TFLOP/s from 2 × active params for known models', () => { + const e = entry({ + model: 'DeepSeek-R1-0528', // 37B active params + tput_per_gpu: 900, + input_tput_per_gpu: 600, + output_tput_per_gpu: 300, + theoretical_cache_hit_rate: 0.8, + }); + const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100'); + // suffix+output = 900 - 600 × 0.8 = 420 tok/s → 2 × 37e9 × 420 / 1e12 + expect(point.newInputSuffixOutputTflopsPerGpu?.y).toBeCloseTo(31.08, 5); + }); + + it('omits suffix+output TFLOP/s for models without architecture data', () => { + const e = entry({ + model: 'not-a-real-model', + tput_per_gpu: 900, + input_tput_per_gpu: 600, + theoretical_cache_hit_rate: 0.8, + }); + const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100'); + expect(point.newInputSuffixOutputTflopsPerGpu).toBeUndefined(); + }); + it('omits new-input-suffix throughput fields when input throughput is missing', () => { const e = entry({ tput_per_gpu: 900, diff --git a/packages/app/src/lib/chart-utils.ts b/packages/app/src/lib/chart-utils.ts index 74c6d249..5c9e477d 100644 --- a/packages/app/src/lib/chart-utils.ts +++ b/packages/app/src/lib/chart-utils.ts @@ -18,6 +18,8 @@ import { type BenchmarkMetricKey, } from '@/components/inference/metric-registry'; import { getGpuSpecs, isKnownGpu } from '@/lib/constants'; +import type { Model } from '@/lib/data-mappings'; +import { getModelArchitecture } from '@/lib/model-architectures'; import { getVendor, type Vendor } from '@/lib/dynamic-colors'; import type { Locale } from '@/lib/i18n'; @@ -350,6 +352,24 @@ export function buildDerivedChartFields( Math.max(0, tputPerGpu - inputTputPerGpu * theoreticalHitRate), ); } + // Achieved model TFLOP/s per chip on the theoretically necessary tokens: + // FLOPs/token = 2 × active params (GEMM-only Kaplan/PaLM convention; + // attention-score FLOPs excluded — see metric-registry doc comment). + // activeParams is in billions, so tok/s × 2 × 1e9 params / 1e12 = ÷1000. + if ( + wants('newInputSuffixOutputTflopsPerGpu') && + hasTheoreticalHitRate && + tputPerGpu && + inputTputPerGpu + ) { + const activeParams = getModelArchitecture(entry.model as Model)?.activeParams; + if (activeParams) { + const suffixOutputTput = Math.max(0, tputPerGpu - inputTputPerGpu * theoreticalHitRate); + fields.newInputSuffixOutputTflopsPerGpu = chartMetric( + (2 * activeParams * suffixOutputTput) / 1000, + ); + } + } if (wants('newInputSuffixTputPerGpu') && hasTheoreticalHitRate && inputTputPerGpu) { fields.newInputSuffixTputPerGpu = chartMetric(inputTputPerGpu * (1 - theoreticalHitRate)); } From 94de05e33637f5fbfd8b75e73d8362e31ccb84f1 Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:19:28 +0000 Subject: [PATCH 08/13] fix(zh-copy): exempt TFLOP/s/chip as an English unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chip-untranslated rule only knew tok/s/chip; the new TFLOP/s/chip y-metric unit tripped it. Units stay English per AGENTS.md rule 6, so extend the exemption to [KMGT]FLOP/s/chip and add a fixture. 中文:chip-untranslated 规则原本只豁免 tok/s/chip,新增的 TFLOP/s/chip Y 轴指标单位触发了误报。按 AGENTS.md 第 6 条单位保留英文,故将豁免 扩展到 [KMGT]FLOP/s/chip 并补充用例。 --- packages/app/src/lib/zh-copy-mechanical-regressions.jsonl | 1 + packages/app/src/lib/zh-copy.test.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/app/src/lib/zh-copy-mechanical-regressions.jsonl b/packages/app/src/lib/zh-copy-mechanical-regressions.jsonl index 8b9e22d1..ce9920f0 100644 --- a/packages/app/src/lib/zh-copy-mechanical-regressions.jsonl +++ b/packages/app/src/lib/zh-copy-mechanical-regressions.jsonl @@ -16,3 +16,4 @@ {"rule": "duplicated-technical-loanword", "kind": "exemption", "text": "'这里所说的预热(warmup)发生在正式测量之前。'", "note": "Chinese-first parenthetical explanation is intentional for a broader audience"} {"rule": "duplicated-technical-loanword", "kind": "exemption", "text": "'随机种子(seed)用于固定会话采样。'", "note": "Chinese-first parenthetical explanation remains allowed"} {"rule": "duplicated-technical-loanword", "kind": "exemption", "text": "'将 KV cache 卸载(offload)到 DRAM。'", "note": "Chinese-first parenthetical explanation remains allowed"} +{"rule": "chip-untranslated", "kind": "exemption", "text": "labelZh: '每芯片新输入 suffix + 输出 TFLOP/s(TFLOP/s/chip)',", "note": "TFLOP/s/chip is a unit and stays English like tok/s/chip"} diff --git a/packages/app/src/lib/zh-copy.test.ts b/packages/app/src/lib/zh-copy.test.ts index 9f47e170..27c6b39a 100644 --- a/packages/app/src/lib/zh-copy.test.ts +++ b/packages/app/src/lib/zh-copy.test.ts @@ -84,7 +84,8 @@ function segment(raw: string, isProse: boolean): string[] { // "Chip" is an ordinary English noun whose Chinese equivalent (芯片) is what // readers actually use, so it cannot inherit GPU's exemption. Units keep the // English form per AGENTS.md rule 6 — that is the one recorded exception. -const CHIP_UNITS = /(?:tok|tokens?)\/s\/chip|\$\/chip[/-](?:hr|hour)|[A-Za-z]Chip\b|\bChip[A-Z]/giu; +const CHIP_UNITS = + /(?:tok|tokens?|[KMGT]?FLOPs?)\/s\/chip|\$\/chip[/-](?:hr|hour)|[A-Za-z]Chip\b|\bChip[A-Z]/giu; const RULES: Rule[] = [ { From 502c3b83e095df5b9c59129b40c5972acd6ee428 Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:17:36 +0000 Subject: [PATCH 09/13] feat(metrics): price per-model attention FLOPs into suffix+output TFLOP/s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the GEMM-only FLOP estimate with GEMM + per-architecture attention FLOPs integrated in closed form over each run's true (ISL, OSL) request distribution. - db: stats v9 adds exact joint request-length moment sums (n, ΣP, ΣP², ΣO, ΣO², ΣPO) to the aggregate-stats bundle and to the derived-agentic-metrics fallback/self-heal path. - app: new attention-flops module prices any affine+capped per-context cost F(L) = lin·L + coeff·min(L, cap) + const exactly from the moments, under the theoretical infinite-cache prefix (cached prefix excluded from computed tokens but still attended by the suffix). - model-architectures: cited attention cost specs for DeepSeek-R1 (absorbed MLA), DeepSeek-V4-Pro (HCA + capped CSA + indexer), Llama 3.x 70B (GQA), gpt-oss (full + sliding-window), Kimi K2.5 (MLA), Kimi K3 (KDA + gated MLA NoPE), MiniMax M2.5 (GQA), MiniMax M3 (dense + capped MSA). - chart-utils: TFLOP/s/chip = suffix+output tput × (2·N_active + attention FLOPs/token) and is omitted when moments or an attention spec are missing. 中文:将 TFLOP/s/chip 指标从仅 GEMM 估算升级为 GEMM + 各模型注意力 FLOPs。 数据库统计 v9 新增请求长度联合矩(n、ΣP、ΣP²、ΣO、ΣO²、ΣPO),前端按各架构 的注意力成本公式 F(L)=lin·L+coeff·min(L,cap)+const 在理论无限缓存前缀假设下 闭式积分。覆盖 DeepSeek-R1/V4-Pro、Llama 3.x 70B、gpt-oss、Kimi K2.5/K3、 MiniMax M2.5/M3 八个架构;缺少矩数据或注意力规格时该指标不显示。 --- .../api/v1/derived-agentic-metrics/route.ts | 2 + .../inference/hooks/useChartData.ts | 32 +++- .../components/inference/metric-registry.ts | 15 +- .../app/src/components/inference/types.ts | 9 ++ .../inference/utils/canonicalFrontier.test.ts | 10 +- .../hooks/api/use-derived-agentic-metrics.ts | 4 + packages/app/src/lib/attention-flops.test.ts | 143 ++++++++++++++++++ packages/app/src/lib/attention-flops.ts | 142 +++++++++++++++++ packages/app/src/lib/benchmark-transform.ts | 10 ++ packages/app/src/lib/chart-utils.test.ts | 40 ++++- packages/app/src/lib/chart-utils.ts | 30 +++- packages/app/src/lib/model-architectures.ts | 114 ++++++++++++++ .../src/etl/compute-aggregate-stats.test.ts | 12 ++ .../db/src/etl/compute-aggregate-stats.ts | 19 ++- packages/db/src/queries/agentic-aggregates.ts | 5 +- packages/db/src/queries/agentic-shared.ts | 69 ++++++++- .../queries/derived-agentic-metrics.test.ts | 42 ++++- .../db/src/queries/derived-agentic-metrics.ts | 28 +++- 18 files changed, 690 insertions(+), 36 deletions(-) create mode 100644 packages/app/src/lib/attention-flops.test.ts create mode 100644 packages/app/src/lib/attention-flops.ts diff --git a/packages/app/src/app/api/v1/derived-agentic-metrics/route.ts b/packages/app/src/app/api/v1/derived-agentic-metrics/route.ts index aa483fc3..2564d33e 100644 --- a/packages/app/src/app/api/v1/derived-agentic-metrics/route.ts +++ b/packages/app/src/app/api/v1/derived-agentic-metrics/route.ts @@ -38,6 +38,8 @@ const getCachedDerivedAgenticMetrics = cachedQuery( * - p75/p90_e2e_norm_intvty: slow-tail OSL / E2E-latency in tok/s/user, * computed as 1 / pXX(per-request E2EL/OSL) across every turn in every * session — plain interactivity charged for the prefill wait. + * - request_length_moments: exact joint (ISL, OSL) moment sums over profiling + * requests, used client-side for closed-form attention-FLOP pricing. * * Ids without a trace_replay blob or with unparseable records are omitted. */ diff --git a/packages/app/src/components/inference/hooks/useChartData.ts b/packages/app/src/components/inference/hooks/useChartData.ts index e293ca1c..796c45aa 100644 --- a/packages/app/src/components/inference/hooks/useChartData.ts +++ b/packages/app/src/components/inference/hooks/useChartData.ts @@ -18,7 +18,10 @@ import { resolveComparisonEntries, } from '@/components/inference/utils/comparisonEntry'; import { useBenchmarks, benchmarkQueryOptions } from '@/hooks/api/use-benchmarks'; +import { useDerivedAgenticMetrics } from '@/hooks/api/use-derived-agentic-metrics'; import type { BenchmarkRow } from '@/lib/api'; +import type { RequestLengthMoments } from '@/lib/attention-flops'; +import { isPersistedBenchmarkId } from '@/lib/benchmark-id'; import { GPU_ALIAS_TO_CANONICAL, getModelSortIndex, @@ -384,6 +387,31 @@ export function useChartData( overviewHistoryPair?.baselineConfigKey, ]); + // Request-length moment sums for agentic points — the attention-FLOPs input + // of the TFLOP/s-per-chip y-metric. Shares the 'derived-agentic-metrics' + // react-query cache with ChartDisplay's derived-x fetch, so switching between + // the two costs one request. The chart renders without them first; the + // TFLOP/s metric fills in when they arrive. + const momentTargetIds = useMemo(() => { + const ids = new Set(); + for (const r of rows) { + const numericId = typeof r.id === 'number' ? r.id : Number(r.id); + if (r.benchmark_type === 'agentic_traces' && isPersistedBenchmarkId(numericId)) { + ids.add(numericId); + } + } + return [...ids].sort((a, b) => a - b); + }, [rows]); + const derivedAgenticQuery = useDerivedAgenticMetrics(momentTargetIds, momentTargetIds.length > 0); + const requestLengthMomentsById = useMemo(() => { + if (!derivedAgenticQuery.data) return undefined; + const map: Record = {}; + for (const metric of Object.values(derivedAgenticQuery.data)) { + map[metric.id] = metric.request_length_moments; + } + return map; + }, [derivedAgenticQuery.data]); + // Transform filtered rows into chart data const { chartData, hardwareConfig: rawHardwareConfig } = useMemo(() => { if (rows.length === 0) @@ -391,8 +419,8 @@ export function useChartData( chartData: [] as InferenceData[][], hardwareConfig: {} as HardwareConfig, }; - return transformBenchmarkRows(rows, selectedPercentile); - }, [rows, selectedPercentile]); + return transformBenchmarkRows(rows, selectedPercentile, requestLengthMomentsById); + }, [rows, selectedPercentile, requestLengthMomentsById]); // Sort hardware config — stabilize reference when keys haven't changed. // Different sequences for the same model often have the same GPU configs, diff --git a/packages/app/src/components/inference/metric-registry.ts b/packages/app/src/components/inference/metric-registry.ts index 144f59d5..6701385c 100644 --- a/packages/app/src/components/inference/metric-registry.ts +++ b/packages/app/src/components/inference/metric-registry.ts @@ -69,13 +69,14 @@ export const METRIC_REGISTRY = { heading: 'vs. P90 Time To First Token', }, // Achieved model TFLOP/s on the theoretically necessary tokens only: - // 2 × active params per token (GEMM-only Kaplan/PaLM convention) times the - // new-input-suffix + output token throughput above. Attention-score FLOPs - // are deliberately excluded — they depend on each request's context length - // (only aggregate token counts reach the chart layer) and on the attention - // implementation (MHA/GQA/MLA/linear), so 2N_active is the comparable - // cross-model lower bound, in the same spirit as MFU counting only - // theoretically required work. + // (2 × active params + per-model attention FLOPs per computed token) times + // the new-input-suffix + output token throughput above. The attention term + // integrates each architecture's mechanism (MLA, CSA/HCA + indexers, MSA, + // KDA + gated MLA, sliding/full GQA — specs in model-architectures.ts, + // math in attention-flops.ts) over the run's exact per-request (ISL, OSL) + // sums at the theoretical cache hit rate, so different request-length + // distributions are priced individually and summed. Points without stored + // request-length moments or an attention spec omit the metric. newInputSuffixOutputTflopsPerGpu: { field: 'newInputSuffixOutputTflopsPerGpu.y', label: 'New Input Suffix + Output TFLOP/s per Chip (TFLOP/s/chip)', diff --git a/packages/app/src/components/inference/types.ts b/packages/app/src/components/inference/types.ts index b7010759..b8d6000f 100644 --- a/packages/app/src/components/inference/types.ts +++ b/packages/app/src/components/inference/types.ts @@ -1,6 +1,7 @@ import type React from 'react'; import type { WorkerPower } from '@semianalysisai/inferencex-db/queries/benchmarks'; +import type { RequestLengthMoments } from '@/lib/attention-flops'; import type { HardwareEntry } from '@/lib/constants'; import type { Model, Sequence } from '@/lib/data-mappings'; import type { MetricKey } from './metric-registry'; @@ -219,6 +220,14 @@ export interface AggDataEntry { total_prompt_tokens?: number; /** Total generated (output) tokens. */ total_generation_tokens?: number; + /** + * Exact joint (ISL, OSL) sums over the run's request population, fetched + * from the derived-agentic-metrics endpoint and merged in by + * `transformBenchmarkRows`. Feeds the attention-FLOPs term of the + * TFLOP/s-per-chip y-metric; absent on fixed-seq points, unofficial-run + * overlays, and rows whose stats haven't been fetched yet. + */ + request_length_moments?: RequestLengthMoments | null; } /** diff --git a/packages/app/src/components/inference/utils/canonicalFrontier.test.ts b/packages/app/src/components/inference/utils/canonicalFrontier.test.ts index 90ddb549..9896b2a4 100644 --- a/packages/app/src/components/inference/utils/canonicalFrontier.test.ts +++ b/packages/app/src/components/inference/utils/canonicalFrontier.test.ts @@ -25,9 +25,9 @@ describe('canonicalNormalizedFrontierIds', () => { it('computes the true normalized-interactivity frontier', () => { const points = [point(1, 100), point(2, 150), point(3, 120)]; const metrics = { - 1: { id: 1, p75_e2e_norm_intvty: 10, p90_e2e_norm_intvty: 10 }, - 2: { id: 2, p75_e2e_norm_intvty: 20, p90_e2e_norm_intvty: 20 }, - 3: { id: 3, p75_e2e_norm_intvty: 30, p90_e2e_norm_intvty: 30 }, + 1: { id: 1, p75_e2e_norm_intvty: 10, p90_e2e_norm_intvty: 10, request_length_moments: null }, + 2: { id: 2, p75_e2e_norm_intvty: 20, p90_e2e_norm_intvty: 20, request_length_moments: null }, + 3: { id: 3, p75_e2e_norm_intvty: 30, p90_e2e_norm_intvty: 30, request_length_moments: null }, }; expect( @@ -38,8 +38,8 @@ describe('canonicalNormalizedFrontierIds', () => { it('keeps frontiers independent across dates', () => { const points = [point(1, 500, { date: '2026-08-01' }), point(2, 100, { date: '2026-08-02' })]; const metrics = { - 1: { id: 1, p75_e2e_norm_intvty: 50, p90_e2e_norm_intvty: 50 }, - 2: { id: 2, p75_e2e_norm_intvty: 10, p90_e2e_norm_intvty: 10 }, + 1: { id: 1, p75_e2e_norm_intvty: 50, p90_e2e_norm_intvty: 50, request_length_moments: null }, + 2: { id: 2, p75_e2e_norm_intvty: 10, p90_e2e_norm_intvty: 10, request_length_moments: null }, }; expect( [...canonicalNormalizedFrontierIds(points, metrics, 'p90', 'upper_left')!].toSorted(), diff --git a/packages/app/src/hooks/api/use-derived-agentic-metrics.ts b/packages/app/src/hooks/api/use-derived-agentic-metrics.ts index aa2c659c..82831ee7 100644 --- a/packages/app/src/hooks/api/use-derived-agentic-metrics.ts +++ b/packages/app/src/hooks/api/use-derived-agentic-metrics.ts @@ -1,3 +1,5 @@ +import type { RequestLengthMoments } from '@/lib/attention-flops'; + import { bulkIdsFetcher, useBulkIdsQuery } from './benchmark-id-query'; export interface DerivedAgenticMetric { @@ -7,6 +9,8 @@ export interface DerivedAgenticMetric { p75_e2e_norm_intvty: number | null; /** Slow-tail P90 E2E Normalized Interactivity in tok/s/user — 1 / p90(per-request E2EL/OSL). */ p90_e2e_norm_intvty: number | null; + /** Exact joint (ISL, OSL) sums over the run's requests — attention-FLOPs input. */ + request_length_moments: RequestLengthMoments | null; } export type DerivedAgenticMetricMap = Record; diff --git a/packages/app/src/lib/attention-flops.test.ts b/packages/app/src/lib/attention-flops.test.ts new file mode 100644 index 00000000..43c33b82 --- /dev/null +++ b/packages/app/src/lib/attention-flops.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; + +import { + attentionFlopsPerComputedToken, + sumComputedTokens, + sumContexts, + type AttentionCostSpec, + type RequestLengthMoments, +} from './attention-flops'; + +/** Build exact moments from raw (ISL, OSL) pairs. */ +function momentsOf(pairs: [number, number][]): RequestLengthMoments { + return { + n: pairs.length, + sumIsl: pairs.reduce((s, [p]) => s + p, 0), + sumIslSq: pairs.reduce((s, [p]) => s + p * p, 0), + sumOsl: pairs.reduce((s, [, o]) => s + o, 0), + sumOslSq: pairs.reduce((s, [, o]) => s + o * o, 0), + sumIslOsl: pairs.reduce((s, [p, o]) => s + p * o, 0), + }; +} + +/** + * Brute-force reference: enumerate every computed token's context. A request + * (P, O) at rate r computes its suffix at contexts r·P+1 … P, then decodes at + * P+1 … P+O. Use rates where r·P is an integer so enumeration is exact. + */ +function bruteForceContexts(pairs: [number, number][], rate: number): number[] { + const contexts: number[] = []; + for (const [p, o] of pairs) { + const cached = rate * p; + for (let t = cached + 1; t <= p + o; t++) contexts.push(t); + } + return contexts; +} + +describe('sumContexts / sumComputedTokens', () => { + it('matches brute-force enumeration at rate 0', () => { + const pairs: [number, number][] = [ + [4, 2], + [10, 3], + [1, 1], + ]; + const contexts = bruteForceContexts(pairs, 0); + const m = momentsOf(pairs); + expect(sumContexts(m, 0)).toBe(contexts.reduce((a, b) => a + b, 0)); + expect(sumComputedTokens(m, 0)).toBe(contexts.length); + }); + + it('matches brute-force enumeration at rate 0.5 (integer cached prefixes)', () => { + const pairs: [number, number][] = [ + [4, 2], + [10, 3], + [100, 7], + ]; + const contexts = bruteForceContexts(pairs, 0.5); + const m = momentsOf(pairs); + expect(sumContexts(m, 0.5)).toBe(contexts.reduce((a, b) => a + b, 0)); + expect(sumComputedTokens(m, 0.5)).toBe(contexts.length); + }); + + it('reduces to decode-only sums at rate 1', () => { + const pairs: [number, number][] = [ + [4, 2], + [10, 3], + ]; + const contexts = bruteForceContexts(pairs, 1); + const m = momentsOf(pairs); + expect(sumContexts(m, 1)).toBe(contexts.reduce((a, b) => a + b, 0)); + expect(sumComputedTokens(m, 1)).toBe(contexts.length); + }); +}); + +describe('attentionFlopsPerComputedToken', () => { + const pairs: [number, number][] = [ + [4, 2], + [10, 3], + ]; + const m = momentsOf(pairs); + + it('prices a pure linear spec exactly (per-context coefficient)', () => { + const spec: AttentionCostSpec = { + groups: [{ label: 'dense', layers: 5, linPerCtx: 1000 }], + }; + const contexts = bruteForceContexts(pairs, 0.5); + const expected = (5 * 1000 * contexts.reduce((a, b) => a + b, 0)) / contexts.length; + expect(attentionFlopsPerComputedToken(spec, m, 0.5)).toBeCloseTo(expected, 8); + }); + + it('prices a constant spec independently of context', () => { + const spec: AttentionCostSpec = { + groups: [{ label: 'linear-attn', layers: 3, constPerToken: 7 }], + }; + expect(attentionFlopsPerComputedToken(spec, m, 0)).toBeCloseTo(21, 10); + expect(attentionFlopsPerComputedToken(spec, m, 0.5)).toBeCloseTo(21, 10); + }); + + it('saturates capped terms at cap × tokens when every context exceeds the cap', () => { + // Single request with a long prompt: all computed contexts are > 2. + const big = momentsOf([[100, 10]]); + const spec: AttentionCostSpec = { + groups: [{ label: 'window', layers: 2, capped: { coeff: 10, cap: 2 } }], + }; + const nTokens = sumComputedTokens(big, 0); + expect(attentionFlopsPerComputedToken(spec, big, 0)).toBeCloseTo( + (2 * 10 * 2 * nTokens) / nTokens, + 10, + ); + }); + + it('falls back to Σctx for capped terms when every context is under the cap', () => { + const spec: AttentionCostSpec = { + groups: [{ label: 'budget', layers: 1, capped: { coeff: 4, cap: 1_000_000 } }], + }; + const contexts = bruteForceContexts(pairs, 0); + const expected = (4 * contexts.reduce((a, b) => a + b, 0)) / contexts.length; + expect(attentionFlopsPerComputedToken(spec, m, 0)).toBeCloseTo(expected, 8); + }); + + it('combines linear + capped + constant groups additively', () => { + const spec: AttentionCostSpec = { + groups: [ + { label: 'a', layers: 2, linPerCtx: 3 }, + { label: 'b', layers: 1, capped: { coeff: 5, cap: 4 } }, + { label: 'c', layers: 4, constPerToken: 11 }, + ], + }; + const nTokens = sumComputedTokens(m, 0); + const ctx = sumContexts(m, 0); + const expected = (2 * 3 * ctx + 5 * Math.min(4 * nTokens, ctx) + 4 * 11 * nTokens) / nTokens; + expect(attentionFlopsPerComputedToken(spec, m, 0)).toBeCloseTo(expected, 8); + }); + + it('returns null for invalid rates, empty moments, and zero computed tokens', () => { + const spec: AttentionCostSpec = { groups: [{ label: 'x', layers: 1, linPerCtx: 1 }] }; + expect(attentionFlopsPerComputedToken(spec, m, -0.1)).toBeNull(); + expect(attentionFlopsPerComputedToken(spec, m, 1.1)).toBeNull(); + expect(attentionFlopsPerComputedToken(spec, m, Number.NaN)).toBeNull(); + expect(attentionFlopsPerComputedToken(spec, momentsOf([]), 0.5)).toBeNull(); + // rate 1 with zero output tokens → nothing is computed. + expect(attentionFlopsPerComputedToken(spec, momentsOf([[10, 0]]), 1)).toBeNull(); + }); +}); diff --git a/packages/app/src/lib/attention-flops.ts b/packages/app/src/lib/attention-flops.ts new file mode 100644 index 00000000..b41fa7d8 --- /dev/null +++ b/packages/app/src/lib/attention-flops.ts @@ -0,0 +1,142 @@ +/** + * Model-specific attention-FLOPs accounting for the + * "New Input Suffix + Output TFLOP/s per Chip" y-metric. + * + * ## What is counted + * + * Activation–activation attention compute only: QK^T score matmuls, + * attention-weighted value aggregation (AV), sparse-attention indexer + * scoring, and linear-attention (KDA) state update/readout. Weight matmuls + * (Q/K/V/O projections, indexer weight projections, MLA up/down projections) + * are EXCLUDED — they are covered by the separate `2 × N_active_params` + * GEMM term (Kaplan et al. / PaLM appendix convention, + * https://arxiv.org/pdf/2204.02311). One multiply-accumulate = 2 FLOPs. + * + * ## Per-layer-group cost model + * + * Every supported architecture's per-computed-token attention cost at + * context length L reduces to the affine-plus-capped form + * + * F_group(L) = linPerCtx · L + capped.coeff · min(L, capped.cap) + constPerToken + * + * summed over layer groups (× layer count). Examples: + * - Dense GQA/MHA/absorbed-MLA: pure `linPerCtx` (4·H·d·L or 2·H·(d_s+d_v)·L). + * - Sliding-window layers (gpt-oss): capped term with cap = window. + * - Top-k sparse attention (DSA/MSA): dense indexer scoring is `linPerCtx`, + * the main attention over the selected set is a capped term with + * cap = token budget (e.g. DeepSeek-V4-Pro CSA's + * 262,144·min(1024, L/4) rewrites to 65,536·min(L, 4096)). + * - Linear attention (Kimi K3 KDA): `constPerToken`, independent of L. + * + * ## Integration over the request population + * + * The DB layer stores exact joint sums over each run's per-request + * (ISL, OSL) pairs (`RequestLengthMoments`). With the theoretical cache hit + * rate r (infinite-cache prefix share of the prompt, so a request with + * prompt P computes its suffix of (1−r)·P tokens at contexts r·P+1 … P, + * then decodes O tokens at contexts P+1 … P+O — cached-prefix tokens are + * NOT recomputed but ARE attended over by every computed token): + * + * Σ contexts = (1−r²)/2·ΣP² + (1−r)/2·ΣP + Σ(P·O) + (ΣO² + ΣO)/2 + * Σ tokens = (1−r)·ΣP + ΣO + * + * both exact given the stored moments. Capped terms use + * Σ min(L, cap) ≈ min(cap · Σtokens, Σcontexts) — an upper bound (min is + * concave) that is exact when every context is on the same side of the cap; + * agentic traces overwhelmingly sit far above the small caps (128-token + * windows) and the error is bounded by the capped term itself for the + * large caps (2048/4096-token budgets). + * + * Per-model formulas and dimensions were verified against HF configs and + * tech reports; sources are cited on each spec in model-architectures.ts. + */ + +/** + * Exact joint (ISL, OSL) sums over a run's request population. Mirrors + * `RequestLengthMoments` in packages/db/src/queries/agentic-shared.ts + * (served through the derived-agentic-metrics endpoint). + */ +export interface RequestLengthMoments { + /** Number of requests with both ISL and OSL present. */ + n: number; + /** Σ ISL_i */ + sumIsl: number; + /** Σ ISL_i² */ + sumIslSq: number; + /** Σ OSL_i */ + sumOsl: number; + /** Σ OSL_i² */ + sumOslSq: number; + /** Σ ISL_i·OSL_i */ + sumIslOsl: number; +} + +/** + * One group of identical attention layers. Per computed token at context L + * the group costs + * layers × (linPerCtx·L + capped.coeff·min(L, capped.cap) + constPerToken) + * FLOPs (MAC = 2 FLOPs; omitted fields default to 0). + */ +export interface AttentionLayerGroup { + /** Human-readable mechanism label (documentation only). */ + label: string; + /** Number of layers of this type. */ + layers: number; + /** FLOPs per computed token per unit of context (the a·L term). */ + linPerCtx?: number; + /** Capped term b·min(L, cap): sliding windows and top-k token budgets. */ + capped?: { coeff: number; cap: number }; + /** Context-independent FLOPs per computed token (linear attention). */ + constPerToken?: number; +} + +/** Full attention-cost specification for one model. */ +export interface AttentionCostSpec { + groups: AttentionLayerGroup[]; +} + +/** Closed-form Σ over all computed tokens of their context lengths. */ +export function sumContexts(moments: RequestLengthMoments, rate: number): number { + const oneMinusR = 1 - rate; + return ( + ((1 - rate * rate) / 2) * moments.sumIslSq + + (oneMinusR / 2) * moments.sumIsl + + moments.sumIslOsl + + (moments.sumOslSq + moments.sumOsl) / 2 + ); +} + +/** Total computed (non-cached) tokens: suffix prefill + decode. */ +export function sumComputedTokens(moments: RequestLengthMoments, rate: number): number { + return (1 - rate) * moments.sumIsl + moments.sumOsl; +} + +/** + * Average attention FLOPs per computed token for a run, integrating the + * model's per-layer-group cost over the request-length distribution at the + * given theoretical cache hit rate. + * + * Returns null when the moments are unusable (no paired requests, or no + * computed tokens — e.g. rate = 1 with zero output tokens). + */ +export function attentionFlopsPerComputedToken( + spec: AttentionCostSpec, + moments: RequestLengthMoments, + rate: number, +): number | null { + if (!Number.isFinite(rate) || rate < 0 || rate > 1) return null; + if (!moments || moments.n <= 0) return null; + const nTokens = sumComputedTokens(moments, rate); + if (!Number.isFinite(nTokens) || nTokens <= 0) return null; + const sumCtx = sumContexts(moments, rate); + if (!Number.isFinite(sumCtx) || sumCtx < 0) return null; + + let totalFlops = 0; + for (const g of spec.groups) { + const lin = (g.linPerCtx ?? 0) * sumCtx; + const capped = g.capped ? g.capped.coeff * Math.min(g.capped.cap * nTokens, sumCtx) : 0; + const constant = (g.constPerToken ?? 0) * nTokens; + totalFlops += g.layers * (lin + capped + constant); + } + return totalFlops / nTokens; +} diff --git a/packages/app/src/lib/benchmark-transform.ts b/packages/app/src/lib/benchmark-transform.ts index 4036af80..f1827035 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 type { RequestLengthMoments } from '@/lib/attention-flops'; import type { BenchmarkRow } from '@/lib/api'; /** @@ -362,10 +363,16 @@ export function mergeRunScopedRows( * (default 'median'). Swaps `median_intvty`/`median_e2el` in the chart * definition for the chosen percentile — only agentic rows carry the * full set (median/p90/p99/p99.9) so this mainly affects that scenario. + * @param requestLengthMomentsById Optional per-persisted-id (ISL, OSL) moment + * sums from the derived-agentic-metrics endpoint. Merged into each entry + * before derived fields are built so the TFLOP/s-per-chip metric can price + * attention FLOPs; callers without them (unofficial-run overlays, AI chart) + * simply omit that metric. */ export function transformBenchmarkRows( rows: BenchmarkRow[], percentile = 'median', + requestLengthMomentsById?: Record, ): { chartData: InferenceData[][]; hardwareConfig: HardwareConfig; @@ -378,6 +385,9 @@ export function transformBenchmarkRows( for (let i = 0; i < rows.length; i++) { const row = rows[i]; const entry = rowToAggDataEntry(row); + if (requestLengthMomentsById && entry.id !== undefined) { + entry.request_length_moments = requestLengthMomentsById[entry.id] ?? null; + } const hwKey = getHardwareKey(entry); entry.hwKey = hwKey; diff --git a/packages/app/src/lib/chart-utils.test.ts b/packages/app/src/lib/chart-utils.test.ts index 7d0e2c0e..3d74836c 100644 --- a/packages/app/src/lib/chart-utils.test.ts +++ b/packages/app/src/lib/chart-utils.test.ts @@ -714,17 +714,30 @@ describe('createChartDataPoint', () => { expect(point.newInputSuffixOutputTputPerGpu).toBeUndefined(); }); - it('derives suffix+output TFLOP/s from 2 × active params for known models', () => { + it('derives suffix+output TFLOP/s from GEMM + attention FLOPs for known models', () => { const e = entry({ - model: 'DeepSeek-R1-0528', // 37B active params + model: 'DeepSeek-R1-0528', // 37B active params, 61 absorbed-MLA layers tput_per_gpu: 900, input_tput_per_gpu: 600, output_tput_per_gpu: 300, theoretical_cache_hit_rate: 0.8, + // One request: ISL 1000, OSL 100. + request_length_moments: { + n: 1, + sumIsl: 1000, + sumIslSq: 1_000_000, + sumOsl: 100, + sumOslSq: 10_000, + sumIslOsl: 100_000, + }, }); const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100'); - // suffix+output = 900 - 600 × 0.8 = 420 tok/s → 2 × 37e9 × 420 / 1e12 - expect(point.newInputSuffixOutputTflopsPerGpu?.y).toBeCloseTo(31.08, 5); + // suffix+output = 900 - 600 × 0.8 = 420 tok/s. + // Σctx = (1-0.64)/2·1e6 + 0.1·1000 + 1e5 + (1e4+100)/2 = 285,150; + // Σtokens = 0.2·1000 + 100 = 300; + // attention = 61·278,528·285,150/300 = 16,149,192,704 FLOPs/token; + // (2·37e9 + 16,149,192,704) × 420 / 1e12 = 37.86266… TFLOP/s. + expect(point.newInputSuffixOutputTflopsPerGpu?.y).toBeCloseTo(37.86266093568, 5); }); it('omits suffix+output TFLOP/s for models without architecture data', () => { @@ -733,6 +746,25 @@ describe('createChartDataPoint', () => { tput_per_gpu: 900, input_tput_per_gpu: 600, theoretical_cache_hit_rate: 0.8, + request_length_moments: { + n: 1, + sumIsl: 1000, + sumIslSq: 1_000_000, + sumOsl: 100, + sumOslSq: 10_000, + sumIslOsl: 100_000, + }, + }); + const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100'); + expect(point.newInputSuffixOutputTflopsPerGpu).toBeUndefined(); + }); + + it('omits suffix+output TFLOP/s when request-length moments are missing', () => { + const e = entry({ + model: 'DeepSeek-R1-0528', + tput_per_gpu: 900, + input_tput_per_gpu: 600, + theoretical_cache_hit_rate: 0.8, }); const point = createChartDataPoint('2025-01-01', e, 'median_e2el', 'tput_per_gpu', 'h100'); expect(point.newInputSuffixOutputTflopsPerGpu).toBeUndefined(); diff --git a/packages/app/src/lib/chart-utils.ts b/packages/app/src/lib/chart-utils.ts index 5c9e477d..e39fa2f5 100644 --- a/packages/app/src/lib/chart-utils.ts +++ b/packages/app/src/lib/chart-utils.ts @@ -19,6 +19,7 @@ import { } from '@/components/inference/metric-registry'; import { getGpuSpecs, isKnownGpu } from '@/lib/constants'; import type { Model } from '@/lib/data-mappings'; +import { attentionFlopsPerComputedToken } from '@/lib/attention-flops'; import { getModelArchitecture } from '@/lib/model-architectures'; import { getVendor, type Vendor } from '@/lib/dynamic-colors'; import type { Locale } from '@/lib/i18n'; @@ -353,21 +354,34 @@ export function buildDerivedChartFields( ); } // Achieved model TFLOP/s per chip on the theoretically necessary tokens: - // FLOPs/token = 2 × active params (GEMM-only Kaplan/PaLM convention; - // attention-score FLOPs excluded — see metric-registry doc comment). - // activeParams is in billions, so tok/s × 2 × 1e9 params / 1e12 = ÷1000. + // FLOPs/token = 2 × active params (Kaplan/PaLM GEMM convention) + the + // model-specific attention FLOPs per computed token, integrated over the + // run's exact per-request (ISL, OSL) sums at the theoretical hit rate + // (see attention-flops.ts). Requires the moments and an attention spec — + // points without them (fixed-seq, unofficial overlays, models outside the + // architecture registry) omit the metric rather than showing a lower bound + // inconsistent with other points. if ( wants('newInputSuffixOutputTflopsPerGpu') && hasTheoreticalHitRate && tputPerGpu && inputTputPerGpu ) { - const activeParams = getModelArchitecture(entry.model as Model)?.activeParams; - if (activeParams) { - const suffixOutputTput = Math.max(0, tputPerGpu - inputTputPerGpu * theoreticalHitRate); - fields.newInputSuffixOutputTflopsPerGpu = chartMetric( - (2 * activeParams * suffixOutputTput) / 1000, + const arch = getModelArchitecture(entry.model as Model); + const moments = entry.request_length_moments; + if (arch?.activeParams && arch.attention && moments) { + const attnFlopsPerToken = attentionFlopsPerComputedToken( + arch.attention, + moments, + theoreticalHitRate, ); + if (attnFlopsPerToken !== null) { + const suffixOutputTput = Math.max(0, tputPerGpu - inputTputPerGpu * theoreticalHitRate); + const flopsPerToken = 2 * arch.activeParams * 1e9 + attnFlopsPerToken; + fields.newInputSuffixOutputTflopsPerGpu = chartMetric( + (suffixOutputTput * flopsPerToken) / 1e12, + ); + } } } if (wants('newInputSuffixTputPerGpu') && hasTheoreticalHitRate && inputTputPerGpu) { diff --git a/packages/app/src/lib/model-architectures.ts b/packages/app/src/lib/model-architectures.ts index f30393bb..1ae9fa90 100644 --- a/packages/app/src/lib/model-architectures.ts +++ b/packages/app/src/lib/model-architectures.ts @@ -1,4 +1,5 @@ import { Model } from '@/lib/data-mappings'; +import type { AttentionCostSpec } from '@/lib/attention-flops'; /** * Model architecture types @@ -134,6 +135,13 @@ export interface ModelArchitecture { ffnVariant?: string; /** Elementwise activation applied to the gate projection. Defaults to `SiLU`. */ ffnGateActivation?: string; + /** + * Attention-FLOPs cost model for the TFLOP/s-per-chip y-metric — see + * attention-flops.ts for the accounting conventions (activation–activation + * ops only, MAC = 2 FLOPs, absorbed MLA form). Models without a spec are + * simply omitted from that metric. + */ + attention?: AttentionCostSpec; } /** @@ -177,6 +185,15 @@ export const MODEL_ARCHITECTURES: Partial> = { ], developer: 'DeepSeek', sourceUrl: 'https://huggingface.co/deepseek-ai/DeepSeek-R1-0528', + // Absorbed (MQA-mode) MLA — the form that executes at decode: all 128 + // query heads score against the shared 576-dim cached latent (512 + // kv_lora_rank + 64 rope) and aggregate 512-dim latent values. + // 2·128·(576+512)·L = 278,528·L per layer. Dims: + // https://huggingface.co/deepseek-ai/DeepSeek-R1-0528/raw/main/config.json; + // MQA-mode description: https://arxiv.org/html/2512.02556v1. + attention: { + groups: [{ label: 'MLA (absorbed)', layers: 61, linPerCtx: 278528 }], + }, }, [Model.DeepSeek_V4_Pro]: { model: Model.DeepSeek_V4_Pro, @@ -242,6 +259,36 @@ export const MODEL_ARCHITECTURES: Partial> = { ], developer: 'DeepSeek', sourceUrl: 'https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro', + // Shared single-latent MQA (d_score = d_v = 512, RoPE in-place in the last + // 64 dims). Config `compress_ratios` decodes the full stack as 31 HCA + + // 30 CSA layers (the 3 hash-routed MoE layers are HCA, HCA, CSA). + // HCA: dense over L/128 pooled entries + 128-token window, no indexer → + // 2·128·(512+512)·(L/128 + 128) = 2,048·L + 33,554,432. + // CSA: FP4 lightning indexer (64 heads × dim 128) over L/4 pooled keys + + // core attention over top-1024 pooled entries + 128-token window → + // 4,096·L + 262,144·(min(1024, L/4) + 128), and + // 262,144·min(1024, L/4) ≡ 65,536·min(L, 4096). + // Attention sink = one learnable per-head logit (0 KV entries, ~0 FLOPs). + // Sources: https://arxiv.org/html/2606.19348v1; + // https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro/raw/main/config.json; + // https://huggingface.co/docs/transformers/en/model_doc/deepseek_v4. + attention: { + groups: [ + { + label: 'HCA (128:1 pooled, dense)', + layers: 31, + linPerCtx: 2048, + constPerToken: 33554432, + }, + { + label: 'CSA (4:1 pooled, indexer top-1024)', + layers: 30, + linPerCtx: 4096, + capped: { coeff: 65536, cap: 4096 }, + constPerToken: 33554432, + }, + ], + }, }, [Model.Llama3_3_70B]: { model: Model.Llama3_3_70B, @@ -259,6 +306,12 @@ export const MODEL_ARCHITECTURES: Partial> = { features: ['Grouped Query Attention', 'RoPE'], developer: 'Meta', sourceUrl: 'https://huggingface.co/meta-llama/Llama-3.3-70B-Instruct', + // Dense causal GQA: 4·H·d·L = 4·64·128·L per layer (GQA KV sharing saves + // cache, not score/AV compute). Dims: + // https://huggingface.co/unsloth/Llama-3.3-70B-Instruct/raw/main/config.json. + attention: { + groups: [{ label: 'Full GQA', layers: 80, linPerCtx: 65536 }], + }, }, [Model.Llama3_1_70B]: { model: Model.Llama3_1_70B, @@ -276,6 +329,11 @@ export const MODEL_ARCHITECTURES: Partial> = { features: ['Grouped Query Attention', 'RoPE'], developer: 'Meta', sourceUrl: 'https://huggingface.co/meta-llama/Llama-3.1-70B-Instruct', + // Identical attention geometry to Llama 3.3 70B (80 × 64 heads × dim 128): + // https://huggingface.co/unsloth/Meta-Llama-3.1-70B-Instruct/raw/main/config.json. + attention: { + groups: [{ label: 'Full GQA', layers: 80, linPerCtx: 65536 }], + }, }, [Model.GptOss]: { model: Model.GptOss, @@ -318,6 +376,17 @@ export const MODEL_ARCHITECTURES: Partial> = { ], developer: 'OpenAI', sourceUrl: 'https://huggingface.co/openai/gpt-oss-120b', + // 18 full + 18 sliding-128 GQA layers, 64 heads × dim 64 → 4·64·64 = + // 16,384 FLOPs/token per ctx unit; sliding layers cap L at 128. The + // learnable sink is one extra softmax logit per head (~0 FLOPs). + // https://huggingface.co/openai/gpt-oss-120b/raw/main/config.json; + // https://arxiv.org/abs/2508.10925. + attention: { + groups: [ + { label: 'Full GQA', layers: 18, linPerCtx: 16384 }, + { label: 'Sliding-128 GQA', layers: 18, capped: { coeff: 16384, cap: 128 } }, + ], + }, }, [Model.Kimi_K2_5]: { model: Model.Kimi_K2_5, @@ -339,6 +408,13 @@ export const MODEL_ARCHITECTURES: Partial> = { features: ['Multi-head Latent Attention', 'DeepSeek-style MoE', 'YaRN RoPE'], developer: 'Moonshot AI', sourceUrl: 'https://huggingface.co/moonshotai/Kimi-K2.5', + // DeepSeek-V3-style absorbed MLA on all 61 layers, 64 heads: score dim + // 576 (512 latent + 64 rope), value dim 512 → 2·64·(576+512)·L = + // 139,264·L per layer. + // https://huggingface.co/moonshotai/Kimi-K2.5/raw/main/config.json. + attention: { + groups: [{ label: 'MLA (absorbed)', layers: 61, linPerCtx: 139264 }], + }, }, [Model.Kimi_K3]: { model: Model.Kimi_K3, @@ -407,6 +483,20 @@ export const MODEL_ARCHITECTURES: Partial> = { ], developer: 'Moonshot AI', sourceUrl: 'https://huggingface.co/moonshotai/Kimi-K3', + // 69 KDA linear-attention layers: gated delta-rule update + readout on a + // 128×128 state per head × 96 heads ≈ 7·H·d² = 11,010,048 FLOPs/token, + // independent of L (recurrent form; the chunked prefill kernel is ~11% + // higher — within noise of this metric). 24 gated-MLA layers are NoPE + // (`mla_use_nope`), so score dim = value dim = 512 → 2·96·(512+512)·L = + // 196,608·L per layer. + // https://huggingface.co/moonshotai/Kimi-K3/raw/main/config.json; + // https://arxiv.org/abs/2510.26692. + attention: { + groups: [ + { label: 'KDA (linear)', layers: 69, constPerToken: 11010048 }, + { label: 'Gated MLA (NoPE, absorbed)', layers: 24, linPerCtx: 196608 }, + ], + }, }, [Model.MiniMax_M2_5]: { model: Model.MiniMax_M2_5, @@ -434,6 +524,12 @@ export const MODEL_ARCHITECTURES: Partial> = { ], developer: 'MiniMax', sourceUrl: 'https://huggingface.co/MiniMaxAI/MiniMax-M2', + // Dense causal GQA on all 62 layers (config `attn_type_list` is all 1s): + // 4·48·128·L = 24,576·L per layer. + // https://huggingface.co/MiniMaxAI/MiniMax-M2.5/raw/main/config.json. + attention: { + groups: [{ label: 'Full GQA', layers: 62, linPerCtx: 24576 }], + }, }, [Model.MiniMax_M3]: { model: Model.MiniMax_M3, @@ -467,6 +563,24 @@ export const MODEL_ARCHITECTURES: Partial> = { ], developer: 'MiniMax', sourceUrl: 'https://huggingface.co/MiniMaxAI/MiniMax-M3', + // Layers 0–2 dense GQA (4·64·128·L = 32,768·L); layers 3–59 MSA: per-token + // index scoring (4 group heads × dim 128, no value head) over all L + // positions = 2·4·128·L = 1,024·L, then exact GQA over the top-16 + // 128-token blocks (2048-token budget) = 32,768·min(L, 2048). Reproduces + // the paper's 28.4× attention-compute reduction at 1M. + // https://huggingface.co/MiniMaxAI/MiniMax-M3/raw/main/config.json; + // https://arxiv.org/html/2606.13392v2. + attention: { + groups: [ + { label: 'Dense GQA (layers 0-2)', layers: 3, linPerCtx: 32768 }, + { + label: 'MSA (top-16 blocks of 128)', + layers: 57, + linPerCtx: 1024, + capped: { coeff: 32768, cap: 2048 }, + }, + ], + }, }, }; diff --git a/packages/db/src/etl/compute-aggregate-stats.test.ts b/packages/db/src/etl/compute-aggregate-stats.test.ts index d2119c7b..e9b2d40d 100644 --- a/packages/db/src/etl/compute-aggregate-stats.test.ts +++ b/packages/db/src/etl/compute-aggregate-stats.test.ts @@ -97,6 +97,16 @@ describe('computeAggregateStats', () => { expect(stats.e2elPerOsl?.p50).toBeCloseTo(2 / 75, 6); // p90 of 3 values (linear interpolation): pos=1.8 → 0.02667 + 0.8×(0.03-0.02667) expect(stats.e2elPerOsl?.p90).toBeCloseTo(2 / 75 + 0.8 * (0.03 - 2 / 75), 6); + + // Exact joint (ISL, OSL) moments for closed-form attention-FLOP pricing. + expect(stats.requestLengthMoments).toEqual({ + n: 3, + sumIsl: 600, + sumIslSq: 100 * 100 + 200 * 200 + 300 * 300, + sumOsl: 225, + sumOslSq: 50 * 50 + 75 * 75 + 100 * 100, + sumIslOsl: 100 * 50 + 200 * 75 + 300 * 100, + }); }); it('excludes cancelled requests from every streamed profile distribution', async () => { @@ -114,6 +124,8 @@ describe('computeAggregateStats', () => { expect(stats.e2elPerOsl?.n).toBe(1); expect(stats.sequenceLengths.isl?.n).toBe(1); expect(stats.sequenceLengths.osl?.n).toBe(1); + expect(stats.requestLengthMoments?.n).toBe(1); + expect(stats.requestLengthMoments?.sumIslSq).toBe(100 * 100); }); it('accepts a gzip profile split across arbitrarily small compressed chunks', async () => { diff --git a/packages/db/src/etl/compute-aggregate-stats.ts b/packages/db/src/etl/compute-aggregate-stats.ts index 07729b59..5b301e25 100644 --- a/packages/db/src/etl/compute-aggregate-stats.ts +++ b/packages/db/src/etl/compute-aggregate-stats.ts @@ -22,6 +22,7 @@ import { type MetricPercentiles, type SequenceLengthSketches, } from '../queries/agentic-aggregates'; +import { requestLengthMomentsOf, type RequestLengthMoments } from '../queries/agentic-shared'; export { STATS_VERSION }; @@ -39,6 +40,12 @@ export interface AggregateStats { e2elPerOsl: MetricPercentiles | null; /** Bounded mergeable distributions used by the chart-level subtitle. */ sequenceLengths: SequenceLengthSketches; + /** + * Exact joint (ISL, OSL) sums over the request population — the sufficient + * statistics the frontend integrates model-specific attention-FLOPs + * formulas over (see agentic-shared.ts). + */ + requestLengthMoments: RequestLengthMoments | null; } interface ProfileMetricEnvelope { @@ -75,12 +82,14 @@ async function extractProfileSamples( isl: number[]; osl: number[]; e2elPerOsl: number[]; + pairs: { isl: number; osl: number }[]; }> { const input = Readable.from(compressedChunks).pipe(createGunzip()); const lines = createInterface({ input, crlfDelay: Infinity }); const isl: number[] = []; const osl: number[] = []; const e2elPerOsl: number[] = []; + const pairs: { isl: number; osl: number }[] = []; for await (const line of lines) { if (!line) continue; @@ -100,6 +109,9 @@ async function extractProfileSamples( const outputLength = profileMetricValue(metrics.output_sequence_length); if (inputLength !== undefined) isl.push(inputLength); if (outputLength !== undefined) osl.push(outputLength); + if (inputLength !== undefined && outputLength !== undefined) { + pairs.push({ isl: inputLength, osl: outputLength }); + } const requestLatencyMs = profileMetricValue(metrics.request_latency); const ttftMs = profileMetricValue(metrics.time_to_first_token); @@ -117,7 +129,7 @@ async function extractProfileSamples( } } - return { isl, osl, e2elPerOsl }; + return { isl, osl, e2elPerOsl, pairs }; } /** @@ -132,13 +144,15 @@ export async function computeProfileAggregateStatsFromCompressedChunks( let oslPct: MetricPercentiles | null = null; let e2elPerOsl: MetricPercentiles | null = null; let sequenceLengths: SequenceLengthSketches = { isl: null, osl: null }; + let requestLengthMoments: RequestLengthMoments | null = null; try { - const { isl, osl, e2elPerOsl: ratios } = await extractProfileSamples(compressedChunks); + const { isl, osl, e2elPerOsl: ratios, pairs } = await extractProfileSamples(compressedChunks); islPct = percentilesOf(isl); oslPct = percentilesOf(osl); sequenceLengths = sequenceLengthSketches(isl, osl); e2elPerOsl = percentilesOf(ratios); + requestLengthMoments = requestLengthMomentsOf(pairs); } catch { // Ignore malformed blobs and leave the profile-derived fields null. } @@ -151,6 +165,7 @@ export async function computeProfileAggregateStatsFromCompressedChunks( prefixCacheHitRate: null, e2elPerOsl, sequenceLengths, + requestLengthMoments, }; } diff --git a/packages/db/src/queries/agentic-aggregates.ts b/packages/db/src/queries/agentic-aggregates.ts index 9511d248..033c7d73 100644 --- a/packages/db/src/queries/agentic-aggregates.ts +++ b/packages/db/src/queries/agentic-aggregates.ts @@ -33,6 +33,7 @@ import { STATS_VERSION, writeBackTraceReplayJsonb, type MetricPercentiles, + type RequestLengthMoments, type SequenceLengthSketches, } from './agentic-shared'; @@ -315,7 +316,7 @@ export async function getAgenticAggregates( if (row.profile_blob) { try { const jsonl = gunzipSync(row.profile_blob).toString('utf8'); - const { isl, osl } = extractIslOsl(jsonl); + const { isl, osl, requestLengthMoments } = extractIslOsl(jsonl); const islPct = percentilesOf(isl); const oslPct = percentilesOf(osl); result[id].isl = islPct; @@ -334,6 +335,7 @@ export async function getAgenticAggregates( prefixCacheHitRate: null, e2elPerOsl: derived.e2el_per_osl, sequenceLengths: sequenceLengthSketches(isl, osl), + requestLengthMoments, }, }); } catch { @@ -420,6 +422,7 @@ interface FullAggregateStats { prefixCacheHitRate: MetricPercentiles | null; e2elPerOsl: MetricPercentiles | null; sequenceLengths: SequenceLengthSketches; + requestLengthMoments: RequestLengthMoments | null; } function blankAggregate(id: number): AgenticAggregate { diff --git a/packages/db/src/queries/agentic-shared.ts b/packages/db/src/queries/agentic-shared.ts index c242e4b8..0823d532 100644 --- a/packages/db/src/queries/agentic-shared.ts +++ b/packages/db/src/queries/agentic-shared.ts @@ -48,8 +48,60 @@ import { * v8: add p95 and bounded mergeable ISL/OSL sketches. The dashboard merges * the sketches for all resident chart points instead of loading request-level * timelines or attempting to combine per-point percentiles. + * + * v9: add `requestLengthMoments` — exact joint moments of the per-request + * (ISL, OSL) pairs (n, ΣISL, ΣISL², ΣOSL, ΣOSL², ΣISL·OSL). The frontend + * integrates model-specific attention-FLOPs formulas over the true request + * population from these sums (prefill attention is quadratic in context, so + * E[ISL²] ≠ E[ISL]² matters); marginal percentiles/sketches can't provide the + * joint ISL·OSL term the decode integral needs. */ -export const STATS_VERSION = 8; +export const STATS_VERSION = 9; + +/** + * Exact sums over the per-request (ISL, OSL) pairs of one benchmark point. + * Only records carrying BOTH sequence lengths contribute, so every sum is + * over the same request population and cross-terms stay consistent. + * + * These six sums are sufficient statistics for any attention-cost integral + * that is polynomial (≤ quadratic) in per-request context length: e.g. + * Σᵢ suffix-prefill context = (1−r²)/2 · ΣISL² and Σᵢ decode context = + * ΣISL·OSL + (ΣOSL² + ΣOSL)/2, with r the point's theoretical cache hit rate. + */ +export interface RequestLengthMoments { + /** Number of requests with both ISL and OSL present. */ + n: number; + sumIsl: number; + sumIslSq: number; + sumOsl: number; + sumOslSq: number; + sumIslOsl: number; +} + +/** Accumulate the joint moments for paired per-request (ISL, OSL) samples. */ +export function requestLengthMomentsOf( + pairs: readonly { isl: number; osl: number }[], +): RequestLengthMoments | null { + if (pairs.length === 0) return null; + const m: RequestLengthMoments = { + n: 0, + sumIsl: 0, + sumIslSq: 0, + sumOsl: 0, + sumOslSq: 0, + sumIslOsl: 0, + }; + for (const { isl, osl } of pairs) { + if (!Number.isFinite(isl) || !Number.isFinite(osl) || isl < 0 || osl < 0) continue; + m.n += 1; + m.sumIsl += isl; + m.sumIslSq += isl * isl; + m.sumOsl += osl; + m.sumOslSq += osl * osl; + m.sumIslOsl += isl * osl; + } + return m.n > 0 ? m : null; +} interface ProfileRecord { metadata?: { benchmark_phase?: string; was_cancelled?: boolean }; @@ -59,10 +111,18 @@ interface ProfileRecord { }; } -/** Parse the profile_export.jsonl → per-request ISL + OSL arrays. */ -export function extractIslOsl(jsonl: string): { isl: number[]; osl: number[] } { +/** + * Parse the profile_export.jsonl → per-request ISL + OSL arrays, plus the + * joint (ISL, OSL) moments over records carrying both lengths. + */ +export function extractIslOsl(jsonl: string): { + isl: number[]; + osl: number[]; + requestLengthMoments: RequestLengthMoments | null; +} { const isl: number[] = []; const osl: number[] = []; + const pairs: { isl: number; osl: number }[] = []; for (const line of jsonl.split('\n')) { if (!line) continue; let rec: ProfileRecord; @@ -78,8 +138,9 @@ export function extractIslOsl(jsonl: string): { isl: number[]; osl: number[] } { const o = readNum(m.output_sequence_length); if (typeof i === 'number') isl.push(i); if (typeof o === 'number') osl.push(o); + if (typeof i === 'number' && typeof o === 'number') pairs.push({ isl: i, osl: o }); } - return { isl, osl }; + return { isl, osl, requestLengthMoments: requestLengthMomentsOf(pairs) }; } export interface MetricPercentiles { diff --git a/packages/db/src/queries/derived-agentic-metrics.test.ts b/packages/db/src/queries/derived-agentic-metrics.test.ts index 091fb81e..ad91b2cd 100644 --- a/packages/db/src/queries/derived-agentic-metrics.test.ts +++ b/packages/db/src/queries/derived-agentic-metrics.test.ts @@ -28,6 +28,41 @@ describe('computeDerivedFromBlob', () => { it('returns null when no usable records', () => { const out = computeDerivedFromBlob(''); expect(out.e2el_per_osl).toBeNull(); + expect(out.request_length_moments).toBeNull(); + }); + + it('accumulates exact joint (ISL, OSL) moments over profiling records', () => { + const lines = [ + rec('s1', 0, { isl: 100, osl: 50, ttft_ms: 500, latency_ms: 1000 }), + rec('s2', 0, { isl: 200, osl: 100, ttft_ms: 1000, latency_ms: 4000 }), + // Missing TTFT breaks the latency ratio but NOT the length moments. + JSON.stringify({ + metadata: { conversation_id: 's3', turn_index: 0, benchmark_phase: 'profiling' }, + metrics: { + request_latency: { value: 1000, unit: 'ms' }, + input_sequence_length: { value: 10, unit: 'tokens' }, + output_sequence_length: { value: 20, unit: 'tokens' }, + }, + }), + // Warmup phase is excluded from both. + JSON.stringify({ + metadata: { conversation_id: 's4', turn_index: 0, benchmark_phase: 'warmup' }, + metrics: { + input_sequence_length: { value: 9999, unit: 'tokens' }, + output_sequence_length: { value: 9999, unit: 'tokens' }, + }, + }), + ]; + const out = computeDerivedFromBlob(lines.join('\n')); + expect(out.request_length_moments).toEqual({ + n: 3, + sumIsl: 310, + sumIslSq: 100 * 100 + 200 * 200 + 10 * 10, + sumOsl: 170, + sumOslSq: 50 * 50 + 100 * 100 + 20 * 20, + sumIslOsl: 100 * 50 + 200 * 100 + 10 * 20, + }); + expect(out.e2el_per_osl?.n).toBe(2); }); it('computes per-request E2EL/OSL ratios pooled across sessions', () => { @@ -258,6 +293,11 @@ describe('getDerivedAgenticMetrics write-back', () => { ]); const result = await getDerivedAgenticMetrics(sql, [7]); - expect(result[7]).toEqual({ id: 7, p75_e2e_norm_intvty: null, p90_e2e_norm_intvty: null }); + expect(result[7]).toEqual({ + id: 7, + p75_e2e_norm_intvty: null, + p90_e2e_norm_intvty: null, + request_length_moments: null, + }); }); }); diff --git a/packages/db/src/queries/derived-agentic-metrics.ts b/packages/db/src/queries/derived-agentic-metrics.ts index 5685ef2e..df94cec9 100644 --- a/packages/db/src/queries/derived-agentic-metrics.ts +++ b/packages/db/src/queries/derived-agentic-metrics.ts @@ -29,10 +29,12 @@ import { fetchAggregateStatsRows, percentilesOf, readNum, + requestLengthMomentsOf, sequenceLengthSketches, STATS_VERSION, writeBackTraceReplayJsonb, type MetricPercentiles, + type RequestLengthMoments, type SequenceLengthSketches, } from './agentic-shared'; @@ -43,6 +45,13 @@ export interface DerivedAgenticMetric { p75_e2e_norm_intvty: number | null; /** Slow-tail P90 E2E Normalized Interactivity in tok/s/user — 1 / p90(per-request E2EL/OSL). */ p90_e2e_norm_intvty: number | null; + /** + * Exact joint (ISL, OSL) sums over the request population. The frontend + * integrates per-model attention-FLOPs formulas over these for the + * TFLOP/s-per-chip y-metric. Null when the blob had no usable records or + * the stored bundle predates v9 and hasn't self-healed yet. + */ + request_length_moments: RequestLengthMoments | null; } export type DerivedAgenticMetricMap = Record; @@ -65,6 +74,7 @@ interface StoredAggregateStats { prefixCacheHitRate: MetricPercentiles | null; e2elPerOsl: MetricPercentiles | null; sequenceLengths: SequenceLengthSketches; + requestLengthMoments?: RequestLengthMoments | null; } /** @@ -123,8 +133,10 @@ function invertRatio(v: number | null | undefined): number | null { */ export function computeDerivedFromBlob(jsonl: string): { e2el_per_osl: MetricPercentiles | null; + request_length_moments: RequestLengthMoments | null; } { const ratios: number[] = []; + const pairs: { isl: number; osl: number }[] = []; for (const line of jsonl.split('\n')) { if (!line) continue; let rec: ProfileRecord; @@ -134,11 +146,20 @@ export function computeDerivedFromBlob(jsonl: string): { continue; } if (rec.metadata?.benchmark_phase && rec.metadata.benchmark_phase !== 'profiling') continue; + // Moments only need the sequence-length pair — keep them even when the + // latency fields extractTurn requires are missing or non-positive. + const m = rec.metrics ?? {}; + const isl = readNum(m.input_sequence_length); + const osl = readNum(m.output_sequence_length); + if (typeof isl === 'number' && typeof osl === 'number') pairs.push({ isl, osl }); const turn = extractTurn(rec); if (!turn) continue; ratios.push(turn.request_latency_ms / 1000 / turn.osl); } - return { e2el_per_osl: percentilesOf(ratios) }; + return { + e2el_per_osl: percentilesOf(ratios), + request_length_moments: requestLengthMomentsOf(pairs), + }; } export async function getDerivedAgenticMetrics( @@ -167,6 +188,7 @@ export async function getDerivedAgenticMetrics( id, p75_e2e_norm_intvty: invertRatio(row.stats.e2elPerOsl?.p75), p90_e2e_norm_intvty: invertRatio(row.stats.e2elPerOsl?.p90), + request_length_moments: row.stats.requestLengthMoments ?? null, }; } else { idsNeedingBlob.push(id); @@ -205,11 +227,12 @@ export async function getDerivedAgenticMetrics( const id = Number(row.benchmark_result_id); try { const jsonl = gunzipSync(row.blob).toString('utf8'); - const { e2el_per_osl } = computeDerivedFromBlob(jsonl); + const { e2el_per_osl, request_length_moments } = computeDerivedFromBlob(jsonl); result[id] = { id, p75_e2e_norm_intvty: invertRatio(e2el_per_osl?.p75), p90_e2e_norm_intvty: invertRatio(e2el_per_osl?.p90), + request_length_moments, }; // Self-heal the shared `aggregate_stats` bundle. We only have the profile @@ -237,6 +260,7 @@ export async function getDerivedAgenticMetrics( prefixCacheHitRate: prior?.prefixCacheHitRate ?? null, e2elPerOsl: e2el_per_osl, sequenceLengths: sequenceLengthSketches(isl, osl), + requestLengthMoments: request_length_moments, }; writeBackTraceReplayJsonb(sql, 'aggregate_stats', Number(row.trace_replay_id), merged); } From 6b62f9a6ef003733452dce3891c30a949d704683 Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:25:32 +0000 Subject: [PATCH 10/13] docs(api): document request_length_moments in derived-agentic-metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the joint (ISL, OSL) request-length moment sums to the public API documentation for GET /api/v1/derived-agentic-metrics (schema + example) and refresh the route/shared-source SHA-256 digests in the catalog. 中文:在 GET /api/v1/derived-agentic-metrics 的公开 API 文档中补充 (ISL, OSL) 请求长度联合矩和字段(schema 与示例),并更新路由及共享 源码的 SHA-256 摘要。 --- packages/app/src/lib/api-documentation.ts | 37 ++++++++++++++++++++--- packages/app/src/lib/api-route-catalog.ts | 4 +-- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/app/src/lib/api-documentation.ts b/packages/app/src/lib/api-documentation.ts index 4a22d16c..5081bcd8 100644 --- a/packages/app/src/lib/api-documentation.ts +++ b/packages/app/src/lib/api-documentation.ts @@ -1849,8 +1849,8 @@ export const apiOperations: readonly ApiOperation[] = [ path: '/api/v1/derived-agentic-metrics', summary: text('Read derived agentic metrics', '读取派生智能体指标'), description: text( - 'Returns normalized interactivity percentiles keyed by benchmark result ID. IDs are deduplicated and at most 200 are accepted.', - '按基准结果 ID 返回归一化交互性百分位。ID 会去重,最多接受 200 个。', + 'Returns normalized interactivity percentiles and joint request-length moment sums keyed by benchmark result ID. IDs are deduplicated and at most 200 are accepted.', + '按基准结果 ID 返回归一化交互性百分位及请求长度联合矩和。ID 会去重,最多接受 200 个。', ), audience: 'public', stability: 'beta', @@ -1868,16 +1868,43 @@ export const apiOperations: readonly ApiOperation[] = [ ], responses: [ success( - 'Result IDs mapped to p75 and p90 normalized interactivity.', - '结果 ID 映射到 p75 和 p90 归一化交互性。', + 'Result IDs mapped to p75/p90 normalized interactivity and exact joint (ISL, OSL) request-length moment sums (null when the profile blob is missing).', + '结果 ID 映射到 p75/p90 归一化交互性及精确的 (ISL, OSL) 请求长度联合矩和(缺少 profile blob 时为 null)。', mapSchema( objectSchema({ id: integerSchema, p75_e2e_norm_intvty: nullableNumberSchema, p90_e2e_norm_intvty: nullableNumberSchema, + request_length_moments: { + type: ['object', 'null'], + properties: { + n: integerSchema, + sumIsl: { type: 'number' }, + sumIslSq: { type: 'number' }, + sumOsl: { type: 'number' }, + sumOslSq: { type: 'number' }, + sumIslOsl: { type: 'number' }, + }, + required: ['n', 'sumIsl', 'sumIslSq', 'sumOsl', 'sumOslSq', 'sumIslOsl'], + additionalProperties: false, + }, }), ), - { '421': { id: 421, p75_e2e_norm_intvty: 31.2, p90_e2e_norm_intvty: 24.8 } }, + { + '421': { + id: 421, + p75_e2e_norm_intvty: 31.2, + p90_e2e_norm_intvty: 24.8, + request_length_moments: { + n: 2, + sumIsl: 300, + sumIslSq: 50_000, + sumOsl: 150, + sumOslSq: 12_500, + sumIslOsl: 25_000, + }, + }, + }, ), errorResponse( '400', diff --git a/packages/app/src/lib/api-route-catalog.ts b/packages/app/src/lib/api-route-catalog.ts index bc49b9bb..0135c745 100644 --- a/packages/app/src/lib/api-route-catalog.ts +++ b/packages/app/src/lib/api-route-catalog.ts @@ -191,7 +191,7 @@ export const apiRouteCatalog = [ method: 'GET', classification: 'published-read', operationId: 'get-derived-agentic-metrics', - sourceSha256: '68013b8e1a0354e677c075b5ab31df4be5889fdc0dc2eb82dbe52a108f4d1db2', + sourceSha256: '13c6ba2b0b4d827745336b016728970651580a79bec59bf4fae9743d35d8eb19', }, { source: 'src/app/api/v1/eval-samples-live/route.ts', @@ -711,7 +711,7 @@ export const apiContractSourceDigests = [ }, { source: '../db/src/queries/agentic-aggregates.ts', - sourceSha256: 'fae8d19971730132cb30cd781f677562bfc6328b1f4e35a8268a8391ad187c18', + sourceSha256: '807bb1349b7adf67ff9b699b8c9bb518932ebeaf3a6a46cb37160e078abb1863', reviewArea: { en: 'Agentic aggregate percentile keys, nullability, and ID-keyed response shape.', zh: '智能体汇总百分位字段、可空性和按 ID 索引的响应结构。', From 3513940d060d066f0c64a60c9e00ea0e6a0d615c Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:51:28 +0000 Subject: [PATCH 11/13] fix: stream oversized blobs in agentic fallbacks to survive Neon's 64 MB response cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production profile_export blobs reach 248 MB compressed while Neon's serverless HTTP driver rejects any response over 64 MB (HTTP 507). The derived-agentic-metrics fallback selected 6 whole blobs per query and the agentic-aggregates fallback 8 per query, so after the v9 stats bump routed every pre-v9 row through the fallback, one oversized batch failed the whole query, the endpoint 500'd, and the new TFLOP/s per chip metric (plus p75/p90 E2E-normalized interactivity) showed "No data available" on existing data. - agentic-shared: add streamTraceReplayBlob (bounded, self-terminating substring chunk reads) + a single shared streaming profile-sample extractor used by ingest, backfill, and both query fallbacks - derived-agentic-metrics + agentic-aggregates: metadata query first, then per-row streamed recompute with per-row error isolation so one bad blob can never blank the whole response; server_metrics blobs are also read via bounded chunks (they can exceed the cap too) - etl/compute-aggregate-stats: delegate to the shared extractor - verified against prod: the previously-507ing 35 MB row returns full moments in <2 s; the worst-case 237 MB blob streams in 11 s at 93 MB RSS 中文: 生产环境的 profile_export blob 压缩后最大达 248 MB,而 Neon serverless HTTP 驱动拒绝超过 64 MB 的响应(HTTP 507)。derived-agentic-metrics 回退路径每次 查询内联拉取 6 个完整 blob,agentic-aggregates 回退每次 8 个;v9 版本升级后 所有旧行都走回退路径,一个超大批次即导致整个查询失败、接口 500,新的 TFLOP/s per chip 指标(以及 p75/p90 端到端归一化交互性)在现有数据上显示 "No data available"。 - agentic-shared:新增 streamTraceReplayBlob(有界、自终止的 substring 分块 读取)及统一的流式 profile 样本提取器,供摄取、回填和两个查询回退共用 - derived-agentic-metrics 与 agentic-aggregates:先做元数据查询,再逐行流式 重算并按行隔离错误,单个坏 blob 不再拖垮整个响应;server_metrics blob 同样 改为分块读取(也可能超过上限) - etl/compute-aggregate-stats:委托给共享提取器 - 已用生产数据验证:此前 507 的 35 MB 行 <2 秒返回完整矩量;最坏情况 237 MB blob 11 秒流式完成,内存峰值 93 MB --- packages/app/src/lib/api-route-catalog.ts | 2 +- .../db/src/etl/compute-aggregate-stats.ts | 94 +-------- .../db/src/queries/agentic-aggregates.test.ts | 48 +++-- packages/db/src/queries/agentic-aggregates.ts | 188 +++++++++--------- .../db/src/queries/agentic-shared.test.ts | 93 ++++++++- packages/db/src/queries/agentic-shared.ts | 159 +++++++++++++-- .../queries/derived-agentic-metrics.test.ts | 29 ++- .../db/src/queries/derived-agentic-metrics.ts | 133 ++++--------- 8 files changed, 414 insertions(+), 332 deletions(-) diff --git a/packages/app/src/lib/api-route-catalog.ts b/packages/app/src/lib/api-route-catalog.ts index 0135c745..08450d9f 100644 --- a/packages/app/src/lib/api-route-catalog.ts +++ b/packages/app/src/lib/api-route-catalog.ts @@ -711,7 +711,7 @@ export const apiContractSourceDigests = [ }, { source: '../db/src/queries/agentic-aggregates.ts', - sourceSha256: '807bb1349b7adf67ff9b699b8c9bb518932ebeaf3a6a46cb37160e078abb1863', + sourceSha256: '1fb18a9e81a77a556fffcf16327c2975bdeb07b5594350a1208bc65bacc27ae9', reviewArea: { en: 'Agentic aggregate percentile keys, nullability, and ID-keyed response shape.', zh: '智能体汇总百分位字段、可空性和按 ID 索引的响应结构。', diff --git a/packages/db/src/etl/compute-aggregate-stats.ts b/packages/db/src/etl/compute-aggregate-stats.ts index 5b301e25..0c32b753 100644 --- a/packages/db/src/etl/compute-aggregate-stats.ts +++ b/packages/db/src/etl/compute-aggregate-stats.ts @@ -9,10 +9,6 @@ * computation changes so the backfill script knows which rows to recompute. */ -import { createInterface } from 'node:readline'; -import { Readable } from 'node:stream'; -import { createGunzip } from 'node:zlib'; - import { gunzipJsonWithinLimit, streamCollectKeys } from './gzip-json-stream'; import { STATS_VERSION, @@ -22,7 +18,11 @@ import { type MetricPercentiles, type SequenceLengthSketches, } from '../queries/agentic-aggregates'; -import { requestLengthMomentsOf, type RequestLengthMoments } from '../queries/agentic-shared'; +import { + extractProfileSamples, + requestLengthMomentsOf, + type RequestLengthMoments, +} from '../queries/agentic-shared'; export { STATS_VERSION }; @@ -48,90 +48,6 @@ export interface AggregateStats { requestLengthMoments: RequestLengthMoments | null; } -interface ProfileMetricEnvelope { - value?: number; -} - -interface ProfileRecord { - metadata?: { - benchmark_phase?: string; - was_cancelled?: boolean; - }; - metrics?: { - input_sequence_length?: ProfileMetricEnvelope | number; - output_sequence_length?: ProfileMetricEnvelope | number; - request_latency?: ProfileMetricEnvelope | number; - time_to_first_token?: ProfileMetricEnvelope | number; - }; -} - -function profileMetricValue(value: ProfileMetricEnvelope | number | undefined): number | undefined { - const number = typeof value === 'number' ? value : value?.value; - return typeof number === 'number' && Number.isFinite(number) ? number : undefined; -} - -/** - * Stream a profile export line by line so exceptionally large traces never - * materialize their multi-gigabyte decompressed JSONL as one string. The - * numeric sample arrays are tiny relative to the source and are needed for - * exact percentile calculation. - */ -async function extractProfileSamples( - compressedChunks: Iterable | AsyncIterable, -): Promise<{ - isl: number[]; - osl: number[]; - e2elPerOsl: number[]; - pairs: { isl: number; osl: number }[]; -}> { - const input = Readable.from(compressedChunks).pipe(createGunzip()); - const lines = createInterface({ input, crlfDelay: Infinity }); - const isl: number[] = []; - const osl: number[] = []; - const e2elPerOsl: number[] = []; - const pairs: { isl: number; osl: number }[] = []; - - for await (const line of lines) { - if (!line) continue; - let record: ProfileRecord; - try { - record = JSON.parse(line) as ProfileRecord; - } catch { - continue; - } - if (record.metadata?.benchmark_phase && record.metadata.benchmark_phase !== 'profiling') { - continue; - } - if (record.metadata?.was_cancelled === true) continue; - - const metrics = record.metrics ?? {}; - const inputLength = profileMetricValue(metrics.input_sequence_length); - const outputLength = profileMetricValue(metrics.output_sequence_length); - if (inputLength !== undefined) isl.push(inputLength); - if (outputLength !== undefined) osl.push(outputLength); - if (inputLength !== undefined && outputLength !== undefined) { - pairs.push({ isl: inputLength, osl: outputLength }); - } - - const requestLatencyMs = profileMetricValue(metrics.request_latency); - const ttftMs = profileMetricValue(metrics.time_to_first_token); - if ( - requestLatencyMs !== undefined && - ttftMs !== undefined && - inputLength !== undefined && - outputLength !== undefined && - requestLatencyMs > 0 && - ttftMs > 0 && - inputLength > 0 && - outputLength > 0 - ) { - e2elPerOsl.push(requestLatencyMs / 1000 / outputLength); - } - } - - return { isl, osl, e2elPerOsl, pairs }; -} - /** * Compute the profile-derived half of the aggregate bundle from compressed * chunks. Backfills use this for oversized TOAST values so neither Postgres diff --git a/packages/db/src/queries/agentic-aggregates.test.ts b/packages/db/src/queries/agentic-aggregates.test.ts index 89ec4b91..96da7913 100644 --- a/packages/db/src/queries/agentic-aggregates.test.ts +++ b/packages/db/src/queries/agentic-aggregates.test.ts @@ -211,10 +211,19 @@ describe('getAgenticAggregates write-back', () => { const { sql, calls } = mockSql([ // fetchAggregateStatsRows [{ benchmark_result_id: 7, stats: staleStats }], - // Pass 1: profile blob (+ trace_replay_id for write-back) - [{ benchmark_result_id: 7, trace_replay_id: 870, profile_blob: profileBlob }], - // Pass 2: server blob - [{ benchmark_result_id: 7, server_blob: serverBlob }], + // metadata query: ids → trace_replay rows + blob presence (no blob inline) + [ + { + benchmark_result_id: 7, + trace_replay_id: 870, + has_profile_blob: true, + has_server_blob: true, + }, + ], + // Pass 1: profile blob substring chunk (short → stream terminates) + [{ chunk: profileBlob }], + // Pass 2: server blob substring chunk + [{ chunk: serverBlob }], ]); const result = await getAgenticAggregates(sql, [7]); @@ -223,14 +232,17 @@ describe('getAgenticAggregates write-back', () => { expect(result[7]?.isl?.n).toBe(2); expect(result[7]?.kvCacheUtil?.mean).toBeCloseTo(0.25, 6); - // 4 calls: stats read, profile read, server read, write-back UPDATE. - expect(calls).toHaveLength(4); - expect(calls[3]!.text).toContain('update agentic_trace_replay set aggregate_stats'); - expect(calls[3]!.text).toContain('::jsonb where id'); + // 5 calls: stats read, metadata read, profile chunk, server chunk, write-back UPDATE. + expect(calls).toHaveLength(5); + // Blobs are streamed via bounded substring chunks — never selected whole. + expect(calls[2]!.text).toContain('substring(profile_export_jsonl_gz from'); + expect(calls[3]!.text).toContain('substring(server_metrics_json_gz from'); + expect(calls[4]!.text).toContain('update agentic_trace_replay set aggregate_stats'); + expect(calls[4]!.text).toContain('::jsonb where id'); // The payload OBJECT is bound directly (not stringified — that would // double-encode into a JSONB string). - const [written, traceReplayId] = calls[3]!.values as [WrittenStats, number]; + const [written, traceReplayId] = calls[4]!.values as [WrittenStats, number]; expect(traceReplayId).toBe(870); expect(written.version).toBe(STATS_VERSION); // Server field FRESHLY recomputed (0.25), not the stale 0.9 carried forward. @@ -258,16 +270,22 @@ describe('getAgenticAggregates write-back', () => { }; const { sql, calls } = mockSql([ [{ benchmark_result_id: 7, stats: staleStats }], - // Pass 1: no profile blob → nothing to recompute, nothing to heal. - [{ benchmark_result_id: 7, trace_replay_id: 870, profile_blob: null }], - // Pass 2: no server blob either. - [{ benchmark_result_id: 7, server_blob: null }], + // Metadata: both blobs missing → nothing to recompute, nothing to heal, + // and no substring reads are even issued. + [ + { + benchmark_result_id: 7, + trace_replay_id: 870, + has_profile_blob: false, + has_server_blob: false, + }, + ], ]); await getAgenticAggregates(sql, [7]); - // stats read + 2 blob reads only — no write-back (profile parse never succeeded). - expect(calls).toHaveLength(3); + // stats read + metadata read only — no write-back (profile parse never ran). + expect(calls).toHaveLength(2); expect(calls.some((c) => c.text.includes('update agentic_trace_replay'))).toBe(false); }); }); diff --git a/packages/db/src/queries/agentic-aggregates.ts b/packages/db/src/queries/agentic-aggregates.ts index 033c7d73..4a36eb24 100644 --- a/packages/db/src/queries/agentic-aggregates.ts +++ b/packages/db/src/queries/agentic-aggregates.ts @@ -14,7 +14,7 @@ */ import { Readable } from 'node:stream'; -import { createGunzip, gunzipSync } from 'node:zlib'; +import { createGunzip } from 'node:zlib'; import { chain } from 'stream-chain'; @@ -24,13 +24,15 @@ import { streamObject } from 'stream-json/streamers/stream-object.js'; import { gunzipJsonWithinLimit } from '../etl/gzip-json-stream'; import type { DbClient } from '../connection.js'; -import { computeDerivedFromBlob } from './derived-agentic-metrics'; import { - extractIslOsl, + extractProfileSamples, fetchAggregateStatsRows, percentilesOf, + readTraceReplayBlob, + requestLengthMomentsOf, sequenceLengthSketches, STATS_VERSION, + streamTraceReplayBlob, writeBackTraceReplayJsonb, type MetricPercentiles, type RequestLengthMoments, @@ -60,17 +62,6 @@ export interface AgenticAggregate { export type AgenticAggregateMap = Record; -/** - * `profile_export_jsonl_gz` is small (~1-3 MB) so we can batch many per - * round-trip. `server_metrics_json_gz` is much bigger (~17 MB compressed - * for high-conc TP+EP runs; Neon encodes bytea over HTTP at ~1.6× wire - * size, so two of those = ~50 MB and three already trips the 64 MB cap). - * We fetch the two blob types in separate queries with different chunk - * sizes. - */ -const PROFILE_CHUNK_SIZE = 8; -const SERVER_CHUNK_SIZE = 1; - interface TimeSlice { start_ns?: number; end_ns?: number; @@ -260,7 +251,7 @@ export async function getAgenticAggregates( const statsRows = await fetchAggregateStatsRows(sql, benchmarkResultIds); const idsNeedingProfile: number[] = []; - const idsNeedingServer: number[] = []; + for (const row of statsRows) { const id = Number(row.benchmark_result_id); const agg = blankAggregate(id); @@ -273,7 +264,6 @@ export async function getAgenticAggregates( // No stats (or stale version) — schedule the blob-parse fallback below // so the response still surfaces data. Backfill should drain these. idsNeedingProfile.push(id); - idsNeedingServer.push(id); } result[id] = agg; } @@ -283,7 +273,7 @@ export async function getAgenticAggregates( if (!(id in result)) result[id] = blankAggregate(id); } - if (idsNeedingProfile.length === 0 && idsNeedingServer.length === 0) { + if (idsNeedingProfile.length === 0) { return result; } @@ -294,94 +284,94 @@ export async function getAgenticAggregates( // good stored data. const pendingById = new Map(); - // ── Fallback Pass 1: profile_export blobs (cheap; large batches). ────── - for (let i = 0; i < idsNeedingProfile.length; i += PROFILE_CHUNK_SIZE) { - const chunk = idsNeedingProfile.slice(i, i + PROFILE_CHUNK_SIZE); - const rows = (await sql` - select - br.id as benchmark_result_id, - atr.id as trace_replay_id, - atr.profile_export_jsonl_gz as profile_blob - from benchmark_results br - join agentic_trace_replay atr on atr.id = br.trace_replay_id - where br.id = any(${chunk}::bigint[]) - `) as { - benchmark_result_id: number; - trace_replay_id: number; - profile_blob: Buffer | null; - }[]; - for (const row of rows) { - const id = Number(row.benchmark_result_id); - result[id] ??= blankAggregate(id); - if (row.profile_blob) { - try { - const jsonl = gunzipSync(row.profile_blob).toString('utf8'); - const { isl, osl, requestLengthMoments } = extractIslOsl(jsonl); - const islPct = percentilesOf(isl); - const oslPct = percentilesOf(osl); - result[id].isl = islPct; - result[id].osl = oslPct; - // Recompute every profile-derived field from this same JSONL so the - // self-healed bundle is complete at the new version. Server-derived - // fields are filled in Pass 2 (or stay null without a server blob). - const derived = computeDerivedFromBlob(jsonl); - pendingById.set(id, { - traceReplayId: Number(row.trace_replay_id), - stats: { - version: STATS_VERSION, - isl: islPct, - osl: oslPct, - kvCacheUtil: null, - prefixCacheHitRate: null, - e2elPerOsl: derived.e2el_per_osl, - sequenceLengths: sequenceLengthSketches(isl, osl), - requestLengthMoments, - }, - }); - } catch { - // ignore malformed blob - } - } + // Both passes stream blobs through bounded `substring` chunks instead of + // selecting whole bytea columns: production profile blobs reach >240 MB + // compressed while Neon's serverless HTTP driver caps a single response at + // 64 MB (HTTP 507 above that), so an inline blob select can fail the whole + // query — the failure mode that blanked derived metrics for stale-version + // rows. One cheap metadata query maps ids to trace_replay rows first. + const metaRows = (await sql` + select + br.id as benchmark_result_id, + atr.id as trace_replay_id, + (atr.profile_export_jsonl_gz is not null) as has_profile_blob, + (atr.server_metrics_json_gz is not null) as has_server_blob + from benchmark_results br + join agentic_trace_replay atr on atr.id = br.trace_replay_id + where br.id = any(${idsNeedingProfile}::bigint[]) + `) as { + benchmark_result_id: number; + trace_replay_id: number; + has_profile_blob: boolean; + has_server_blob: boolean; + }[]; + + // ── Fallback Pass 1: profile_export blobs (streamed line parse). ────── + for (const row of metaRows) { + const id = Number(row.benchmark_result_id); + result[id] ??= blankAggregate(id); + if (!row.has_profile_blob) continue; + try { + // One pass yields every profile-derived field so the self-healed + // bundle is complete at the new version. Server-derived fields are + // filled in Pass 2 (or stay null without a server blob). + const { isl, osl, e2elPerOsl, pairs } = await extractProfileSamples( + streamTraceReplayBlob(sql, 'profile_export_jsonl_gz', Number(row.trace_replay_id)), + ); + const islPct = percentilesOf(isl); + const oslPct = percentilesOf(osl); + result[id].isl = islPct; + result[id].osl = oslPct; + pendingById.set(id, { + traceReplayId: Number(row.trace_replay_id), + stats: { + version: STATS_VERSION, + isl: islPct, + osl: oslPct, + kvCacheUtil: null, + prefixCacheHitRate: null, + e2elPerOsl: percentilesOf(e2elPerOsl), + sequenceLengths: sequenceLengthSketches(isl, osl), + requestLengthMoments: requestLengthMomentsOf(pairs), + }, + }); + } catch { + // ignore malformed/unreadable blob — never fail the whole response } } // ── Fallback Pass 2: server_metrics blobs (huge; one at a time). ─────── // Serial to avoid OOM on the decompressed JSON of a high-conc TP+EP row // (>500 MB raw). The aggregator is fronted by a blob cache, so the slow // path runs at most once per sibling set. - for (let i = 0; i < idsNeedingServer.length; i += SERVER_CHUNK_SIZE) { - const chunk = idsNeedingServer.slice(i, i + SERVER_CHUNK_SIZE); - const rows = (await sql` - select - br.id as benchmark_result_id, - atr.server_metrics_json_gz as server_blob - from benchmark_results br - join agentic_trace_replay atr on atr.id = br.trace_replay_id - where br.id = any(${chunk}::bigint[]) - `) as { benchmark_result_id: number; server_blob: Buffer | null }[]; - for (const row of rows) { - const id = Number(row.benchmark_result_id); - result[id] ??= blankAggregate(id); - if (!row.server_blob) continue; - let parsed: { kvCacheUtil: number[]; prefixCacheHitRate: number[] } | null = null; - try { - const json = gunzipJsonWithinLimit(row.server_blob); - parsed = - json === null - ? await streamExtractServerMetricSamples(row.server_blob) - : extractServerMetricSamples(json); - } catch { - // malformed blob or failed stream fallback — leave nulls - } - if (parsed) { - const kvPct = percentilesOf(parsed.kvCacheUtil); - const prefixPct = percentilesOf(parsed.prefixCacheHitRate); - result[id].kvCacheUtil = kvPct; - result[id].prefixCacheHitRate = prefixPct; - const pending = pendingById.get(id); - if (pending) { - pending.stats.kvCacheUtil = kvPct; - pending.stats.prefixCacheHitRate = prefixPct; - } + for (const row of metaRows) { + const id = Number(row.benchmark_result_id); + result[id] ??= blankAggregate(id); + if (!row.has_server_blob) continue; + let parsed: { kvCacheUtil: number[]; prefixCacheHitRate: number[] } | null = null; + try { + const serverBlob = await readTraceReplayBlob( + sql, + 'server_metrics_json_gz', + Number(row.trace_replay_id), + ); + if (!serverBlob) continue; + const json = gunzipJsonWithinLimit(serverBlob); + parsed = + json === null + ? await streamExtractServerMetricSamples(serverBlob) + : extractServerMetricSamples(json); + } catch { + // malformed blob or failed stream fallback — leave nulls + } + if (parsed) { + const kvPct = percentilesOf(parsed.kvCacheUtil); + const prefixPct = percentilesOf(parsed.prefixCacheHitRate); + result[id].kvCacheUtil = kvPct; + result[id].prefixCacheHitRate = prefixPct; + const pending = pendingById.get(id); + if (pending) { + pending.stats.kvCacheUtil = kvPct; + pending.stats.prefixCacheHitRate = prefixPct; } } } diff --git a/packages/db/src/queries/agentic-shared.test.ts b/packages/db/src/queries/agentic-shared.test.ts index 35a25d97..44b4c16e 100644 --- a/packages/db/src/queries/agentic-shared.test.ts +++ b/packages/db/src/queries/agentic-shared.test.ts @@ -1,8 +1,17 @@ +import { randomBytes } from 'node:crypto'; +import { gzipSync } from 'node:zlib'; + import { afterEach, describe, expect, it, vi } from 'vitest'; import type { DbClient } from '../connection.js'; -import { _resetWriteBackWarned, writeBackTraceReplayJsonb } from './agentic-shared'; +import { + _resetWriteBackWarned, + BLOB_CHUNK_BYTES, + extractProfileSamples, + streamTraceReplayBlob, + writeBackTraceReplayJsonb, +} from './agentic-shared'; /** * Capture every SQL call: the joined template text plus the bound values, so we @@ -77,3 +86,85 @@ describe('writeBackTraceReplayJsonb', () => { expect(warn.mock.calls[0]![0]).toContain('could not persist chart_series'); }); }); + +/** Serve `substring(col from ? for ?)` reads out of an in-memory buffer. */ +function blobSql(blob: Buffer): { sql: DbClient; chunkReads: number[] } { + const chunkReads: number[] = []; + const sql = ((strings: TemplateStringsArray, ...values: unknown[]) => { + const text = strings.join('?'); + expect(text).toContain('substring(profile_export_jsonl_gz from'); + const [offset, length] = values as [number, number, number]; + chunkReads.push(offset); + // SQL substring is 1-based; slice is 0-based. + const chunk = blob.subarray(offset - 1, offset - 1 + length); + return Promise.resolve([{ chunk }]); + }) as unknown as DbClient; + return { sql, chunkReads }; +} + +/** One profiling record padded with poorly compressible bytes. */ +function paddedRec(isl: number, osl: number): string { + return JSON.stringify({ + metadata: { benchmark_phase: 'profiling' }, + metrics: { + request_latency: { value: 1000, unit: 'ms' }, + time_to_first_token: { value: 100, unit: 'ms' }, + input_sequence_length: { value: isl, unit: 'tokens' }, + output_sequence_length: { value: osl, unit: 'tokens' }, + padding: randomBytes(4 * 1024 * 1024).toString('base64'), + }, + }); +} + +describe('streamTraceReplayBlob + extractProfileSamples', () => { + it('reassembles a blob larger than one chunk across multiple substring reads', async () => { + // Poorly compressible padding pushes the gzip past BLOB_CHUNK_BYTES so the + // reader must issue several bounded reads and the gunzip stream must + // reassemble records that straddle chunk boundaries. + const jsonl = [paddedRec(100, 50), paddedRec(200, 100), paddedRec(300, 25)].join('\n'); + const blob = gzipSync(Buffer.from(jsonl)); + expect(blob.length).toBeGreaterThan(BLOB_CHUNK_BYTES); + + const { sql, chunkReads } = blobSql(blob); + const samples = await extractProfileSamples( + streamTraceReplayBlob(sql, 'profile_export_jsonl_gz', 42), + ); + + expect(chunkReads.length).toBeGreaterThan(1); + expect(chunkReads[0]).toBe(1); + expect(chunkReads[1]).toBe(1 + BLOB_CHUNK_BYTES); + expect(samples.isl).toEqual([100, 200, 300]); + expect(samples.osl).toEqual([50, 100, 25]); + expect(samples.e2elPerOsl).toEqual([1 / 50, 1 / 100, 1 / 25]); + expect(samples.pairs).toHaveLength(3); + }); + + it('terminates without reads beyond a short final chunk and skips cancelled records', async () => { + const lines = [ + JSON.stringify({ + metadata: { benchmark_phase: 'profiling' }, + metrics: { + input_sequence_length: { value: 10, unit: 'tokens' }, + output_sequence_length: { value: 5, unit: 'tokens' }, + }, + }), + JSON.stringify({ + metadata: { benchmark_phase: 'profiling', was_cancelled: true }, + metrics: { + input_sequence_length: { value: 7777, unit: 'tokens' }, + output_sequence_length: { value: 7777, unit: 'tokens' }, + }, + }), + ]; + const blob = gzipSync(Buffer.from(lines.join('\n'))); + const { sql, chunkReads } = blobSql(blob); + const samples = await extractProfileSamples( + streamTraceReplayBlob(sql, 'profile_export_jsonl_gz', 42), + ); + // Single short chunk — the reader must not issue a second round-trip. + expect(chunkReads).toEqual([1]); + expect(samples.pairs).toEqual([{ isl: 10, osl: 5 }]); + // No latency fields → no ratio samples; cancelled record fully ignored. + expect(samples.e2elPerOsl).toEqual([]); + }); +}); diff --git a/packages/db/src/queries/agentic-shared.ts b/packages/db/src/queries/agentic-shared.ts index 0823d532..073c4dcb 100644 --- a/packages/db/src/queries/agentic-shared.ts +++ b/packages/db/src/queries/agentic-shared.ts @@ -13,6 +13,10 @@ * write-back. (agentic-aggregates re-exports both for existing importers.) */ +import { createInterface } from 'node:readline'; +import { Readable } from 'node:stream'; +import { createGunzip } from 'node:zlib'; + import type { DbClient } from '../connection.js'; import { @@ -106,11 +110,83 @@ export function requestLengthMomentsOf( interface ProfileRecord { metadata?: { benchmark_phase?: string; was_cancelled?: boolean }; metrics?: { + request_latency?: { value?: number; unit?: string } | number; + time_to_first_token?: { value?: number; unit?: string } | number; input_sequence_length?: { value?: number } | number; output_sequence_length?: { value?: number } | number; }; } +/** + * Per-request samples pulled from a profile_export.jsonl blob in one pass — + * the raw material every profile-derived aggregate is computed from. Both + * query fallbacks and the ingest/backfill path share this single extractor so + * the fast (stored bundle) and slow (blob recompute) paths can never drift. + */ +export interface ProfileSamples { + isl: number[]; + osl: number[]; + /** Per-request E2E latency / OSL ratios (seconds per output token). */ + e2elPerOsl: number[]; + /** (ISL, OSL) pairs over records carrying both lengths. */ + pairs: { isl: number; osl: number }[]; +} + +function addProfileSampleLine(acc: ProfileSamples, line: string): void { + if (!line) return; + let rec: ProfileRecord; + try { + rec = JSON.parse(line) as ProfileRecord; + } catch { + return; + } + if (rec.metadata?.benchmark_phase && rec.metadata.benchmark_phase !== 'profiling') return; + if (rec.metadata?.was_cancelled === true) return; + const m = rec.metrics ?? {}; + const isl = readNum(m.input_sequence_length); + const osl = readNum(m.output_sequence_length); + if (isl !== undefined) acc.isl.push(isl); + if (osl !== undefined) acc.osl.push(osl); + if (isl !== undefined && osl !== undefined) acc.pairs.push({ isl, osl }); + const rl = readNum(m.request_latency); + const tt = readNum(m.time_to_first_token); + if ( + rl !== undefined && + tt !== undefined && + isl !== undefined && + osl !== undefined && + rl > 0 && + tt > 0 && + isl > 0 && + osl > 0 + ) { + acc.e2elPerOsl.push(rl / 1000 / osl); + } +} + +/** Collect profile samples from an already-decompressed JSONL string. */ +export function collectProfileSamplesFromJsonl(jsonl: string): ProfileSamples { + const acc: ProfileSamples = { isl: [], osl: [], e2elPerOsl: [], pairs: [] }; + for (const line of jsonl.split('\n')) addProfileSampleLine(acc, line); + return acc; +} + +/** + * Stream a gzipped profile export line by line so exceptionally large traces + * never materialize their multi-gigabyte decompressed JSONL as one string. + * The numeric sample arrays are tiny relative to the source and are needed + * for exact percentile calculation. + */ +export async function extractProfileSamples( + compressedChunks: Iterable | AsyncIterable, +): Promise { + const input = Readable.from(compressedChunks).pipe(createGunzip()); + const lines = createInterface({ input, crlfDelay: Infinity }); + const acc: ProfileSamples = { isl: [], osl: [], e2elPerOsl: [], pairs: [] }; + for await (const line of lines) addProfileSampleLine(acc, line); + return acc; +} + /** * Parse the profile_export.jsonl → per-request ISL + OSL arrays, plus the * joint (ISL, OSL) moments over records carrying both lengths. @@ -120,29 +196,72 @@ export function extractIslOsl(jsonl: string): { osl: number[]; requestLengthMoments: RequestLengthMoments | null; } { - const isl: number[] = []; - const osl: number[] = []; - const pairs: { isl: number; osl: number }[] = []; - for (const line of jsonl.split('\n')) { - if (!line) continue; - let rec: ProfileRecord; - try { - rec = JSON.parse(line) as ProfileRecord; - } catch { - continue; - } - if (rec.metadata?.benchmark_phase && rec.metadata.benchmark_phase !== 'profiling') continue; - if (rec.metadata?.was_cancelled === true) continue; - const m = rec.metrics ?? {}; - const i = readNum(m.input_sequence_length); - const o = readNum(m.output_sequence_length); - if (typeof i === 'number') isl.push(i); - if (typeof o === 'number') osl.push(o); - if (typeof i === 'number' && typeof o === 'number') pairs.push({ isl: i, osl: o }); - } + const { isl, osl, pairs } = collectProfileSamplesFromJsonl(jsonl); return { isl, osl, requestLengthMoments: requestLengthMomentsOf(pairs) }; } +/** + * 8 MiB of bytea per `substring` read — the hex wire encoding doubles it, so + * each response stays far under Neon's serverless-HTTP 64 MB response cap. + * Production profile blobs reach >240 MB compressed (server blobs are of the + * same order), so selecting a whole blob column inline is NEVER safe: the + * driver rejects the response (HTTP 507) and the whole query fails. + */ +export const BLOB_CHUNK_BYTES = 8 * 1024 * 1024; + +export type TraceReplayBlobColumn = 'profile_export_jsonl_gz' | 'server_metrics_json_gz'; + +/** Normalize a driver-returned bytea value (Buffer, Uint8Array, or hex text). */ +function asBuffer(v: unknown): Buffer | null { + if (v === null || v === undefined) return null; + if (Buffer.isBuffer(v)) return v; + if (v instanceof Uint8Array) return Buffer.from(v); + if (typeof v === 'string' && v.startsWith(String.raw`\x`)) return Buffer.from(v.slice(2), 'hex'); + return null; +} + +/** + * Stream a gzipped blob column in bounded `substring` chunks. Self-terminating + * on the first short/empty chunk, so no size pre-query is needed and a + * `pg_column_size` vs `octet_length` mismatch can never truncate the stream. + */ +export async function* streamTraceReplayBlob( + sql: DbClient, + column: TraceReplayBlobColumn, + traceReplayId: number, +): AsyncGenerator { + for (let offset = 1; ; offset += BLOB_CHUNK_BYTES) { + // Static SQL per column (no dynamic identifiers) — `column` is a + // closed union, not caller-supplied text. + const rows = (await (column === 'profile_export_jsonl_gz' + ? sql` + select substring(profile_export_jsonl_gz from ${offset} for ${BLOB_CHUNK_BYTES}) as chunk + from agentic_trace_replay + where id = ${traceReplayId} + ` + : sql` + select substring(server_metrics_json_gz from ${offset} for ${BLOB_CHUNK_BYTES}) as chunk + from agentic_trace_replay + where id = ${traceReplayId} + `)) as { chunk: unknown }[]; + const chunk = asBuffer(rows[0]?.chunk); + if (!chunk || chunk.length === 0) break; + yield chunk; + if (chunk.length < BLOB_CHUNK_BYTES) break; + } +} + +/** Read a whole blob column via bounded chunks; null when absent/empty. */ +export async function readTraceReplayBlob( + sql: DbClient, + column: TraceReplayBlobColumn, + traceReplayId: number, +): Promise { + const chunks: Buffer[] = []; + for await (const chunk of streamTraceReplayBlob(sql, column, traceReplayId)) chunks.push(chunk); + return chunks.length > 0 ? Buffer.concat(chunks) : null; +} + export interface MetricPercentiles { mean: number; p50: number; diff --git a/packages/db/src/queries/derived-agentic-metrics.test.ts b/packages/db/src/queries/derived-agentic-metrics.test.ts index ad91b2cd..d5eaa16a 100644 --- a/packages/db/src/queries/derived-agentic-metrics.test.ts +++ b/packages/db/src/queries/derived-agentic-metrics.test.ts @@ -191,8 +191,11 @@ describe('getDerivedAgenticMetrics write-back', () => { const { sql, calls } = mockSql([ // fetchAggregateStatsRows [{ benchmark_result_id: 7, stats: staleStats }], - // fallback profile-blob query - [{ benchmark_result_id: 7, trace_replay_id: 870, blob }], + // fallback metadata query (ids → trace_replay rows; no blob inline) + [{ benchmark_result_id: 7, trace_replay_id: 870 }], + // first substring chunk — shorter than BLOB_CHUNK_BYTES, so the + // streaming reader terminates without another round-trip + [{ chunk: blob }], ]); const result = await getDerivedAgenticMetrics(sql, [7]); @@ -201,10 +204,12 @@ describe('getDerivedAgenticMetrics write-back', () => { expect(result[7]?.p90_e2e_norm_intvty).toBeCloseTo(1 / 0.038, 6); expect(result[7]?.p75_e2e_norm_intvty).toBeCloseTo(1 / 0.035, 6); - // 3 calls: stats read, blob read, write-back UPDATE. - expect(calls).toHaveLength(3); - expect(calls[2]!.text).toContain('update agentic_trace_replay set aggregate_stats'); - expect(calls[2]!.text).toContain('::jsonb where id'); + // 4 calls: stats read, metadata read, blob chunk read, write-back UPDATE. + expect(calls).toHaveLength(4); + // The blob is streamed via bounded substring chunks — never selected whole. + expect(calls[2]!.text).toContain('substring(profile_export_jsonl_gz from'); + expect(calls[3]!.text).toContain('update agentic_trace_replay set aggregate_stats'); + expect(calls[3]!.text).toContain('::jsonb where id'); // The write-back binds a COMPLETE, version-stamped bundle at the new version, // recomputing profile fields and carrying server fields forward untouched. @@ -217,7 +222,7 @@ describe('getDerivedAgenticMetrics write-back', () => { kvCacheUtil: unknown; e2elPerOsl: { p75: number; p90: number; n: number } | null; } - const [written, traceReplayId] = calls[2]!.values as [WrittenStats, number]; + const [written, traceReplayId] = calls[3]!.values as [WrittenStats, number]; expect(traceReplayId).toBe(870); expect(written.version).toBe(STATS_VERSION); expect(written.e2elPerOsl?.n).toBe(2); @@ -245,16 +250,18 @@ describe('getDerivedAgenticMetrics write-back', () => { const { sql, calls } = mockSql([ // fetchAggregateStatsRows — no stored bundle at all [{ benchmark_result_id: 7, stats: null }], - // fallback profile-blob query - [{ benchmark_result_id: 7, trace_replay_id: 870, blob }], + // fallback metadata query + [{ benchmark_result_id: 7, trace_replay_id: 870 }], + // single substring chunk + [{ chunk: blob }], ]); const result = await getDerivedAgenticMetrics(sql, [7]); // Caller still gets the freshly computed metric (1 / 0.02 s-per-token). expect(result[7]?.p90_e2e_norm_intvty).toBeCloseTo(50, 6); - // Stats read + blob read only — no write-back UPDATE. - expect(calls).toHaveLength(2); + // Stats read + metadata read + chunk read only — no write-back UPDATE. + expect(calls).toHaveLength(3); expect(calls.some((c) => c.text.includes('update agentic_trace_replay'))).toBe(false); }); diff --git a/packages/db/src/queries/derived-agentic-metrics.ts b/packages/db/src/queries/derived-agentic-metrics.ts index df94cec9..a0a8464c 100644 --- a/packages/db/src/queries/derived-agentic-metrics.ts +++ b/packages/db/src/queries/derived-agentic-metrics.ts @@ -21,17 +21,16 @@ * WORST request's effective token rate. */ -import { gunzipSync } from 'node:zlib'; - import type { DbClient } from '../connection.js'; import { - extractIslOsl, + collectProfileSamplesFromJsonl, + extractProfileSamples, fetchAggregateStatsRows, percentilesOf, - readNum, requestLengthMomentsOf, sequenceLengthSketches, STATS_VERSION, + streamTraceReplayBlob, writeBackTraceReplayJsonb, type MetricPercentiles, type RequestLengthMoments, @@ -77,48 +76,6 @@ interface StoredAggregateStats { requestLengthMoments?: RequestLengthMoments | null; } -/** - * JSONL blobs can be ~1-2 MB compressed (~5-10 MB raw) and Neon's serverless - * HTTP driver caps responses at 64 MB — chunk to stay well under. - */ -const QUERY_CHUNK_SIZE = 6; - -interface RecordMetrics { - request_latency?: { value?: number; unit?: string } | number; - time_to_first_token?: { value?: number; unit?: string } | number; - input_sequence_length?: { value?: number } | number; - output_sequence_length?: { value?: number } | number; -} - -interface RecordMetadata { - conversation_id?: string; - turn_index?: number; - benchmark_phase?: string; -} - -interface ProfileRecord { - metadata?: RecordMetadata; - metrics?: RecordMetrics; -} - -interface TurnFields { - request_latency_ms: number; - ttft_ms: number; - isl: number; - osl: number; -} - -function extractTurn(rec: ProfileRecord): TurnFields | null { - const m = rec.metrics ?? {}; - const rl = readNum(m.request_latency); - const tt = readNum(m.time_to_first_token); - const isl = readNum(m.input_sequence_length); - const osl = readNum(m.output_sequence_length); - if (rl === undefined || tt === undefined || isl === undefined || osl === undefined) return null; - if (rl <= 0 || tt <= 0 || isl <= 0 || osl <= 0) return null; - return { request_latency_ms: rl, ttft_ms: tt, isl, osl }; -} - /** 1/x for a positive stored ratio; null when the bundle/percentile is absent. */ function invertRatio(v: number | null | undefined): number | null { return typeof v === 'number' && Number.isFinite(v) && v > 0 ? 1 / v : null; @@ -135,29 +92,12 @@ export function computeDerivedFromBlob(jsonl: string): { e2el_per_osl: MetricPercentiles | null; request_length_moments: RequestLengthMoments | null; } { - const ratios: number[] = []; - const pairs: { isl: number; osl: number }[] = []; - for (const line of jsonl.split('\n')) { - if (!line) continue; - let rec: ProfileRecord; - try { - rec = JSON.parse(line) as ProfileRecord; - } catch { - continue; - } - if (rec.metadata?.benchmark_phase && rec.metadata.benchmark_phase !== 'profiling') continue; - // Moments only need the sequence-length pair — keep them even when the - // latency fields extractTurn requires are missing or non-positive. - const m = rec.metrics ?? {}; - const isl = readNum(m.input_sequence_length); - const osl = readNum(m.output_sequence_length); - if (typeof isl === 'number' && typeof osl === 'number') pairs.push({ isl, osl }); - const turn = extractTurn(rec); - if (!turn) continue; - ratios.push(turn.request_latency_ms / 1000 / turn.osl); - } + // Moments only need the sequence-length pair — the shared collector keeps + // them even when the latency fields the ratio requires are missing or + // non-positive. + const { e2elPerOsl, pairs } = collectProfileSamplesFromJsonl(jsonl); return { - e2el_per_osl: percentilesOf(ratios), + e2el_per_osl: percentilesOf(e2elPerOsl), request_length_moments: requestLengthMomentsOf(pairs), }; } @@ -198,36 +138,37 @@ export async function getDerivedAgenticMetrics( if (idsNeedingBlob.length === 0) return result; - // Fallback: parse the profile blob directly. Used for rows whose + // Fallback: recompute from the profile blob. Used for rows whose // `aggregate_stats` is null or computed by an older STATS_VERSION; the // backfill script drains the population so this path should be rare. - // `trace_replay_id` + the (small) stale `aggregate_stats` come along on the - // same join — no extra round-trip — so we can self-heal after recompute. - const rows: { - benchmark_result_id: number; - trace_replay_id: number; - blob: Buffer; - }[] = []; - for (let i = 0; i < idsNeedingBlob.length; i += QUERY_CHUNK_SIZE) { - const chunk = idsNeedingBlob.slice(i, i + QUERY_CHUNK_SIZE); - const chunkRows = (await sql` - select - br.id as benchmark_result_id, - atr.id as trace_replay_id, - atr.profile_export_jsonl_gz as blob - from benchmark_results br - join agentic_trace_replay atr on atr.id = br.trace_replay_id - where br.id = any(${chunk}::bigint[]) - and atr.profile_export_jsonl_gz is not null - `) as { benchmark_result_id: number; trace_replay_id: number; blob: Buffer }[]; - rows.push(...chunkRows); - } - - for (const row of rows) { + // + // The blob is NEVER selected whole: production profile exports reach + // >240 MB compressed while Neon's serverless HTTP driver caps a response at + // 64 MB (HTTP 507 above that — the failure that blanked the TFLOP/s + // metric for every pre-v9 row). Instead a cheap metadata query maps ids to + // trace_replay rows, then each blob streams through bounded `substring` + // chunks into a streaming gunzip line parser — the same pattern + // backfill-aggregate-stats.ts uses for oversized TOAST values. + const metaRows = (await sql` + select + br.id as benchmark_result_id, + atr.id as trace_replay_id + from benchmark_results br + join agentic_trace_replay atr on atr.id = br.trace_replay_id + where br.id = any(${idsNeedingBlob}::bigint[]) + and atr.profile_export_jsonl_gz is not null + `) as { benchmark_result_id: number; trace_replay_id: number }[]; + + // Serial on purpose: each blob already parallelizes nothing and bounding + // concurrency keeps peak memory at one decompression stream. + for (const row of metaRows) { const id = Number(row.benchmark_result_id); try { - const jsonl = gunzipSync(row.blob).toString('utf8'); - const { e2el_per_osl, request_length_moments } = computeDerivedFromBlob(jsonl); + const { isl, osl, e2elPerOsl, pairs } = await extractProfileSamples( + streamTraceReplayBlob(sql, 'profile_export_jsonl_gz', Number(row.trace_replay_id)), + ); + const e2el_per_osl = percentilesOf(e2elPerOsl); + const request_length_moments = requestLengthMomentsOf(pairs); result[id] = { id, p75_e2e_norm_intvty: invertRatio(e2el_per_osl?.p75), @@ -251,7 +192,6 @@ export async function getDerivedAgenticMetrics( const prior = staleStatsById.get(id) ?? null; const canPreserveServerFields = Boolean(prior?.kvCacheUtil || prior?.prefixCacheHitRate); if (canPreserveServerFields) { - const { isl, osl } = extractIslOsl(jsonl); const merged: StoredAggregateStats = { version: STATS_VERSION, isl: percentilesOf(isl), @@ -265,7 +205,8 @@ export async function getDerivedAgenticMetrics( writeBackTraceReplayJsonb(sql, 'aggregate_stats', Number(row.trace_replay_id), merged); } } catch { - // Skip malformed blobs silently — frontend treats missing ids as "no data". + // One malformed/unreadable blob must never take down the whole + // response — the frontend treats missing ids as "no data". } } return result; From f0422659f48da351f471ecc97dfbfa5a171378fe Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:57:43 +0000 Subject: [PATCH 12/13] chore: refresh agentic-aggregates shared-source digest after format hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中文:提交钩子二次格式化后刷新 agentic-aggregates 的共享源码 SHA-256 摘要。 --- packages/app/src/lib/api-route-catalog.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/lib/api-route-catalog.ts b/packages/app/src/lib/api-route-catalog.ts index 08450d9f..0f18edb6 100644 --- a/packages/app/src/lib/api-route-catalog.ts +++ b/packages/app/src/lib/api-route-catalog.ts @@ -711,7 +711,7 @@ export const apiContractSourceDigests = [ }, { source: '../db/src/queries/agentic-aggregates.ts', - sourceSha256: '1fb18a9e81a77a556fffcf16327c2975bdeb07b5594350a1208bc65bacc27ae9', + sourceSha256: 'f9745baf7d63b0a52081c4f1b587e9d4eb131b31a8127cd3d1281b87c8ec33cc', reviewArea: { en: 'Agentic aggregate percentile keys, nullability, and ID-keyed response shape.', zh: '智能体汇总百分位字段、可空性和按 ID 索引的响应结构。', From 728011ef96f0ac04ba8f8fa316c8177c73af8728 Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:02:57 +0000 Subject: [PATCH 13/13] fix: correct Llama GQA attention constant; never self-heal null server fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugbot findings on #849: - Llama 3.1/3.3 70B linPerCtx was 65536 but 4·H·d with 64 heads × dim 128 is 32768 — attention FLOPs were doubled for both Llama models. - agentic-aggregates pass 2: when a server blob exists but fails to read or parse, drop the id from the self-heal set instead of stamping a current-version bundle with null kvCacheUtil/prefixCacheHitRate, which would permanently cache the miss (fast path would never retry the blob). 中文: - Llama 3.1/3.3 70B 的 linPerCtx 误写为 65536;按 4·H·d(64 头 × 128 维) 应为 32768,此前两款 Llama 的注意力 FLOPs 被高估一倍。 - agentic-aggregates 第二遍:server blob 存在但读取/解析失败时,从自愈集合 中剔除该 id,而不是把 kvCacheUtil/prefixCacheHitRate 为 null 的当前版本 bundle 写回——否则缺失会被永久缓存,快路径不再重试。 --- packages/app/src/lib/api-route-catalog.ts | 2 +- packages/app/src/lib/model-architectures.ts | 4 +- .../db/src/queries/agentic-aggregates.test.ts | 38 +++++++++++++++++++ packages/db/src/queries/agentic-aggregates.ts | 19 +++++++--- 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/packages/app/src/lib/api-route-catalog.ts b/packages/app/src/lib/api-route-catalog.ts index 0f18edb6..b3549e5c 100644 --- a/packages/app/src/lib/api-route-catalog.ts +++ b/packages/app/src/lib/api-route-catalog.ts @@ -711,7 +711,7 @@ export const apiContractSourceDigests = [ }, { source: '../db/src/queries/agentic-aggregates.ts', - sourceSha256: 'f9745baf7d63b0a52081c4f1b587e9d4eb131b31a8127cd3d1281b87c8ec33cc', + sourceSha256: 'd2ce8c8769dd38012ba22054017f600b8e6e978a54a9248dd6085d5fc6b4b642', reviewArea: { en: 'Agentic aggregate percentile keys, nullability, and ID-keyed response shape.', zh: '智能体汇总百分位字段、可空性和按 ID 索引的响应结构。', diff --git a/packages/app/src/lib/model-architectures.ts b/packages/app/src/lib/model-architectures.ts index 1ae9fa90..daef3b00 100644 --- a/packages/app/src/lib/model-architectures.ts +++ b/packages/app/src/lib/model-architectures.ts @@ -310,7 +310,7 @@ export const MODEL_ARCHITECTURES: Partial> = { // cache, not score/AV compute). Dims: // https://huggingface.co/unsloth/Llama-3.3-70B-Instruct/raw/main/config.json. attention: { - groups: [{ label: 'Full GQA', layers: 80, linPerCtx: 65536 }], + groups: [{ label: 'Full GQA', layers: 80, linPerCtx: 32768 }], }, }, [Model.Llama3_1_70B]: { @@ -332,7 +332,7 @@ export const MODEL_ARCHITECTURES: Partial> = { // Identical attention geometry to Llama 3.3 70B (80 × 64 heads × dim 128): // https://huggingface.co/unsloth/Meta-Llama-3.1-70B-Instruct/raw/main/config.json. attention: { - groups: [{ label: 'Full GQA', layers: 80, linPerCtx: 65536 }], + groups: [{ label: 'Full GQA', layers: 80, linPerCtx: 32768 }], }, }, [Model.GptOss]: { diff --git a/packages/db/src/queries/agentic-aggregates.test.ts b/packages/db/src/queries/agentic-aggregates.test.ts index 96da7913..ba3ccf67 100644 --- a/packages/db/src/queries/agentic-aggregates.test.ts +++ b/packages/db/src/queries/agentic-aggregates.test.ts @@ -288,4 +288,42 @@ describe('getAgenticAggregates write-back', () => { expect(calls).toHaveLength(2); expect(calls.some((c) => c.text.includes('update agentic_trace_replay'))).toBe(false); }); + + it('does not stamp a bundle with null server fields when the server blob fails to parse', async () => { + const profileBlob = gzipSync( + Buffer.from(profileRec({ cid: 's1', isl: 100, osl: 50, ttft_ms: 500, latency_ms: 1000 })), + ); + const staleStats = { + version: STATS_VERSION - 1, + isl: null, + osl: null, + kvCacheUtil: null, + prefixCacheHitRate: null, + }; + const { sql, calls } = mockSql([ + [{ benchmark_result_id: 7, stats: staleStats }], + [ + { + benchmark_result_id: 7, + trace_replay_id: 870, + has_profile_blob: true, + has_server_blob: true, + }, + ], + // Pass 1: profile blob parses fine. + [{ chunk: profileBlob }], + // Pass 2: server blob chunk is corrupt — gunzip fails. + [{ chunk: Buffer.from('not gzip at all') }], + ]); + + const result = await getAgenticAggregates(sql, [7]); + + // Profile-derived fields are still served for this request… + expect(result[7]?.isl?.n).toBe(1); + expect(result[7]?.kvCacheUtil).toBeNull(); + // …but nothing is written back: stamping a current-version bundle with + // null server fields would permanently cache the miss. + expect(calls).toHaveLength(4); + expect(calls.some((c) => c.text.includes('update agentic_trace_replay'))).toBe(false); + }); }); diff --git a/packages/db/src/queries/agentic-aggregates.ts b/packages/db/src/queries/agentic-aggregates.ts index 4a36eb24..343b1f82 100644 --- a/packages/db/src/queries/agentic-aggregates.ts +++ b/packages/db/src/queries/agentic-aggregates.ts @@ -354,12 +354,13 @@ export async function getAgenticAggregates( 'server_metrics_json_gz', Number(row.trace_replay_id), ); - if (!serverBlob) continue; - const json = gunzipJsonWithinLimit(serverBlob); - parsed = - json === null - ? await streamExtractServerMetricSamples(serverBlob) - : extractServerMetricSamples(json); + if (serverBlob) { + const json = gunzipJsonWithinLimit(serverBlob); + parsed = + json === null + ? await streamExtractServerMetricSamples(serverBlob) + : extractServerMetricSamples(json); + } } catch { // malformed blob or failed stream fallback — leave nulls } @@ -373,6 +374,12 @@ export async function getAgenticAggregates( pending.stats.kvCacheUtil = kvPct; pending.stats.prefixCacheHitRate = prefixPct; } + } else { + // A server blob exists but couldn't be read or parsed this time + // (transient stream error, oversized JSON, ...). Don't self-heal a + // version-stamped bundle with null server fields — that would + // permanently cache the miss and the fast path would never retry. + pendingById.delete(id); } }