返回 DeepSeek-Reasonix
uiPerf.ts
根目录 / desktop / frontend / src / lib / uiPerf.ts
1 import { app } from "./bridge";
2
3 // UI latency telemetry: turn-scoped, content-free counters and percentiles for
4 // the streaming render pipeline. Everything reported is a bounded (signal,
5 // bucket) pair — no message text, no timings tied to content — matching the
6 // existing desktop metrics privacy posture.
7 //
8 // The targets below are performance budgets, not hard standards; recalibrate
9 // them against the supported machine population before treating a breach as a
10 // regression gate.
11 export const UI_PERF_BUDGETS = {
12 bridgeEventsPerSec: 120, // Go→WebView stream event rate; ideally < 60
13 stateCommitsPerSec: 60, // stream store updates; at most display FPS
14 streamPaintP95Ms: 50, // token dispatch → next frame
15 frameP95Ms: 16.7,
16 slowFramePct: 1, // frames > 33ms
17 inputLatencyP95Ms: 100, // typing while streaming
18 markdownRenderP95Ms: 10,
19 longTasks: 0, // main-thread tasks > 50ms per turn
20 // Session-switch/history pipeline gates (Phase F). Not turn-scoped: these
21 // are enforced by the real-DOM harness in bench/ (REASONIX_BENCH_* env
22 // overrides), not by the per-turn signal path below.
23 sessionFirstPaintP95Ms: 100, // cold open → surface first paint
24 sessionInteractiveP95Ms: 300, // cold open → input enabled + first slice rendered
25 inpP95Ms: 200, // interaction-to-next-paint probes during switching
26 mainThreadTaskP95Ms: 50, // long-task P95 while switching
27 mainThreadTaskMaxMs: 500, // hard cap on any single main-thread task
28 markdownWorkerMaxParseMs: 3_000, // 500KiB markdown-heavy fixture, cold Worker parse
29 switchHeapGrowthMiB: 20, // 100× alternating-switch retained-heap growth over warmup baseline
30 } as const;
31
32 export interface UIPerfSummary {
33 turnMs: number;
34 bridgeEvents: number;
35 stateCommits: number;
36 streamPaintP95Ms?: number;
37 frameP95Ms?: number;
38 slowFramePct?: number;
39 inputLatencyP95Ms?: number;
40 markdownRenderP95Ms?: number;
41 longTasks: number;
42 domNodes?: number;
43 jsHeapMB?: number;
44 }
45
46 export function percentile(values: number[], p: number): number | undefined {
47 if (values.length === 0) return undefined;
48 const sorted = [...values].sort((a, b) => a - b);
49 const index = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1);
50 return sorted[Math.max(0, index)];
51 }
52
53 function rateBucket(perSec: number): string {
54 if (perSec < 30) return "lt30";
55 if (perSec < 60) return "lt60";
56 if (perSec < 120) return "lt120";
57 return "ge120";
58 }
59
60 function msBucket(ms: number, edges: number[]): string {
61 for (const edge of edges) if (ms < edge) return `lt${edge}`;
62 return `ge${edges[edges.length - 1]}`;
63 }
64
65 // uiPerfSignals maps a turn summary onto the closed (signal, bucket) set the
66 // Go side allowlists. Signals without data (feature-detection gaps, no
67 // samples) are omitted rather than reported as zero.
68 export function uiPerfSignals(s: UIPerfSummary): Record<string, string> {
69 const out: Record<string, string> = {};
70 const seconds = s.turnMs / 1000;
71 if (seconds > 1) {
72 out.ui_bridge_events_rate = rateBucket(s.bridgeEvents / seconds);
73 out.ui_state_commit_rate = rateBucket(s.stateCommits / seconds);
74 }
75 if (s.streamPaintP95Ms !== undefined) out.ui_stream_paint_p95 = msBucket(s.streamPaintP95Ms, [25, 50, 100]);
76 if (s.frameP95Ms !== undefined) out.ui_frame_p95 = msBucket(s.frameP95Ms, [17, 33]);
77 if (s.slowFramePct !== undefined) {
78 out.ui_slow_frames = s.slowFramePct === 0 ? "zero" : s.slowFramePct < 1 ? "lt1" : s.slowFramePct < 5 ? "lt5" : "ge5";
79 }
80 if (s.inputLatencyP95Ms !== undefined) out.ui_input_latency_p95 = msBucket(s.inputLatencyP95Ms, [50, 100]);
81 if (s.markdownRenderP95Ms !== undefined) out.ui_markdown_p95 = msBucket(s.markdownRenderP95Ms, [10, 50]);
82 out.ui_long_tasks = s.longTasks === 0 ? "zero" : s.longTasks < 3 ? "lt3" : "ge3";
83 if (s.domNodes !== undefined) {
84 out.ui_dom_nodes = s.domNodes < 5_000 ? "lt5k" : s.domNodes < 20_000 ? "lt20k" : "ge20k";
85 }
86 if (s.jsHeapMB !== undefined) {
87 out.ui_js_heap = s.jsHeapMB < 200 ? "lt200" : s.jsHeapMB < 500 ? "lt500" : "ge500";
88 }
89 return out;
90 }
91
92 interface PerfEnv {
93 now(): number;
94 raf(cb: (ts: number) => void): number;
95 caf(handle: number): void;
96 observe(type: string, cb: (durationsMs: number[]) => void): (() => void) | undefined;
97 domNodes(): number | undefined;
98 jsHeapMB(): number | undefined;
99 }
100
101 function defaultEnv(): PerfEnv {
102 return {
103 now: () => performance.now(),
104 raf: (cb) => requestAnimationFrame(cb),
105 caf: (handle) => cancelAnimationFrame(handle),
106 observe(type, cb) {
107 if (typeof PerformanceObserver === "undefined") return undefined;
108 try {
109 const observer = new PerformanceObserver((list) => {
110 const durations: number[] = [];
111 for (const entry of list.getEntries()) {
112 if (type === "measure" && !entry.name.startsWith("reasonix:markdown")) continue;
113 if (type === "event") {
114 const timing = entry as PerformanceEntry & { processingStart?: number };
115 durations.push((timing.processingStart ?? entry.startTime + entry.duration) - entry.startTime);
116 continue;
117 }
118 durations.push(entry.duration);
119 }
120 if (durations.length > 0) cb(durations);
121 });
122 observer.observe(type === "event" ? { type, durationThreshold: 16 } as PerformanceObserverInit : { type });
123 return () => observer.disconnect();
124 } catch {
125 return undefined;
126 }
127 },
128 domNodes: () => (typeof document === "undefined" ? undefined : document.getElementsByTagName("*").length),
129 jsHeapMB() {
130 const memory = (performance as Performance & { memory?: { usedJSHeapSize: number } }).memory;
131 return memory ? Math.round(memory.usedJSHeapSize / (1 << 20)) : undefined;
132 },
133 };
134 }
135
136 const MAX_SAMPLES = 512;
137
138 function push(samples: number[], value: number) {
139 if (samples.length < MAX_SAMPLES) samples.push(value);
140 }
141
142 // UIPerfTurnCollector samples one turn: a rAF loop measures frame durations
143 // and dispatch→frame latency while it runs; observers count long tasks and
144 // input/markdown timings. All sampling stops at finish().
145 export class UIPerfTurnCollector {
146 private env: PerfEnv;
147 private startedAt: number;
148 private bridgeEvents = 0;
149 private stateCommits = 0;
150 private longTasks = 0;
151 private frames: number[] = [];
152 private slowFrames = 0;
153 private streamPaint: number[] = [];
154 private inputLatency: number[] = [];
155 private markdown: number[] = [];
156 private pendingDispatchAt: number | null = null;
157 private lastFrameAt: number | null = null;
158 private frameHandle: number | null = null;
159 private disconnects: Array<() => void> = [];
160 private done = false;
161
162 constructor(env: PerfEnv = defaultEnv()) {
163 this.env = env;
164 this.startedAt = env.now();
165 const longTask = env.observe("longtask", (durations) => {
166 this.longTasks += durations.length;
167 });
168 if (longTask) this.disconnects.push(longTask);
169 const input = env.observe("event", (durations) => {
170 for (const d of durations) push(this.inputLatency, d);
171 });
172 if (input) this.disconnects.push(input);
173 const markdown = env.observe("measure", (durations) => {
174 for (const d of durations) push(this.markdown, d);
175 });
176 if (markdown) this.disconnects.push(markdown);
177 this.scheduleFrame();
178 }
179
180 private scheduleFrame() {
181 this.frameHandle = this.env.raf((ts) => {
182 if (this.done) return;
183 if (this.lastFrameAt !== null) {
184 const duration = ts - this.lastFrameAt;
185 push(this.frames, duration);
186 if (duration > 33.4) this.slowFrames += 1;
187 }
188 this.lastFrameAt = ts;
189 if (this.pendingDispatchAt !== null) {
190 push(this.streamPaint, Math.max(0, ts - this.pendingDispatchAt));
191 this.pendingDispatchAt = null;
192 }
193 this.scheduleFrame();
194 });
195 }
196
197 noteBridgeEvent() {
198 this.bridgeEvents += 1;
199 }
200
201 noteStateCommit() {
202 this.stateCommits += 1;
203 }
204
205 noteStreamDispatch() {
206 if (this.pendingDispatchAt === null) this.pendingDispatchAt = this.env.now();
207 }
208
209 finish(): UIPerfSummary {
210 this.done = true;
211 if (this.frameHandle !== null) this.env.caf(this.frameHandle);
212 for (const disconnect of this.disconnects) disconnect();
213 this.disconnects = [];
214 return {
215 turnMs: this.env.now() - this.startedAt,
216 bridgeEvents: this.bridgeEvents,
217 stateCommits: this.stateCommits,
218 streamPaintP95Ms: percentile(this.streamPaint, 95),
219 frameP95Ms: percentile(this.frames, 95),
220 slowFramePct: this.frames.length > 0 ? (this.slowFrames / this.frames.length) * 100 : undefined,
221 inputLatencyP95Ms: percentile(this.inputLatency, 95),
222 markdownRenderP95Ms: percentile(this.markdown, 95),
223 longTasks: this.longTasks,
224 domNodes: this.env.domNodes(),
225 jsHeapMB: this.env.jsHeapMB(),
226 };
227 }
228 }
229
230 // uiPerfTracker is the app-wide instance; useController feeds it wire events,
231 // state commits, and stream dispatches.
232 export const uiPerfTracker: UIPerfTracker = createUIPerfTracker((signals) => {
233 // Older shells and lightweight browser/test bridge doubles may not expose
234 // the optional telemetry endpoint. Diagnostics must never turn a completed
235 // turn into a rejected event handler in those environments.
236 if (typeof app.RecordUIPerf !== "function") return;
237 try {
238 void Promise.resolve(app.RecordUIPerf(signals)).catch(() => {});
239 } catch {
240 // A partially initialized desktop binding can still throw synchronously.
241 }
242 });
243
244 export interface UIPerfTracker {
245 onWireEvent(tabId: string, kind: string): void;
246 onStateCommit(): void;
247 onStreamDispatch(): void;
248 }
249
250 // createUIPerfTracker owns per-tab collectors keyed on turn lifecycle events
251 // and reports each finished turn's bucketed signals through `report`.
252 export function createUIPerfTracker(
253 report: (signals: Record<string, string>) => void,
254 makeCollector: () => UIPerfTurnCollector = () => new UIPerfTurnCollector(),
255 ): UIPerfTracker {
256 const collectors = new Map<string, UIPerfTurnCollector>();
257 return {
258 onWireEvent(tabId, kind) {
259 if (kind === "turn_started" && !collectors.has(tabId)) {
260 collectors.set(tabId, makeCollector());
261 return;
262 }
263 const collector = collectors.get(tabId);
264 if (!collector) return;
265 collector.noteBridgeEvent();
266 if (kind === "turn_done") {
267 collectors.delete(tabId);
268 const signals = uiPerfSignals(collector.finish());
269 if (Object.keys(signals).length > 0) report(signals);
270 }
271 },
272 onStateCommit() {
273 for (const collector of collectors.values()) collector.noteStateCommit();
274 },
275 onStreamDispatch() {
276 for (const collector of collectors.values()) collector.noteStreamDispatch();
277 },
278 };
279 }
280
280 lines TYPESCRIPT