返回 DeepSeek-Reasonix
history-performance-benchmark.tsx
根目录 / desktop / frontend / src / __tests__ / history-performance-benchmark.tsx
1 // Run:
2 // corepack pnpm --dir desktop/frontend exec tsx src/__tests__/history-performance-benchmark.tsx
3 //
4 // Synthetic, privacy-safe benchmark for long restored histories. It logs counts,
5 // byte lengths, and elapsed times only; it never uses real conversation content.
6
7 import {
8 noteActivationRequested, noteActivationSettled, noteActivationStarted,
9 noteResumeHistoryPage, noteTranscriptRowCounts, resetSessionDiagnostics, sessionPipelineDiagnostics,
10 } from "../lib/sessionDiagnostics";
11 import { historyMessagesToItems, initialState, reducer, type Item } from "../lib/useController";
12 import type { HistoryMessage } from "../lib/types";
13 import { ChatSource } from "../lib/chatViewSource";
14
15 type BenchCase = {
16 name: string;
17 turns: number;
18 toolsPerTurn: number;
19 outputSize: number;
20 archived: boolean;
21 };
22
23 type BenchResult = {
24 name: string;
25 messages: number;
26 items: number;
27 jsonBytes: number;
28 itemStringBytes: number;
29 convertMs: number;
30 reducerMs: number;
31 transcriptComputeMs: number;
32 turnGroups: number;
33 projectionMs: number;
34 readMs: number;
35 projectedNodes: number;
36 };
37
38 const cases: BenchCase[] = [
39 { name: "200-turns-full-10KB", turns: 200, toolsPerTurn: 1, outputSize: 10 * 1024, archived: false },
40 { name: "200-turns-archived-10KB", turns: 200, toolsPerTurn: 1, outputSize: 10 * 1024, archived: true },
41 { name: "1000-turns-full-1KB", turns: 1000, toolsPerTurn: 1, outputSize: 1024, archived: false },
42 { name: "1000-turns-archived-1KB", turns: 1000, toolsPerTurn: 1, outputSize: 1024, archived: true },
43 { name: "1000-turns-archived-3-tools", turns: 1000, toolsPerTurn: 3, outputSize: 1024, archived: true },
44 { name: "5000-turns-archived-1-tool", turns: 5000, toolsPerTurn: 1, outputSize: 1024, archived: true },
45 { name: "10000-turns-archived-1-tool", turns: 10000, toolsPerTurn: 1, outputSize: 1024, archived: true },
46 ];
47
48 function syntheticHistory(c: BenchCase): HistoryMessage[] {
49 const messages: any[] = [];
50 const output = "x".repeat(c.outputSize);
51 for (let turn = 0; turn < c.turns; turn += 1) {
52 messages.push({ role: "user", content: `prompt ${turn}` });
53 const toolCalls: any[] = [];
54 for (let tool = 0; tool < c.toolsPerTurn; tool += 1) {
55 const id = `call_${turn}_${tool}`;
56 toolCalls.push({
57 id,
58 name: "bash",
59 arguments: c.archived ? "" : `{"command":"synthetic ${turn} ${tool}"}`,
60 argumentsArchived: c.archived || undefined,
61 subject: c.archived ? `synthetic ${turn} ${tool}` : undefined,
62 summary: c.archived ? "1 line" : undefined,
63 });
64 }
65 messages.push({ role: "assistant", content: `answer ${turn}`, toolCalls });
66 for (let tool = 0; tool < c.toolsPerTurn; tool += 1) {
67 const id = `call_${turn}_${tool}`;
68 messages.push({
69 role: "tool",
70 toolCallId: id,
71 toolName: "bash",
72 content: c.archived ? "" : output,
73 toolResultArchived: c.archived || undefined,
74 });
75 }
76 }
77 return messages as HistoryMessage[];
78 }
79
80 function itemStringBytes(items: Item[]): number {
81 let total = 0;
82 for (const item of items) {
83 if (item.kind === "user") total += item.text.length;
84 if (item.kind === "assistant") total += item.text.length + item.reasoning.length;
85 if (item.kind === "tool") total += item.args.length + (item.output?.length ?? 0) + (item.error?.length ?? 0);
86 }
87 return total;
88 }
89
90 function time<T>(fn: () => T): { value: T; ms: number } {
91 const start = performance.now();
92 const value = fn();
93 return { value, ms: performance.now() - start };
94 }
95
96 function runCase(c: BenchCase): BenchResult {
97 const messages = syntheticHistory(c);
98 const jsonBytes = JSON.stringify(messages).length;
99 const converted = time(() => historyMessagesToItems(messages, "perf"));
100 const reduced = time(() => reducer(initialState, { type: "history", messages }));
101 const items = converted.value.items;
102 const source = new ChatSource(c.name);
103 const projection = time(() => source.update({ items, running: false, hydrating: false, hasOlder: false, loadingOlder: false }));
104 const transcript = time(() => source.getOrderSnapshot().filter(key => source.getNodeSnapshot(key)?.kind === "user"));
105 const range = time(() => source.getOrderSnapshot().map(key => source.getNodeSnapshot(key)));
106 const nodeCount = range.value.length;
107 source.dispose();
108 return {
109 name: c.name,
110 messages: messages.length,
111 items: items.length,
112 jsonBytes,
113 itemStringBytes: itemStringBytes(reduced.value.items),
114 convertMs: converted.ms,
115 reducerMs: reduced.ms,
116 transcriptComputeMs: transcript.ms,
117 turnGroups: transcript.value.length,
118 projectionMs: projection.ms,
119 readMs: range.ms,
120 projectedNodes: nodeCount,
121 };
122 }
123
124 function printResult(r: BenchResult): void {
125 process.stdout.write([
126 r.name,
127 `messages=${r.messages}`,
128 `items=${r.items}`,
129 `jsonBytes=${r.jsonBytes}`,
130 `itemStringBytes=${r.itemStringBytes}`,
131 `convertMs=${r.convertMs.toFixed(2)}`,
132 `reducerMs=${r.reducerMs.toFixed(2)}`,
133 `transcriptComputeMs=${r.transcriptComputeMs.toFixed(2)}`,
134 `turnGroups=${r.turnGroups}`,
135 `projectionMs=${r.projectionMs.toFixed(2)}`,
136 `readMs=${r.readMs.toFixed(2)}`,
137 `projectedNodes=${r.projectedNodes}`,
138 ].join(" ") + "\n");
139 }
140
141 console.log("\nhistory performance benchmark");
142 const results = cases.map(runCase);
143 for (const result of results) {
144 printResult(result);
145 }
146
147 const failures: string[] = [];
148 for (let index = 0; index < results.length; index += 1) {
149 const result = results[index];
150 const input = cases[index];
151 const expectedMessages = input.turns * (2 + input.toolsPerTurn);
152 if (result.messages !== expectedMessages) failures.push(`${result.name}: unexpected message count`);
153 if (result.turnGroups !== input.turns) failures.push(`${result.name}: unexpected turn-group count`);
154 if (result.convertMs > 1_000 || result.reducerMs > 1_000 || result.transcriptComputeMs > 1_000 || result.projectionMs > 1_000 || result.readMs > 1_000) {
155 failures.push(`${result.name}: exceeded 1s responsiveness ceiling`);
156 }
157 if (result.projectedNodes < input.turns * 3) failures.push(`${result.name}: missing loaded history nodes`);
158 }
159
160 const full10KB = results.find((result) => result.name === "200-turns-full-10KB");
161 const archived10KB = results.find((result) => result.name === "200-turns-archived-10KB");
162 if (!full10KB || full10KB.itemStringBytes * 10 >= full10KB.jsonBytes) {
163 failures.push("restored full tool results retained too much source text");
164 }
165 if (!archived10KB || archived10KB.itemStringBytes * 5 >= archived10KB.jsonBytes) {
166 failures.push("restored archived tool results retained too much source text");
167 }
168
169 // ── Session-switch diagnostics ───────────────────────────────────────────────
170 // These fixtures verify diagnostic interpretation, not physical disk reads.
171 // The desktop switch tests exercise the real load and snapshot entry points.
172 // Missing evidence must remain unknown instead of passing a zero-repeat gate.
173 const switchMessages = syntheticHistory(cases[1]);
174 const switchPhases = {
175 resolveMs: 1, loadMs: 12, rebindMs: 30, historyMs: 9, totalMs: 52,
176 loadedMessages: switchMessages.length, loadedBytes: 65_536,
177 historyEntries: switchMessages.length, durableReads: 1, outcome: "ok",
178 };
179
180 resetSessionDiagnostics();
181 noteActivationRequested("switch-ticket");
182 noteActivationStarted("switch-ticket", "tab-switch");
183 noteActivationSettled("switch-ticket", "ready");
184 noteResumeHistoryPage({ messages: switchMessages, switch: switchPhases }, switchPhases.totalMs);
185 noteTranscriptRowCounts(40, switchMessages.length);
186 const pipelined = sessionPipelineDiagnostics();
187 process.stdout.write(`\n${JSON.stringify({
188 activation: pipelined.activation,
189 history: pipelined.history,
190 mountedRows: pipelined.mountedRows,
191 duplicateLoadCount: pipelined.duplicateLoadCount,
192 }, null, 2)}\n`);
193
194 if (pipelined.duplicateLoadCount !== 0) {
195 failures.push(`switch performed duplicate durable loads: ${pipelined.duplicateLoadCount}`);
196 }
197 if (pipelined.history?.source !== "resume-loaded") {
198 failures.push(`switch history source = ${pipelined.history?.source}, want resume-loaded`);
199 }
200 if (pipelined.history?.entries !== switchMessages.length) {
201 failures.push(`switch history entries = ${pipelined.history?.entries}, want ${switchMessages.length}`);
202 }
203 if (pipelined.activation?.totalMs === undefined || pipelined.activation.startingToReadyMs === undefined) {
204 failures.push("activation phases were not derived from the ticket");
205 }
206 if (pipelined.mountedRows?.mounted !== 40 || pipelined.mountedRows.total !== switchMessages.length) {
207 failures.push("mounted row counts were not reported");
208 }
209 // The gate has to be able to fail, or it proves nothing: a switch that rebuilt
210 // its first screen from a second read reports two.
211 noteResumeHistoryPage({ messages: switchMessages, switch: { ...switchPhases, durableReads: 2 } }, switchPhases.totalMs);
212 if (sessionPipelineDiagnostics().duplicateLoadCount !== 1) {
213 failures.push("duplicate-load gate did not observe a second durable read");
214 }
215 resetSessionDiagnostics();
216 if (sessionPipelineDiagnostics().duplicateLoadCount !== null) {
217 failures.push("missing switch evidence must remain unknown");
218 }
219 noteResumeHistoryPage({ messages: switchMessages, switch: switchPhases }, 60, 8);
220 if (sessionPipelineDiagnostics().resumeHistory?.source !== "transcript-snapshot" || sessionPipelineDiagnostics().resumeSnapshotMs !== 8) failures.push("modern snapshot timing is missing");
221 noteResumeHistoryPage({ messages: [] }, 1, 1);
222 if (sessionPipelineDiagnostics().duplicateLoadCount !== null || sessionPipelineDiagnostics().resumeSwitch) failures.push("an uninstrumented response retained old switch evidence");
223
224 if (failures.length > 0) {
225 for (const failure of failures) process.stderr.write(`FAIL ${failure}\n`);
226 process.exit(1);
227 }
228 process.stdout.write("PASS long-history performance contracts\n");
229
229 lines Plain Text