Skip to content

Commit f4346d6

Browse files
authored
fix(db): chunk trace-replay uploads (#570)
Upload large agentic trace values in 4 MiB pieces and assemble them atomically in PostgreSQL. Extend the workflow timeout so recovered sweeps retain enough headroom. 中文:将大型 agentic trace 数据拆分为 4 MiB 分块上传,并在 PostgreSQL 中以事务方式原子组装。同时延长工作流超时时间,为恢复完整 sweep 预留足够时间。
1 parent dbf8fd9 commit f4346d6

3 files changed

Lines changed: 240 additions & 80 deletions

File tree

.github/workflows/ingest-agentic-results.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,9 @@ jobs:
9494
name: Ingest agentic benchmark results
9595
# Blob-heavy: uploading trace-replay sidecars for a ~20-point sweep takes
9696
# far longer than a fixed-seq-len ingest.
97-
timeout-minutes: 60
97+
# High-concurrency rows also precompute request timelines from GiB-scale
98+
# artifacts, so retain enough headroom for a complete recovered sweep.
99+
timeout-minutes: 180
98100
runs-on: blacksmith-4vcpu-ubuntu-2404
99101
permissions:
100102
contents: read
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type postgres from 'postgres';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import {
5+
TRACE_REPLAY_UPLOAD_CHUNK_BYTES,
6+
uploadTraceReplayPayloadChunks,
7+
} from './trace-replay-ingest';
8+
9+
interface SqlCall {
10+
text: string;
11+
values: unknown[];
12+
}
13+
14+
function mockTransactionSql(): { sql: postgres.TransactionSql; calls: SqlCall[] } {
15+
const calls: SqlCall[] = [];
16+
const sql = ((strings: TemplateStringsArray, ...values: unknown[]) => {
17+
calls.push({ text: strings.join('?'), values });
18+
return Promise.resolve([]);
19+
}) as unknown as postgres.TransactionSql;
20+
return { sql, calls };
21+
}
22+
23+
describe('uploadTraceReplayPayloadChunks', () => {
24+
it('bounds every Bind payload for the measured 90 MiB staging row', async () => {
25+
// Exact payload sizes from InferenceX run 29181694248, item 8/9.
26+
const measuredPayloads = [
27+
['profile_export_jsonl_gz', Buffer.alloc(22_992_290)],
28+
['server_metrics_json_gz', Buffer.alloc(49_891_135)],
29+
['request_timeline', Buffer.alloc(22_146_655)],
30+
] as const;
31+
const { sql, calls } = mockTransactionSql();
32+
33+
let expectedParts = 0;
34+
for (const [field, payload] of measuredPayloads) {
35+
const parts = await uploadTraceReplayPayloadChunks(sql, field, payload);
36+
const expected = Math.ceil(payload.length / TRACE_REPLAY_UPLOAD_CHUNK_BYTES);
37+
expect(parts).toBe(expected);
38+
expectedParts += expected;
39+
}
40+
41+
expect(calls).toHaveLength(expectedParts);
42+
expect(calls.every((call) => call.text.includes('trace_replay_upload_parts'))).toBe(true);
43+
const chunks = calls.flatMap((call) => call.values.filter(Buffer.isBuffer));
44+
expect(chunks).toHaveLength(expectedParts);
45+
expect(Math.max(...chunks.map((chunk) => chunk.length))).toBe(TRACE_REPLAY_UPLOAD_CHUNK_BYTES);
46+
expect(chunks.reduce((total, chunk) => total + chunk.length, 0)).toBe(
47+
measuredPayloads.reduce((total, [, payload]) => total + payload.length, 0),
48+
);
49+
});
50+
51+
it('does not issue a query for a missing payload', async () => {
52+
const { sql, calls } = mockTransactionSql();
53+
54+
await expect(uploadTraceReplayPayloadChunks(sql, 'chart_series', null)).resolves.toBe(0);
55+
expect(calls).toHaveLength(0);
56+
});
57+
});

packages/db/src/etl/trace-replay-ingest.ts

Lines changed: 180 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,21 @@ import type { ServerMetricsContext } from './server-metrics-adapters';
1919

2020
type Sql = ReturnType<typeof postgres>;
2121

22+
/**
23+
* Keep each postgres.js Bind message comfortably below the large payload that
24+
* can stall through Neon's proxy. The final row is assembled server-side in a
25+
* transaction, so callers still get all-or-nothing trace persistence.
26+
*/
27+
export const TRACE_REPLAY_UPLOAD_CHUNK_BYTES = 4 * 1024 * 1024;
28+
29+
type TraceReplayUploadField =
30+
| 'profile_export_jsonl_gz'
31+
| 'server_metrics_csv'
32+
| 'server_metrics_json_gz'
33+
| 'aggregate_stats'
34+
| 'chart_series'
35+
| 'request_timeline';
36+
2237
export interface TraceReplayIngestOptions {
2338
metricsContext?: ServerMetricsContext;
2439
progressLabel?: string;
@@ -38,6 +53,34 @@ function elapsed(startMs: number): string {
3853
return `${((Date.now() - startMs) / 1000).toFixed(1)}s`;
3954
}
4055

56+
function jsonBuffer(value: unknown | null): Buffer | null {
57+
if (value === null) return null;
58+
return Buffer.from(JSON.stringify(structuredClone(value)), 'utf8');
59+
}
60+
61+
/**
62+
* Upload one trace-replay value as bounded binary parameters. The caller must
63+
* create `pg_temp.trace_replay_upload_parts` in the same transaction first.
64+
*/
65+
export async function uploadTraceReplayPayloadChunks(
66+
sql: postgres.TransactionSql,
67+
field: TraceReplayUploadField,
68+
payload: Buffer | null,
69+
): Promise<number> {
70+
if (!payload) return 0;
71+
72+
let part = 0;
73+
for (let offset = 0; offset < payload.length; offset += TRACE_REPLAY_UPLOAD_CHUNK_BYTES) {
74+
const chunk = payload.subarray(offset, offset + TRACE_REPLAY_UPLOAD_CHUNK_BYTES);
75+
await sql`
76+
insert into pg_temp.trace_replay_upload_parts (field, part, data)
77+
values (${field}, ${part}, ${chunk})
78+
`;
79+
part += 1;
80+
}
81+
return part;
82+
}
83+
4184
/**
4285
* Persist the per-point trace files and link them to `benchmarkResultIds`.
4386
*
@@ -119,86 +162,144 @@ export async function insertTraceReplay(
119162
`timeline_requests=${requestTimeline?.requests.length ?? 0} (${elapsed(computeStart)})`,
120163
);
121164

165+
const aggregateStatsJson = jsonBuffer(aggregateStats);
166+
const chartSeriesJson = jsonBuffer(chartSeries);
167+
const requestTimelineJson = jsonBuffer(requestTimeline);
168+
122169
const insertStart = Date.now();
123-
log('inserting trace_replay blob row');
124-
const [{ id: traceReplayId }] = await sql<{ id: number }[]>`
125-
insert into agentic_trace_replay (
126-
profile_export_jsonl_gz,
127-
profile_export_uncompressed_size,
128-
server_metrics_csv,
129-
server_metrics_csv_size,
130-
server_metrics_json_gz,
131-
server_metrics_json_uncompressed_size,
132-
aggregate_stats,
133-
chart_series,
134-
request_timeline
135-
)
136-
values (
137-
${profileGz},
138-
${profileSize},
139-
${serverMetricsCsv},
140-
${csvSize},
141-
${metricsJsonGz},
142-
${metricsJsonSize},
143-
${sql.json(structuredClone(aggregateStats) as unknown as Parameters<typeof sql.json>[0])},
144-
${chartSeries === null ? null : sql.json(structuredClone(chartSeries) as unknown as Parameters<typeof sql.json>[0])},
145-
${requestTimeline === null ? null : sql.json(structuredClone(requestTimeline) as unknown as Parameters<typeof sql.json>[0])}
146-
)
147-
returning id
148-
`;
149-
log(`inserted trace_replay_id=${traceReplayId} (${elapsed(insertStart)})`);
150-
151-
const updateStart = Date.now();
152-
log(`linking trace_replay_id=${traceReplayId} to ${unlinked.length} benchmark row(s)`);
153-
await sql`
154-
update benchmark_results
155-
set trace_replay_id = ${traceReplayId}
156-
where id = any(${sql.array(unlinked.map((r) => r.id))}::bigint[])
157-
`;
158-
log(`linked benchmark rows (${elapsed(updateStart)})`);
159-
160-
// Derive lifetime GPU + CPU cache hit rates from chart_series. SGLang
161-
// runs don't populate these in the harness JSON; vLLM runs do but only
162-
// for GPU. We always recompute to keep the derivation consistent with
163-
// what the detail-page charts plot — overwriting any pre-existing value.
164-
//
165-
// Source label naming differs by framework / cache topology:
166-
// SGLang hicache: 'cache hit (HBM)' + 'cache hit (CPU offload)'
167-
// SGLang older: 'cache hit' (no tier breakdown)
168-
// vLLM LMCache: 'local_cache_hit' + 'external_kv_transfer' (+ 'local_compute' for miss)
169-
// vLLM single: falls back to prefixCacheHitsTps total (= local cache only)
170-
if (chartSeries && chartSeries.prefillTps.length > 0) {
171-
const sumPrompts = chartSeries.prefillTps.reduce((s, p) => s + p.value, 0);
172-
if (sumPrompts > 0) {
173-
const sumOf = (name: string): number =>
174-
(chartSeries.promptTokensBySource[name] ?? []).reduce((s, p) => s + p.value, 0);
175-
// CPU-offload hits: SGLang hicache + vLLM LMCache external transfer.
176-
const cpuHits = sumOf('cache hit (CPU offload)') + sumOf('external_kv_transfer');
177-
// GPU/HBM hits from source breakdown, summed across known aliases.
178-
const hbmFromBreakdown =
179-
sumOf('cache hit (HBM)') + sumOf('cache hit') + sumOf('local_cache_hit');
180-
// If the source breakdown has any GPU entry, use it. Otherwise fall back
181-
// to total prefixCacheHitsTps sum (single-source vLLM path with no
182-
// by_source metric — equals the lone cache counter's lifetime).
183-
const gpuHits =
184-
hbmFromBreakdown > 0
185-
? hbmFromBreakdown
186-
: chartSeries.prefixCacheHitsTps.reduce((s, p) => s + p.value, 0);
187-
const gpuRate = gpuHits / sumPrompts;
188-
const cpuRate = cpuHits > 0 ? cpuHits / sumPrompts : null;
189-
await sql`
190-
update benchmark_results
191-
set metrics = jsonb_set(
192-
case when ${cpuRate}::numeric is not null
193-
then jsonb_set(metrics, '{server_cpu_cache_hit_rate}', to_jsonb(${cpuRate}::numeric))
194-
else metrics
195-
end,
196-
'{server_gpu_cache_hit_rate}',
197-
to_jsonb(${gpuRate}::numeric)
170+
log(`uploading trace_replay payloads in ${formatBytes(TRACE_REPLAY_UPLOAD_CHUNK_BYTES)} chunks`);
171+
await sql.begin(async (tx) => {
172+
await tx`
173+
create temporary table trace_replay_upload_parts (
174+
field text not null,
175+
part integer not null,
176+
data bytea not null,
177+
primary key (field, part)
178+
) on commit drop
179+
`;
180+
181+
const payloads: [TraceReplayUploadField, Buffer | null][] = [
182+
['profile_export_jsonl_gz', profileGz],
183+
['server_metrics_csv', serverMetricsCsv],
184+
['server_metrics_json_gz', metricsJsonGz],
185+
['aggregate_stats', aggregateStatsJson],
186+
['chart_series', chartSeriesJson],
187+
['request_timeline', requestTimelineJson],
188+
];
189+
for (const [field, payload] of payloads) {
190+
const uploadStart = Date.now();
191+
const parts = await uploadTraceReplayPayloadChunks(tx, field, payload);
192+
log(
193+
`uploaded ${field}=${formatBytes(payload?.length)} in ${parts} part(s) ` +
194+
`(${elapsed(uploadStart)})`,
195+
);
196+
}
197+
198+
log('assembling trace_replay blob row');
199+
const [{ id: traceReplayId }] = await tx<{ id: number }[]>`
200+
insert into agentic_trace_replay (
201+
profile_export_jsonl_gz,
202+
profile_export_uncompressed_size,
203+
server_metrics_csv,
204+
server_metrics_csv_size,
205+
server_metrics_json_gz,
206+
server_metrics_json_uncompressed_size,
207+
aggregate_stats,
208+
chart_series,
209+
request_timeline
210+
)
211+
values (
212+
(
213+
select string_agg(data, ''::bytea order by part)
214+
from pg_temp.trace_replay_upload_parts
215+
where field = 'profile_export_jsonl_gz'
216+
),
217+
${profileSize},
218+
(
219+
select string_agg(data, ''::bytea order by part)
220+
from pg_temp.trace_replay_upload_parts
221+
where field = 'server_metrics_csv'
222+
),
223+
${csvSize},
224+
(
225+
select string_agg(data, ''::bytea order by part)
226+
from pg_temp.trace_replay_upload_parts
227+
where field = 'server_metrics_json_gz'
228+
),
229+
${metricsJsonSize},
230+
(
231+
select convert_from(string_agg(data, ''::bytea order by part), 'UTF8')::jsonb
232+
from pg_temp.trace_replay_upload_parts
233+
where field = 'aggregate_stats'
234+
),
235+
(
236+
select convert_from(string_agg(data, ''::bytea order by part), 'UTF8')::jsonb
237+
from pg_temp.trace_replay_upload_parts
238+
where field = 'chart_series'
239+
),
240+
(
241+
select convert_from(string_agg(data, ''::bytea order by part), 'UTF8')::jsonb
242+
from pg_temp.trace_replay_upload_parts
243+
where field = 'request_timeline'
198244
)
199-
where id = any(${sql.array(unlinked.map((r) => r.id))}::bigint[])
200-
`;
201-
log('updated cache-hit metrics from chart series');
245+
)
246+
returning id
247+
`;
248+
log(`assembled trace_replay_id=${traceReplayId}`);
249+
250+
const updateStart = Date.now();
251+
log(`linking trace_replay_id=${traceReplayId} to ${unlinked.length} benchmark row(s)`);
252+
await tx`
253+
update benchmark_results
254+
set trace_replay_id = ${traceReplayId}
255+
where id = any(${tx.array(unlinked.map((r) => r.id))}::bigint[])
256+
`;
257+
log(`linked benchmark rows (${elapsed(updateStart)})`);
258+
259+
// Derive lifetime GPU + CPU cache hit rates from chart_series. SGLang
260+
// runs don't populate these in the harness JSON; vLLM runs do but only
261+
// for GPU. We always recompute to keep the derivation consistent with
262+
// what the detail-page charts plot — overwriting any pre-existing value.
263+
//
264+
// Source label naming differs by framework / cache topology:
265+
// SGLang hicache: 'cache hit (HBM)' + 'cache hit (CPU offload)'
266+
// SGLang older: 'cache hit' (no tier breakdown)
267+
// vLLM LMCache: 'local_cache_hit' + 'external_kv_transfer' (+ 'local_compute' for miss)
268+
// vLLM single: falls back to prefixCacheHitsTps total (= local cache only)
269+
if (chartSeries && chartSeries.prefillTps.length > 0) {
270+
const sumPrompts = chartSeries.prefillTps.reduce((s, p) => s + p.value, 0);
271+
if (sumPrompts > 0) {
272+
const sumOf = (name: string): number =>
273+
(chartSeries.promptTokensBySource[name] ?? []).reduce((s, p) => s + p.value, 0);
274+
// CPU-offload hits: SGLang hicache + vLLM LMCache external transfer.
275+
const cpuHits = sumOf('cache hit (CPU offload)') + sumOf('external_kv_transfer');
276+
// GPU/HBM hits from source breakdown, summed across known aliases.
277+
const hbmFromBreakdown =
278+
sumOf('cache hit (HBM)') + sumOf('cache hit') + sumOf('local_cache_hit');
279+
// If the source breakdown has any GPU entry, use it. Otherwise fall back
280+
// to total prefixCacheHitsTps sum (single-source vLLM path with no
281+
// by_source metric — equals the lone cache counter's lifetime).
282+
const gpuHits =
283+
hbmFromBreakdown > 0
284+
? hbmFromBreakdown
285+
: chartSeries.prefixCacheHitsTps.reduce((s, p) => s + p.value, 0);
286+
const gpuRate = gpuHits / sumPrompts;
287+
const cpuRate = cpuHits > 0 ? cpuHits / sumPrompts : null;
288+
await tx`
289+
update benchmark_results
290+
set metrics = jsonb_set(
291+
case when ${cpuRate}::numeric is not null
292+
then jsonb_set(metrics, '{server_cpu_cache_hit_rate}', to_jsonb(${cpuRate}::numeric))
293+
else metrics
294+
end,
295+
'{server_gpu_cache_hit_rate}',
296+
to_jsonb(${gpuRate}::numeric)
297+
)
298+
where id = any(${tx.array(unlinked.map((r) => r.id))}::bigint[])
299+
`;
300+
log('updated cache-hit metrics from chart series');
301+
}
202302
}
203-
}
303+
});
304+
log(`inserted trace_replay payload (${elapsed(insertStart)})`);
204305
}

0 commit comments

Comments
 (0)