perf: reduce streaming render cost and refresh activity page on demand
- Segment streaming Markdown so completed blocks are reused and only the growing tail is re-parsed; fall back to whole-document parsing for document-wide constructs - Memoize conversation sidebar rows and cache per-conversation derived activity, ordering and pending approvals - Activity page loads on entry, refreshes every 15 s while visible, and has a single header refresh button - Include pending settings, model download source, device sharing and knowledge gateway changes in the working tree - Add Electron streaming input benchmark and regression tests
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import assert from 'node:assert/strict'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
import { AgentEventBuffer } from '../../src/main/agent-event-buffer'
|
||||
import { longAnswerCorpus } from './streaming-markdown-corpus'
|
||||
|
||||
const directory = process.env.GOODBUDDY_STREAMING_INPUT_DIRECTORY!
|
||||
const epoch = () => performance.timeOrigin + performance.now()
|
||||
const duration = 5_000
|
||||
const chunkPeriod = 8
|
||||
const inputPeriod = 71
|
||||
// End on a complete section and add math, rather than truncating a code fence/table.
|
||||
const sectionEnd = longAnswerCorpus.indexOf('## Deployment review', 22_000)
|
||||
const answer = longAnswerCorpus.slice(0, sectionEnd) + '\nInline \\(x^2 + y^2\\) and display math:\n\n\\[E = mc^2\\]\n'
|
||||
const chunkCount = duration / chunkPeriod
|
||||
const chunks = Array.from({ length: chunkCount }, (_, index) => answer.slice(
|
||||
Math.floor(answer.length * index / chunkCount), Math.floor(answer.length * (index + 1) / chunkCount)))
|
||||
const inputOffsets = Array.from({ length: Math.floor((duration - 200) / inputPeriod) }, (_, index) => 103 + index * inputPeriod)
|
||||
const draft = inputOffsets.map((_, index) => String.fromCharCode(97 + index % 26)).join('')
|
||||
const stats = (values: number[]) => {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return { n: sorted.length, median: (sorted[Math.floor((sorted.length - 1) / 2)]! + sorted[Math.floor(sorted.length / 2)]!) / 2,
|
||||
p95: sorted[Math.ceil(sorted.length * 0.95) - 1]!, max: sorted.at(-1)! }
|
||||
}
|
||||
app.setPath('userData', join(directory, 'profile'))
|
||||
app.commandLine.appendSwitch('force-device-scale-factor', '1')
|
||||
app.whenReady().then(async () => {
|
||||
const win = new BrowserWindow({ show: true, width: 1100, height: 800,
|
||||
webPreferences: { preload: join(directory, 'preload.cjs'), sandbox: true,
|
||||
contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } })
|
||||
const errors: string[] = []
|
||||
win.webContents.on('console-message', event => { if (event.level === 'error') errors.push(event.message) })
|
||||
await win.loadFile(join(directory, 'renderer', 'streaming-input-page.html'))
|
||||
win.focus()
|
||||
const evaluate = (code: string) => win.webContents.executeJavaScript(`window.streamingInput.${code}`)
|
||||
const reference = await evaluate(`reset(${JSON.stringify(answer)})`)
|
||||
assert(reference.includes('Deployment review'))
|
||||
const results: { interval: number; updates: number; longTasks: number;
|
||||
raw: { sendToInput: number[]; inputToFrame: number[] } }[] = []
|
||||
// Warm one full growing answer, then rotate and reverse the measured order.
|
||||
const order = [32, 16, 50, 32, 80, 80, 32, 50, 16]
|
||||
for (const [position, interval] of order.entries()) {
|
||||
await evaluate('reset()')
|
||||
// Estimate process clock offset while idle; minimum RTT bounds the timestamp uncertainty.
|
||||
const calibration = []
|
||||
for (let sample = 0; sample < 12; sample++) {
|
||||
const before = epoch()
|
||||
const renderer = await evaluate('clock()')
|
||||
const after = epoch()
|
||||
calibration.push({ offset: renderer - (before + after) / 2, rtt: after - before })
|
||||
}
|
||||
const clock = calibration.sort((a, b) => a.rtt - b.rtt)[0]!
|
||||
const sent: number[] = []
|
||||
const chunkLateness: number[] = []
|
||||
const inputLateness: number[] = []
|
||||
let flushes = 0
|
||||
let firstFlush = 0
|
||||
const buffer = new AgentEventBuffer({ flushIntervalMs: interval, onEvent(event) {
|
||||
if (event.type !== 'text') return
|
||||
flushes++
|
||||
if (!firstFlush) firstFlush = epoch()
|
||||
win.webContents.send('streaming-input:chunk', event.delta)
|
||||
} })
|
||||
const start = epoch()
|
||||
// All deadlines are scheduled in main up front. Renderer blockage cannot slow the producer or typing.
|
||||
const arrivals = chunks.map((delta, index) => new Promise<void>(resolve => {
|
||||
const deliver = () => {
|
||||
chunkLateness.push(epoch() - start - index * chunkPeriod)
|
||||
buffer.push({ type: 'text', requestId: 'benchmark', delta })
|
||||
if (index === 0) buffer.flush()
|
||||
resolve()
|
||||
}
|
||||
if (index === 0) deliver()
|
||||
else setTimeout(deliver, Math.max(0, start + index * chunkPeriod - epoch()))
|
||||
}))
|
||||
const typing = inputOffsets.map((offset, index) => new Promise<void>(resolve => {
|
||||
setTimeout(() => {
|
||||
sent.push(epoch())
|
||||
inputLateness.push(sent.at(-1)! - start - offset)
|
||||
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: draft[index]! })
|
||||
win.webContents.sendInputEvent({ type: 'char', keyCode: draft[index]! })
|
||||
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: draft[index]! })
|
||||
resolve()
|
||||
}, Math.max(0, start + offset - epoch()))
|
||||
}))
|
||||
await Promise.all([...arrivals, ...typing, delay(Math.max(0, start + duration - epoch()))])
|
||||
buffer.close()
|
||||
const producerDuration = epoch() - start
|
||||
let ready = false
|
||||
for (let poll = 0; poll < 400; poll++) {
|
||||
const status = await evaluate('status()')
|
||||
if (status.length === answer.length && status.inputs === draft.length && status.frames === draft.length) {
|
||||
ready = true
|
||||
break
|
||||
}
|
||||
await delay(10)
|
||||
}
|
||||
assert(ready, 'renderer must drain the entire independently scheduled stream and native input')
|
||||
const result = await evaluate('result()')
|
||||
assert.equal(result.committed, answer)
|
||||
assert.equal(result.domText, reference)
|
||||
assert.equal(result.draft, draft)
|
||||
assert.equal(result.committedDraft, draft)
|
||||
assert.equal(result.focused, true)
|
||||
assert.equal(result.received, flushes)
|
||||
assert.equal(result.inputs.length, sent.length)
|
||||
for (const [index, input] of result.inputs.entries()) {
|
||||
assert.equal(input.value, draft.slice(0, index + 1))
|
||||
assert.equal(input.trusted, true)
|
||||
}
|
||||
const sendToInput = result.inputs.map((input: { at: number }, index: number) => input.at - clock.offset - sent[index]!)
|
||||
const inputToFrame = result.inputs.map((input: { at: number; frame: number }) => input.frame - input.at)
|
||||
const row = { interval, run: position === 0 ? 'warmup' : position <= 4 ? 1 : 2,
|
||||
sendToInputMs: stats(sendToInput), inputToFrameMs: stats(inputToFrame),
|
||||
sendToFrameMs: stats(sendToInput.map((value: number, index: number) => value + inputToFrame[index])),
|
||||
updates: result.updates, flushes, longTasks: result.tasks.length,
|
||||
longTaskMs: result.tasks.length ? stats(result.tasks) : null,
|
||||
longTaskTotalMs: result.tasks.reduce((sum: number, value: number) => sum + value, 0),
|
||||
producerDurationMs: producerDuration, firstFlushMs: firstFlush - start,
|
||||
firstCommitMs: result.firstCommit - clock.offset - start,
|
||||
lastCommitMs: result.lastCommit - clock.offset - start,
|
||||
chunkLatenessMs: stats(chunkLateness), inputLatenessMs: stats(inputLateness),
|
||||
clockUncertaintyMs: clock.rtt / 2, exactTextDraftFocus: true }
|
||||
console.log('Streaming input run ' + JSON.stringify(row))
|
||||
if (position > 0) results.push({ ...row, raw: { sendToInput, inputToFrame } })
|
||||
}
|
||||
assert.deepEqual(errors, [])
|
||||
const aggregate = [16, 32, 50, 80].map(interval => {
|
||||
const runs = results.filter(row => row.interval === interval)
|
||||
return { interval, sendToInputMs: stats(runs.flatMap(row => row.raw.sendToInput)),
|
||||
inputToFrameMs: stats(runs.flatMap(row => row.raw.inputToFrame)),
|
||||
sendToFrameMs: stats(runs.flatMap(row => row.raw.sendToInput.map((value, index) => value + row.raw.inputToFrame[index]!))),
|
||||
updates: runs.map(row => row.updates), longTasks: runs.map(row => row.longTasks) }
|
||||
})
|
||||
console.log('Streaming input summary ' + JSON.stringify({ electron: process.versions.electron,
|
||||
chromium: process.versions.chrome, renderer: 'production ChatTimeline + controlled textarea (not App)',
|
||||
bytes: Buffer.byteLength(answer), durationMs: duration, chunks: chunks.length, chunkPeriodMs: chunkPeriod,
|
||||
inputPeriodMs: inputPeriod, measuredOrder: order.slice(1), aggregate }))
|
||||
app.exit(0)
|
||||
}).catch(error => { console.error(error); app.exit(1) })
|
||||
Reference in New Issue
Block a user