perf(web): bound streamed tool argument work
This commit is contained in:
1 parent
d1b2b5a63b
commit
a33bd1a80b
7 files changed
+205
-36
No files matched your search
@@ -1,4 +1,4 @@
|
||||
/** Required performance budget for the compiled cold Client conversation fold. */
|
||||
/** Required performance budgets for compiled Client history folding and tool preparation. */
|
||||
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
ciTimeBudget,
|
||||
PERFORMANCE_BUDGET_HEADROOM,
|
||||
} from '../support/calibration.ts'
|
||||
import type { ConversationFoldWorkerReport } from './conversation-fold.worker.client.ts'
|
||||
import type { ConversationFoldWorkerReport, PreparingToolWorkerReport } from './conversation-fold.worker.client.ts'
|
||||
|
||||
/** Replies in the folded window; each carries one reasoning block and one text block. */
|
||||
const TURNS = 200
|
||||
@@ -39,6 +39,12 @@ const LARGE_FOLD_BUDGET_MS = ciTimeBudget(EXPECTED_LARGE_FOLD_MS)
|
||||
const EXPECTED_DELTA_SCALING = 2.5
|
||||
const MAX_DELTA_SCALING = EXPECTED_DELTA_SCALING * PERFORMANCE_BUDGET_HEADROOM
|
||||
|
||||
const PREPARING_SAMPLES = 3
|
||||
const PREPARING_CASES = [
|
||||
{ tool: 'write', characters: 512 * 1024, expectedMs: 300, expectedMb: 30 },
|
||||
{ tool: 'bash', characters: 128 * 1024, expectedMs: 150, expectedMb: 8 },
|
||||
] as const
|
||||
|
||||
const WORKER = join(
|
||||
import.meta.dirname,
|
||||
'..',
|
||||
@@ -47,9 +53,9 @@ const WORKER = join(
|
||||
'conversation-fold.worker.js',
|
||||
)
|
||||
|
||||
function requireReport(
|
||||
run: BuiltBenchmarkWorkerRun<ConversationFoldWorkerReport>,
|
||||
): ConversationFoldWorkerReport {
|
||||
function requireReport<Report>(
|
||||
run: BuiltBenchmarkWorkerRun<Report>,
|
||||
): Report {
|
||||
if (run.report !== undefined) return run.report
|
||||
const stderrLines = run.stderr.trim().split('\n')
|
||||
throw new Error(
|
||||
@@ -76,3 +82,29 @@ describe('cold Chat fold of a large v2 history window', () => {
|
||||
expect(report.scaling).toBeLessThanOrEqual(MAX_DELTA_SCALING)
|
||||
})
|
||||
})
|
||||
|
||||
describe('preparing tool arguments', () => {
|
||||
it.each(PREPARING_CASES)('publishes streamed $tool arguments within bounded CPU and heap costs', async (workload) => {
|
||||
const samples: PreparingToolWorkerReport[] = []
|
||||
for (let index = 0; index < PREPARING_SAMPLES; index++) {
|
||||
const run = await runBuiltBenchmarkWorker<PreparingToolWorkerReport>({
|
||||
worker: WORKER, args: ['preparing', workload.tool, String(workload.characters)],
|
||||
exposeGc: true, timeoutMs: WORKER_TIMEOUT_MS,
|
||||
})
|
||||
expect(run.timedOut, run.stderr).toBe(false)
|
||||
expect(run.signal, run.stderr).toBeNull()
|
||||
expect(run.exitCode, run.stderr).toBe(0)
|
||||
const report = requireReport(run)
|
||||
expect(report.characters).toBe(workload.characters)
|
||||
expect(report.detail).toBe(workload.tool === 'write' ? 'preview.md' : `${'abcdefghijklmno '.repeat(10).slice(0, 159)}…`)
|
||||
samples.push(report)
|
||||
}
|
||||
const medianMs = samples.map(sample => sample.elapsedMs).toSorted((a, b) => a - b)[1]!
|
||||
const retainedMb = samples.map(sample => sample.retainedMb).toSorted((a, b) => a - b)[1]!
|
||||
const budgetMs = ciTimeBudget(workload.expectedMs)
|
||||
const budgetMb = workload.expectedMb * PERFORMANCE_BUDGET_HEADROOM
|
||||
console.log(JSON.stringify({ benchmark: `conversation-fold/preparing-${workload.tool}`, samples, medianMs, retainedMb, budgetMs, budgetMb }))
|
||||
expect(medianMs).toBeLessThanOrEqual(budgetMs)
|
||||
expect(retainedMb).toBeLessThanOrEqual(budgetMb)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,11 @@
|
||||
/** Compiled worker for the cold Client conversation-fold benchmark. */
|
||||
/** Compiled worker for Client history folding and live tool preparation. */
|
||||
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream'
|
||||
import { LlmAttemptId, ToolCallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ChatSnapshot } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { ChatNode, ChatSnapshot } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { SessionEventLikeEntry } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
// These Client-only fold modules have no plain-Node package export and are compiled into this worker.
|
||||
import { ConversationNodeAssembler } from '../../packages/client/ui-conversation/src/client/conversation/assembler.ts'
|
||||
@@ -20,6 +21,7 @@ import { compactionDefinition } from '../../packages/client/ui-chat/src/client/c
|
||||
import { unknownFallbackDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/fallback.ts'
|
||||
import { nextStepInboxDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts'
|
||||
import { messageDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/message.ts'
|
||||
import { processGroupDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts'
|
||||
import { requestPromptDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts'
|
||||
import { retryDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/retry.ts'
|
||||
import { toolDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/tool.ts'
|
||||
@@ -42,6 +44,16 @@ export interface ConversationFoldWorkerReport {
|
||||
readonly scaling: number
|
||||
}
|
||||
|
||||
/** Incremental argument folding through the Tool Definition and Chat group publication. */
|
||||
export interface PreparingToolWorkerReport {
|
||||
readonly tool: 'write' | 'bash'
|
||||
readonly characters: number | undefined
|
||||
readonly fragments: number
|
||||
readonly elapsedMs: number
|
||||
readonly retainedMb: number
|
||||
readonly detail: string | undefined
|
||||
}
|
||||
|
||||
class BenchEventDefinitions {
|
||||
readonly definitions: readonly ConversationNodeDefinition[] = [
|
||||
nextStepInboxDefinition,
|
||||
@@ -173,31 +185,95 @@ function positiveInteger(value: string | undefined, label: string): number {
|
||||
return parsed
|
||||
}
|
||||
|
||||
function preparingTool(tool: 'write' | 'bash', characters: number): PreparingToolWorkerReport {
|
||||
if (globalThis.gc === undefined) throw new Error('preparing benchmark requires --expose-gc')
|
||||
const chunkSize = 16
|
||||
const chunksPerPublication = 64
|
||||
const payload = 'abcdefghijklmno '.repeat(Math.ceil(characters / chunkSize)).slice(0, characters)
|
||||
const tail = `${payload}"}`
|
||||
const callId = ToolCallId('preparing-benchmark')
|
||||
const attemptId = LlmAttemptId('preparing-benchmark')
|
||||
const delta = (index: number, argumentsDelta: string): SessionEventLikeEntry => ({
|
||||
type: 'transient',
|
||||
event: {
|
||||
type: 'assistant/live-chunk', seq: 2 + index / (tail.length + 1), time: TIME_ZERO + index,
|
||||
data: { turn: 1, step: 1, attemptId, chunk: {
|
||||
type: 'tool-call-delta', index: 0, id: callId, argumentsDelta,
|
||||
...index === 0 ? { name: tool } : {},
|
||||
} },
|
||||
},
|
||||
})
|
||||
const chunks: SessionEventLikeEntry[] = []
|
||||
for (let at = 0; at < tail.length; at += chunkSize) chunks.push(delta(at + 1, tail.slice(at, at + chunkSize)))
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new BenchEventDefinitions(), new BenchViewDefinitions(),
|
||||
{ entries: () => [processGroupDefinition], forTarget: target => target === 'chat' ? processGroupDefinition : undefined },
|
||||
)
|
||||
assembler.replaceWindow([
|
||||
entry(0, 'turn/start', { turn: 1 }),
|
||||
entry(1, 'step/start', { turn: 1, step: 1 }),
|
||||
delta(0, tool === 'write' ? '{"file_path":"preview.md","content":"' : '{"command":"'),
|
||||
], false)
|
||||
assembler.activateTarget('chat')
|
||||
const snapshot = assembler.get('chat')
|
||||
const node = snapshot?.nodes.values().find((candidate): candidate is ChatNode<'tool-call'> => candidate.kind === 'tool-call')
|
||||
if (node === undefined) throw new Error('preparing benchmark did not create its Tool node')
|
||||
const args = node.data.root.args
|
||||
if (tool === 'write') args.stringLength('content', { step: 1024 })
|
||||
globalThis.gc()
|
||||
const before = process.memoryUsage().heapUsed
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < chunks.length; index++) {
|
||||
assembler.append(chunks[index]!)
|
||||
if ((index + 1) % chunksPerPublication === 0) assembler.flush()
|
||||
}
|
||||
assembler.flush()
|
||||
const elapsedMs = performance.now() - start
|
||||
globalThis.gc()
|
||||
const retainedMb = (process.memoryUsage().heapUsed - before) / 1048576
|
||||
const groups = assembler.grouped('chat')
|
||||
const group = groups?.entries.find(reference => reference.kind === 'group')
|
||||
return {
|
||||
tool, characters: args.stringLength(tool === 'write' ? 'content' : 'command'), fragments: chunks.length,
|
||||
elapsedMs, retainedMb,
|
||||
detail: group === undefined ? undefined : groups?.groupSource(group.key).getSnapshot()?.data.summary.runningDetail,
|
||||
}
|
||||
}
|
||||
|
||||
assertBuiltBenchmarkRuntime(import.meta.url, {
|
||||
'@deepseek-ai/dsh-brand': import.meta.resolve('@deepseek-ai/dsh-brand'),
|
||||
'@deepseek-ai/dsh-client-store': import.meta.resolve('@deepseek-ai/dsh-client-store'),
|
||||
'@deepseek-ai/dsh-llm/assistant-stream': import.meta.resolve('@deepseek-ai/dsh-llm/assistant-stream'),
|
||||
'@deepseek-ai/dsh-session/surface': import.meta.resolve('@deepseek-ai/dsh-session/surface'),
|
||||
'@deepseek-ai/dsh-token-meter/client': import.meta.resolve('@deepseek-ai/dsh-token-meter/client'),
|
||||
'@deepseek-ai/dsh-util-values': import.meta.resolve('@deepseek-ai/dsh-util-values'),
|
||||
})
|
||||
const [turnsValue, smallDeltasValue, largeDeltasValue, attemptsValue] = process.argv.slice(2)
|
||||
const turns = positiveInteger(turnsValue, 'turns')
|
||||
const smallDeltas = positiveInteger(smallDeltasValue, 'small deltas')
|
||||
const largeDeltas = positiveInteger(largeDeltasValue, 'large deltas')
|
||||
const attempts = positiveInteger(attemptsValue, 'attempts')
|
||||
const small = synthesizeWindow(turns, smallDeltas)
|
||||
const large = synthesizeWindow(turns, largeDeltas)
|
||||
if (large.entries.length !== small.entries.length || large.records !== small.records) {
|
||||
throw new Error('conversation-fold workloads must have matching event and compact-record counts')
|
||||
if (process.argv[2] === 'preparing') {
|
||||
const tool = process.argv[3]
|
||||
if (tool !== 'write' && tool !== 'bash') throw new Error('preparing benchmark tool must be write or bash')
|
||||
const report = preparingTool(tool, positiveInteger(process.argv[4], 'characters'))
|
||||
process.stdout.write(`${JSON.stringify(report)}\n`)
|
||||
} else {
|
||||
const [turnsValue, smallDeltasValue, largeDeltasValue, attemptsValue] = process.argv.slice(2)
|
||||
const turns = positiveInteger(turnsValue, 'turns')
|
||||
const smallDeltas = positiveInteger(smallDeltasValue, 'small deltas')
|
||||
const largeDeltas = positiveInteger(largeDeltasValue, 'large deltas')
|
||||
const attempts = positiveInteger(attemptsValue, 'attempts')
|
||||
const small = synthesizeWindow(turns, smallDeltas)
|
||||
const large = synthesizeWindow(turns, largeDeltas)
|
||||
if (large.entries.length !== small.entries.length || large.records !== small.records) {
|
||||
throw new Error('conversation-fold workloads must have matching event and compact-record counts')
|
||||
}
|
||||
const smallFold = bestOf(small.entries, attempts)
|
||||
const largeFold = bestOf(large.entries, attempts)
|
||||
const report: ConversationFoldWorkerReport = {
|
||||
events: large.entries.length,
|
||||
compactRecords: large.records,
|
||||
streamedDeltas: turns * (largeDeltas + Math.floor(largeDeltas / 4)),
|
||||
chatNodes: largeFold.nodes,
|
||||
smallFoldMs: Math.round(smallFold.ms * 10) / 10,
|
||||
largeFoldMs: Math.round(largeFold.ms * 10) / 10,
|
||||
scaling: Math.round((largeFold.ms / Math.max(smallFold.ms, 1)) * 100) / 100,
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(report)}\n`)
|
||||
}
|
||||
const smallFold = bestOf(small.entries, attempts)
|
||||
const largeFold = bestOf(large.entries, attempts)
|
||||
const report: ConversationFoldWorkerReport = {
|
||||
events: large.entries.length,
|
||||
compactRecords: large.records,
|
||||
streamedDeltas: turns * (largeDeltas + Math.floor(largeDeltas / 4)),
|
||||
chatNodes: largeFold.nodes,
|
||||
smallFoldMs: Math.round(smallFold.ms * 10) / 10,
|
||||
largeFoldMs: Math.round(largeFold.ms * 10) / 10,
|
||||
scaling: Math.round((largeFold.ms / Math.max(smallFold.ms, 1)) * 100) / 100,
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(report)}\n`)
|
||||
@@ -16,6 +16,7 @@
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:*",
|
||||
"@deepseek-ai/dsh-agent-preset-registry": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:*",
|
||||
"@deepseek-ai/dsh-brand": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-chat": "workspace:*",
|
||||
"@deepseek-ai/dsh-deque": "workspace:*",
|
||||
|
||||
@@ -35,10 +35,14 @@ function normalizeLiveToolDetail(value: unknown): string {
|
||||
? value.join(', ')
|
||||
: ''
|
||||
const normalized = text.replace(/\s+/g, ' ').trim()
|
||||
const chars = Array.from(LIVE_TOOL_DETAIL_SEGMENTER.segment(normalized), part => part.segment)
|
||||
return chars.length <= LIVE_TOOL_DETAIL_MAX_CHARS
|
||||
? normalized
|
||||
: `${chars.slice(0, LIVE_TOOL_DETAIL_MAX_CHARS - 1).join('').trimEnd()}…`
|
||||
const chars: string[] = []
|
||||
for (const { segment } of LIVE_TOOL_DETAIL_SEGMENTER.segment(normalized)) {
|
||||
if (chars.length === LIVE_TOOL_DETAIL_MAX_CHARS) {
|
||||
return `${chars.slice(0, LIVE_TOOL_DETAIL_MAX_CHARS - 1).join('').trimEnd()}…`
|
||||
}
|
||||
chars.push(segment)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function questionDetail(value: unknown): string {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PartialArguments } from '@deepseek-ai/dsh-util-values'
|
||||
import type { ChatConversationViewNode, ChatNode } from '../src/client/contract/chat-nodes.ts'
|
||||
import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
|
||||
import { ProcessState } from '../src/client/conversation-nodes/process-groups.ts'
|
||||
import { processActivity } from '../src/client/conversation-nodes/process-activity.ts'
|
||||
import { ConversationGroupStore } from '../../ui-conversation/src/client/conversation/group-store.ts'
|
||||
import type { ProcessGroupData } from '../src/client/contract/process-groups.ts'
|
||||
|
||||
@@ -80,6 +81,54 @@ function harness(nodes: readonly ChatConversationViewNode[], currentTimeline = t
|
||||
}
|
||||
|
||||
describe('Definition-owned Chat process groups', () => {
|
||||
it.each([
|
||||
['whitespace', ' one\t\n two ', 'one two'],
|
||||
['empty text', ' \t\n ', 'bash'],
|
||||
['exact limit', 'x'.repeat(160), 'x'.repeat(160)],
|
||||
['overflow', 'x'.repeat(161), `${'x'.repeat(159)}…`],
|
||||
['space at cutoff', `${'x'.repeat(158)} \n yz`, `${'x'.repeat(158)}…`],
|
||||
['combining marks', 'e\u0301'.repeat(161), `${'e\u0301'.repeat(159)}…`],
|
||||
['emoji at limit', '👩💻'.repeat(160), '👩💻'.repeat(160)],
|
||||
['emoji overflow', '👩💻'.repeat(161), `${'👩💻'.repeat(159)}…`],
|
||||
])('preserves normalized live detail for %s', (_label, command, expected) => {
|
||||
const node = tool('call', 2)
|
||||
const argsRaw = JSON.stringify({ command })
|
||||
const root = { ...node.data.root, argsRaw, args: PartialArguments.fromText(argsRaw) }
|
||||
expect(processActivity([{ ...node, data: { root } }]).runningDetail).toBe(expected)
|
||||
})
|
||||
|
||||
it('bounds grapheme traversal while allowing a later description to replace a long command', () => {
|
||||
const args = new PartialArguments()
|
||||
args.append(`{"command":"${'x'.repeat(1024)}`)
|
||||
const node: ChatNode<'tool-call'> = {
|
||||
...tool('call', 2),
|
||||
data: { root: { phase: 'preparing', args, callId: 'call', name: 'bash', turn: 1, step: 1, time: 2, subCalls: [] } },
|
||||
}
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
||||
const segment = segmenter.segment.bind(segmenter)
|
||||
let visited = 0
|
||||
const spy = vi.spyOn(Intl.Segmenter.prototype, 'segment').mockImplementation((input) => {
|
||||
const segments = segment(input)
|
||||
const iterator = segments[Symbol.iterator]()
|
||||
segments[Symbol.iterator] = function* () {
|
||||
for (const part of iterator) {
|
||||
visited++
|
||||
yield part
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return segments
|
||||
})
|
||||
try {
|
||||
expect(processActivity([node]).runningDetail).toBe(`${'x'.repeat(159)}…`)
|
||||
expect(visited).toBeLessThanOrEqual(161)
|
||||
expect(args.append('","description":"Run focused tests"}')).toBe(true)
|
||||
expect(processActivity([node]).runningDetail).toBe('Run focused tests')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('clears preparation from a closed group while retaining its recorded activity', () => {
|
||||
const preparing: ChatNode<'tool-call'> = {
|
||||
...tool('call', 2, 'write'),
|
||||
|
||||
@@ -90,7 +90,7 @@ export class PartialArguments {
|
||||
*/
|
||||
static fromText(text: string): PartialArguments {
|
||||
const view = new PartialArguments()
|
||||
view.raw = text
|
||||
view.append(text)
|
||||
view.sealed = true
|
||||
return view
|
||||
}
|
||||
@@ -118,6 +118,7 @@ export class PartialArguments {
|
||||
private object: Readonly<Record<string, JsonValue>> | undefined
|
||||
private sealed = false
|
||||
// Scan progress, located fields, and remembered reads are caches over the source.
|
||||
#pending = ''
|
||||
#consumed = 0
|
||||
#mode: Mode = 'root'
|
||||
#escape = false
|
||||
@@ -145,6 +146,7 @@ export class PartialArguments {
|
||||
append(fragment: string): boolean {
|
||||
if (this.sealed) throw new Error('PartialArguments: cannot append to a sealed view')
|
||||
this.raw += fragment
|
||||
this.#pending += fragment
|
||||
if (this.#reads.size === 0) return false
|
||||
this.scan()
|
||||
let changed = false
|
||||
@@ -297,11 +299,13 @@ export class PartialArguments {
|
||||
return entry?.kind === 'value' && entry.end >= 0 ? entry.parsed : undefined
|
||||
}
|
||||
|
||||
/** Scan the text not yet consumed; each character is handled once. */
|
||||
/** Index only unread text; indexing accumulated raw text repeatedly flattens its prefix. */
|
||||
private scan(): void {
|
||||
if (this.object !== undefined) return
|
||||
while (this.#consumed < this.raw.length && this.#mode !== 'invalid') {
|
||||
this.step(this.raw[this.#consumed] as string, this.#consumed)
|
||||
const pending = this.#pending
|
||||
this.#pending = ''
|
||||
for (let index = 0; index < pending.length && this.#mode !== 'invalid'; index++) {
|
||||
this.step(pending[index] as string, this.#consumed)
|
||||
this.#consumed++
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+3
@@ -883,6 +883,9 @@ importers:
|
||||
'@deepseek-ai/dsh-api-session-controller':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/api/session-controller
|
||||
'@deepseek-ai/dsh-brand':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/util/brand
|
||||
'@deepseek-ai/dsh-client-store':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/client/store
|
||||
|
||||
Reference in new issue
Block a user