Skip to content

Commit 7cfe0be

Browse files
authored
fix(agentic): Deduplicate phase-scoped KV rank series / 修复分阶段 KV rank 序列重复 (#602)
* fix(agentic): deduplicate phase-scoped KV rank series Coalesce warmup and profiling metrics by source identity, filter phase-empty rank series, and bump the chart-series cache version. 中文:修复按阶段查看 KV cache 时 DP rank 重复的问题。按来源标识合并 warmup 与 profiling 指标,过滤当前阶段无数据的 rank 序列,并升级图表序列缓存版本。 * fix(agentic): upgrade stored chart series without raw blob Serve v13 chart data through a lightweight v14 upgrader, including SQL and dump-mode paths, so preview functions do not decompress oversized raw metrics. 中文:通过轻量级升级逻辑将已存储的 v13 图表数据转换为 v14,并覆盖 SQL 与 dump 模式,避免预览环境解压超大原始指标导致函数内存耗尽。 * fix(agentic): safely upgrade recoverable v12 chart series Use non-overlapping rank time ranges to upgrade recoverable v12 rows while rejecting ambiguous engine-label collisions that require raw-data backfill. 中文:通过不重叠的 rank 时间范围安全升级可恢复的 v12 图表序列;若 engine 标签存在无法判定的冲突,则拒绝在线升级并保留原始数据回填路径。 * refactor(agentic): make chart series canonical Remove embedded chart-series versions, request-time legacy upgrades, and version-derived cache keys. Treat the normalized DB payload as canonical and use explicit backfills plus cache invalidation for extraction changes. 中文:将图表序列改为数据库中的规范化数据,移除 payload 内嵌版本、请求链路中的旧版本升级逻辑及版本派生缓存键。后续提取逻辑变更统一通过显式回填与缓存失效完成。
1 parent b9e84bc commit 7cfe0be

13 files changed

Lines changed: 213 additions & 144 deletions

docs/data-pipeline.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ AIPerf defines the `server_metrics_export.json` envelope, but labels such as wor
6868

6969
Adapters are selected from the benchmark's canonical framework, and per-worker series are only emitted for disaggregated configs with a recognized adapter. Unknown orchestrators and non-disaggregated configs retain their aggregate-only series; roles are never guessed from ports or metric names. The frontend only consumes the canonical source identity and never interprets orchestrator-native labels.
7070

71+
### Canonical Agentic Chart Series
72+
73+
`agentic_trace_replay.chart_series` is canonical materialized DB data, not a chain of application-versioned cache formats. The ingest path writes the current representation, API readers trust any non-null stored value, and the raw compressed server-metrics blob is only opened when the materialized column is missing.
74+
75+
When extraction behavior changes, use an explicit expand/migrate/contract rollout: deploy a reader compatible with the canonical shape, backfill every maintained Neon branch, purge the API/Blob cache through `/api/v1/invalidate`, and then remove temporary migration compatibility. The `--force` option on `db:backfill-chart-series` deliberately recomputes all rows when needed. Keeping migration state in the operational workflow instead of every JSONB payload prevents historical version branches from accumulating on the request path.
76+
7177
### Agentic Dataset Provenance
7278

7379
AIPerf exports public-dataset provenance in `metadata.dataset`, including the Hugging Face dataset ID. InferenceX preserves that object as `dataset` on each agentic aggregate benchmark row. During benchmark ingest, `ingest-ci-run.ts` derives the dashboard slug from `hf_dataset_name` (for example, `semianalysisai/cc-traces-weka-062126` becomes `cc-traces-weka-062126`) and upserts `run_datasets` for the workflow run.

packages/app/src/app/api/v1/agentic-cache-keys.test.ts

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
/**
2-
* Guards that every agentic blob-cache key is DERIVED from the version constant
3-
* that governs its payload — not a hand-written string. `blobSet` is write-once
4-
* and nothing purges the blob cache after a backfill, so an unversioned (or
5-
* hand-bumped) key would serve stale data forever after a payload-version bump.
6-
* Deriving the key from the constant means a future bump rolls the cache
7-
* namespace automatically; these tests fail loudly if a route drifts back to a
8-
* literal string.
2+
* Versioned derived payloads roll their cache namespace with their format.
3+
* chart_series is instead canonical DB data under a stable key; ingest and
4+
* backfill operations explicitly purge Blob storage through the invalidate API.
95
*/
106

117
import { describe, expect, it, vi } from 'vitest';
@@ -26,7 +22,6 @@ vi.mock('@/lib/api-cache', () => ({
2622
}));
2723

2824
import { STATS_VERSION } from '@semianalysisai/inferencex-db/queries/agentic-aggregates';
29-
import { CHART_SERIES_VERSION } from '@semianalysisai/inferencex-db/etl/compute-chart-series';
3025
import { REQUEST_TIMELINE_VERSION } from '@semianalysisai/inferencex-db/etl/compute-request-timeline';
3126

3227
import { CACHE_KEY_PREFIX as derivedAgenticMetricsKey } from './derived-agentic-metrics/route';
@@ -35,7 +30,7 @@ import { CACHE_KEY_PREFIX as requestTimelineKey } from './request-timeline/route
3530
import { CACHE_KEY_PREFIX as traceServerMetricsKey } from './trace-server-metrics/route';
3631
import { CACHE_KEY_PREFIX as traceHistogramsKey } from './trace-histograms/route';
3732

38-
describe('agentic blob-cache keys are version-derived', () => {
33+
describe('agentic blob-cache keys match their storage contracts', () => {
3934
it('derived-agentic-metrics key embeds STATS_VERSION', () => {
4035
expect(derivedAgenticMetricsKey).toBe(`derived-agentic-metrics-v${STATS_VERSION}`);
4136
});
@@ -48,20 +43,19 @@ describe('agentic blob-cache keys are version-derived', () => {
4843
expect(requestTimelineKey).toBe(`request-timeline-v${REQUEST_TIMELINE_VERSION}`);
4944
});
5045

51-
it('trace-server-metrics key embeds CHART_SERIES_VERSION', () => {
52-
expect(traceServerMetricsKey).toBe(`trace-server-metrics-v${CHART_SERIES_VERSION}`);
46+
it('trace-server-metrics uses the canonical chart-series namespace', () => {
47+
expect(traceServerMetricsKey).toBe('trace-server-metrics');
5348
});
5449

5550
it('trace-histograms key embeds REQUEST_TIMELINE_VERSION (its payload is read from request_timeline)', () => {
5651
expect(traceHistogramsKey).toBe(`trace-histograms-v${REQUEST_TIMELINE_VERSION}`);
5752
});
5853

59-
it('every key actually contains a version segment (no unversioned literals)', () => {
54+
it('keeps versioned namespaces for the remaining versioned derived payloads', () => {
6055
for (const key of [
6156
derivedAgenticMetricsKey,
6257
agenticAggregatesKey,
6358
requestTimelineKey,
64-
traceServerMetricsKey,
6559
traceHistogramsKey,
6660
]) {
6761
expect(key).toMatch(/-v\d+$/u);

packages/app/src/app/api/v1/trace-server-metrics/route.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { CHART_SERIES_VERSION } from '@semianalysisai/inferencex-db/etl/compute-chart-series';
21
import { JSON_MODE, getDb } from '@semianalysisai/inferencex-db/connection';
32
import * as jsonProvider from '@semianalysisai/inferencex-db/json-provider';
43
import {
@@ -12,12 +11,9 @@ import { idQueryRoute } from '../id-routes';
1211

1312
export const dynamic = 'force-dynamic';
1413

15-
// Key derived from CHART_SERIES_VERSION (governs the `chart_series` payload).
16-
// The blob cache is write-once with no post-backfill purge, so the
17-
// version-derived key is what rolls the namespace on a bump — a hand-written
18-
// string would serve stale blob-cached series forever.
19-
/** Version-derived blob-cache key namespace (exported for the key-derivation test). */
20-
export const CACHE_KEY_PREFIX = `trace-server-metrics-v${CHART_SERIES_VERSION}`;
14+
// chart_series is canonical DB data. Ingest/backfill operations purge Blob and
15+
// Next.js caches through /api/v1/invalidate after changing it.
16+
export const CACHE_KEY_PREFIX = 'trace-server-metrics';
2117

2218
const getCachedTraceServerMetrics = cachedQuery(
2319
(id: number): Promise<TraceServerMetrics | null> => {

packages/app/src/components/inference/agentic-point/phase-slice.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,26 @@ describe('sliceServerSeriesByPhase', () => {
151151
expect(out.series.kvCacheUsageByEngine[0]!.engineLabel).toBe('e0');
152152
});
153153

154+
it('drops per-engine series with no points in the selected phase', () => {
155+
const s = makeSeries([0, 1, 2, 3]);
156+
s.kvCacheUsageByEngine = [
157+
{ engineLabel: 'warmup-only', points: s.kvCacheUsage.filter((p) => p.t < 2) },
158+
{ engineLabel: 'profiling-only', points: s.kvCacheUsage.filter((p) => p.t >= 2) },
159+
];
160+
161+
const out = sliceServerSeriesByPhase(s, 'profiling', 2, 4);
162+
163+
expect(out.series.kvCacheUsageByEngine).toEqual([
164+
{
165+
engineLabel: 'profiling-only',
166+
points: [
167+
{ t: 0, value: 20 },
168+
{ t: 1, value: 30 },
169+
],
170+
},
171+
]);
172+
});
173+
154174
it('does not mutate the input series', () => {
155175
const s = makeSeries([0, 1, 2]);
156176
const before = s.kvCacheUsage.map((p) => p.t);

packages/app/src/components/inference/agentic-point/phase-slice.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,12 @@ export function sliceServerSeriesByPhase<S extends ServerSeriesLike>(
136136
decodeTps: sliceTs(series.decodeTps),
137137
prefixCacheHitsTps: sliceTs(series.prefixCacheHitsTps),
138138
hostKvCacheUsage: sliceTs(series.hostKvCacheUsage),
139-
kvCacheUsageByEngine: series.kvCacheUsageByEngine.map((e) => ({
140-
engineLabel: e.engineLabel,
141-
points: sliceTs(e.points),
142-
})),
139+
kvCacheUsageByEngine: series.kvCacheUsageByEngine
140+
.map((e) => ({
141+
engineLabel: e.engineLabel,
142+
points: sliceTs(e.points),
143+
}))
144+
.filter(({ points }) => points.length > 0),
143145
};
144146

145147
const durationS = phase === 'warmup' ? b : Math.max(1, fullDurationS - b);

packages/db/src/backfill-chart-series.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
/**
2-
* Backfill `agentic_trace_replay.chart_series` for rows that are missing it
3-
* or were computed by an older `CHART_SERIES_VERSION`.
2+
* Backfill `agentic_trace_replay.chart_series` for rows that are missing it.
43
*
54
* The ingest path now computes the time-series inline, but existing rows
6-
* (and rows whose computation logic has since changed) still need this
7-
* pass. Run after the agentic schema migration and any time `CHART_SERIES_VERSION`
8-
* bumps.
5+
* Existing rows whose extraction logic has changed can be deliberately
6+
* recomputed with `--force`; chart_series itself carries no format version.
97
*
108
* Strategy:
119
* - Stream rows one at a time (server_metrics_json_gz can decompress
@@ -23,7 +21,7 @@
2321
*/
2422

2523
import { hasNoSslFlag } from './cli-utils.js';
26-
import { CHART_SERIES_VERSION, computeChartSeries } from './etl/compute-chart-series.js';
24+
import { computeChartSeries } from './etl/compute-chart-series.js';
2725
import { createAdminSql } from './etl/db-utils.js';
2826
import {
2927
confirmProceed,
@@ -43,7 +41,6 @@ const sql = createAdminSql({
4341

4442
async function main(): Promise<void> {
4543
console.log('=== backfill-chart-series ===');
46-
console.log(` CHART_SERIES_VERSION = ${CHART_SERIES_VERSION}`);
4744
console.log(` force = ${flags.force}`);
4845
console.log(` limit = ${flags.limit ?? 'none'}`);
4946

@@ -64,10 +61,7 @@ async function main(): Promise<void> {
6461
select id
6562
from agentic_trace_replay
6663
where server_metrics_json_gz is not null
67-
and (
68-
chart_series is null
69-
or coalesce((chart_series->>'version')::int, -1) <> ${CHART_SERIES_VERSION}
70-
)
64+
and chart_series is null
7165
order by id
7266
${flags.limit ? sql`limit ${flags.limit}` : sql``}
7367
`;

packages/db/src/etl/compute-chart-series.test.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { gzipSync } from 'node:zlib';
22

33
import { describe, expect, it } from 'vitest';
44

5-
import { CHART_SERIES_VERSION, computeChartSeries } from './compute-chart-series.js';
5+
import { computeChartSeries } from './compute-chart-series.js';
66

77
/**
88
* Build a minimal server_metrics_json blob covering the metrics the chart
@@ -119,14 +119,27 @@ function buildDynamoSeries(
119119
};
120120
}
121121

122+
function buildPhaseKvSeries(startNs: number, baseValue: number) {
123+
return Array.from({ length: 8 }, (_, dpRank) => ({
124+
labels: { engine: '0', dp_rank: String(dpRank) },
125+
timeslices: [
126+
{
127+
start_ns: startNs,
128+
end_ns: startNs + 1e9,
129+
avg: baseValue + dpRank / 100,
130+
},
131+
],
132+
}));
133+
}
134+
122135
describe('computeChartSeries', () => {
123136
it('returns null when the blob is null', async () => {
124137
expect(await computeChartSeries(null)).toBeNull();
125138
});
126139

127-
it('returns the current CHART_SERIES_VERSION in the bundle', async () => {
140+
it('returns a canonical bundle without embedded version metadata', async () => {
128141
const series = await computeChartSeries(makeBlob());
129-
expect(series?.version).toBe(CHART_SERIES_VERSION);
142+
expect(series).not.toHaveProperty('version');
130143
});
131144

132145
it('extracts kvCacheUsage points with t=seconds-from-start', async () => {
@@ -181,6 +194,43 @@ describe('computeChartSeries', () => {
181194
]);
182195
});
183196

197+
it('coalesces each DP rank across warmup and profiling instead of duplicating it', async () => {
198+
const blob = gzipSync(
199+
Buffer.from(
200+
JSON.stringify({
201+
warmup_metrics: {
202+
'vllm:kv_cache_usage_perc': { series: buildPhaseKvSeries(0, 0.1) },
203+
},
204+
metrics: {
205+
'vllm:kv_cache_usage_perc': { series: buildPhaseKvSeries(10e9, 0.2) },
206+
},
207+
}),
208+
),
209+
);
210+
211+
const series = await computeChartSeries(blob);
212+
213+
expect(series?.kvCacheUsageByEngine).toHaveLength(8);
214+
expect(series?.kvCacheUsageByEngine.map(({ engineLabel }) => engineLabel)).toEqual([
215+
'0',
216+
'1',
217+
'2',
218+
'3',
219+
'4',
220+
'5',
221+
'6',
222+
'7',
223+
]);
224+
expect(series?.kvCacheUsageByEngine.every(({ points }) => points.length === 2)).toBe(true);
225+
expect(series?.kvCacheUsageByEngine[0]).toEqual({
226+
engineLabel: '0',
227+
points: [
228+
{ t: 0, value: 0.1 },
229+
{ t: 10, value: 0.2 },
230+
],
231+
});
232+
});
233+
184234
it('computes prefixCacheHitRate as hits.rate / queries.rate', async () => {
185235
const series = await computeChartSeries(makeBlob({ prefixHits: 80, prefixQueries: 100 }));
186236
expect(series?.prefixCacheHitRate).toEqual([{ t: 0, value: 0.8 }]);

0 commit comments

Comments
 (0)