test(bench): add scaled machine and memory-pressure report

Record budgeted benchmark endpoints and peak memory, and render a
self-contained interactive report that rescales measured times to
reviewed laptop and cloud profiles by single-thread CPU rating and
default-disk latency, and compares peaks with 4 GB and 8 GB of
available memory.
This commit is contained in:
Turtle committed 2026-10-07 20:42:30 +08:00
1 parent a2501d0779
commit 12c33f6d0c
13 files changed
+375

No files matched your search

+1
View File
@@ -39,6 +39,7 @@ packages/**/__pycache__/
apps/web/dist/
.artifacts/
.dsh-build/
.dsh-report/
.playwright-mcp/
.orig
.worktrees/
+1
View File
@@ -8,6 +8,7 @@ This tree owns required, repository-level performance gates whose measured user
- Browser workflow cases drive built Client bundles through the shared shipped-composition Web scaffold. Report its source-resolved test Host separately from published-Host evidence; two animation frames prove a rendering opportunity, not hardware presentation. Use fresh browsers and private scaffold worlds per sample.
- Synthesize fixed inputs from reviewed constants. Never use recorded Sessions, user material, ambient repositories, or network services.
- Run process-level wall-clock and retained-memory samples in fresh children with private `mkdtemp` roots. Pure synchronous folds create a fresh object graph per sample and must not mutate process-global state. Bound every child, await exit, and remove owned roots after failure as well as success.
- Pass every budgeted wall-clock endpoint to `recordTimings` from `support/scaling-report.ts` before asserting it, with a commented storage-wait share; omit endpoints that include paced or fixed waits. Pass whole-process or page memory peaks to `recordPeakMemory`. Profiles live in `support/machine-profiles.ts`; each run writes `.dsh-report/report.html`.
- Record reference-machine expectations separately from the shared CI time scale and variance headroom. Do not apply the time scale to memory or dimensionless ratios.
- Report enough raw and aggregate measurements to explain each verdict, including whether a budget uses a median, minimum, absolute value, or ratio. Enforce reviewed source constants; environment variables must not override performance budgets.
- Keep scenario-specific support beside its benchmark. Move a helper into `benchmarks/support/` only after at least two benchmark directories require the same behavior.
@@ -3,12 +3,15 @@ import { join } from 'node:path'
import { expect, it } from 'vitest'
import { runBuiltBenchmarkWorker } from '../support/built-worker.ts'
import { ciTimeBudget, PERFORMANCE_BUDGET_HEADROOM } from '../support/calibration.ts'
import { recordTimings } from '../support/scaling-report.ts'
import type { ReconnectReport } from './reconnect.worker.client.ts'
const REFERENCE_REPLACE_MS = 50
const REPLACE_BUDGET_MS = ciTimeBudget(REFERENCE_REPLACE_MS)
const REFERENCE_RETAINED_MB = 24
const SAMPLES = 3
/** Replacement folds in-memory Client state; no storage access is timed. */
const IO_SHARE = 0
function expectReplacementWithinBudget(value: number, budget: number): void {
expect(value).toBeLessThanOrEqual(budget)
@@ -49,6 +52,7 @@ it('reconstructs a 100000-delta live prefix within baseline time and retained-me
const budgetMs = REPLACE_BUDGET_MS
const budgetMb = REFERENCE_RETAINED_MB * PERFORMANCE_BUDGET_HEADROOM
console.log(JSON.stringify({ benchmark: 'active-stream-reconnect', samples, median: { replaceMs, retainedMb }, referenceMs: REFERENCE_REPLACE_MS, referenceMb: REFERENCE_RETAINED_MB, budgetMs, budgetMb }))
recordTimings('active-stream-reconnect', IO_SHARE, { replaceMs }, { replaceMs: budgetMs })
expectReplacementWithinBudget(replaceMs, budgetMs)
expect.soft(retainedMb).toBeLessThanOrEqual(budgetMb)
})
@@ -6,6 +6,7 @@ import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { runBuiltBenchmarkWorker } from '../support/built-worker.ts'
import { ciTimeBudget, PERFORMANCE_BUDGET_HEADROOM } from '../support/calibration.ts'
import { recordTimings } from '../support/scaling-report.ts'
import type { ContinuationReport } from './agent-continuation.worker.ts'
import type { CatalogReport } from './child-catalog.worker.ts'
import type { ProfileReport } from './profile-continuation.worker.ts'
@@ -28,6 +29,8 @@ const CATALOG_BUDGET_MS = Math.ceil(EXPECTED_CATALOG_CI_MS * PERFORMANCE_BUDGET_
const REQUEST_HISTORY_BUDGET_MS = 297
const EXPECTED_RETAINED_HEAP_MB = 23
const WORKERS = join(import.meta.dirname, '..', '.dsh-build', 'agent-continuation')
/** Estimated storage wait: continuation reads a copied Session tree and appends synchronized log records. */
const IO_SHARE = 0.15
type Scenario = 'request-history' | 'catalog' | 'tool-continuation' | keyof typeof EXPECTED_MS
type Report = ContinuationReport | CatalogReport | ProfileReport
@@ -175,6 +178,7 @@ describe('continuing tool-heavy Sessions with large histories', () => {
samples, totalMs: { min: Math.min(...totalMs), median: median(totalMs), max: Math.max(...totalMs) },
budgetMs, ...(scenario === 'tool-continuation' ? { retainedHeapBudgetMb } : {}),
}))
recordTimings('agent-continuation/' + scenario, IO_SHARE, { totalMs: median(totalMs) }, { totalMs: budgetMs })
if (scenario === 'request-history') assertRequestHistoryBudget(median(totalMs))
else expectTotalWithinBudget(median(totalMs), budgetMs)
if (scenario === 'tool-continuation') {
@@ -10,6 +10,7 @@ import {
ciTimeBudget,
PERFORMANCE_BUDGET_HEADROOM,
} from '../support/calibration.ts'
import { recordTimings } from '../support/scaling-report.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. */
@@ -20,6 +21,8 @@ const LARGE_DELTAS = 2_000
const SMALL_DELTAS = 100
/** Fresh object graphs measured in one compiled worker; the fastest sample removes scheduler delay. */
const ATTEMPTS = 3
/** Folding and argument preparation run on in-memory Client state without storage access. */
const IO_SHARE = 0
/** A stuck fold worker is reaped before the outer benchmark deadline. */
const WORKER_TIMEOUT_MS = 60_000
@@ -77,6 +80,7 @@ describe('cold Chat fold of a large v2 history window', () => {
budgetMs: LARGE_FOLD_BUDGET_MS,
maxScaling: MAX_DELTA_SCALING,
}))
recordTimings('conversation-fold/large-window', IO_SHARE, { largeFoldMs: report.largeFoldMs }, { largeFoldMs: LARGE_FOLD_BUDGET_MS })
expect(report.chatNodes).toBeGreaterThan(0)
expect(report.largeFoldMs).toBeLessThanOrEqual(LARGE_FOLD_BUDGET_MS)
expect(report.scaling).toBeLessThanOrEqual(MAX_DELTA_SCALING)
@@ -108,6 +112,7 @@ describe('preparing tool arguments', () => {
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 }))
recordTimings(`conversation-fold/preparing-${workload.tool}`, IO_SHARE, { medianMs }, { medianMs: budgetMs })
expect(medianMs).toBeLessThanOrEqual(budgetMs)
expect(retainedMb).toBeLessThanOrEqual(budgetMb)
})
@@ -8,6 +8,7 @@ import { expect, it, vi } from 'vitest'
import { launchWebScaffold, seedSession, watchConsole, webSnapshotMode } from '../../apps/web/tests/scaffold.ts'
import { newEnglishPage } from '../../apps/web/tests/support.ts'
import { ciTimeBudget, PERFORMANCE_BUDGET_HEADROOM } from '../support/calibration.ts'
import { recordPeakMemory, recordTimings } from '../support/scaling-report.ts'
import { HISTORY_TURNS, SESSION_ID, FIRST, DONE, DELTAS, PACE_MS, syntheticHistory, syntheticReply } from './synthetic-history.ts'
const SAMPLES = 3
@@ -20,6 +21,10 @@ const OPEN_BUDGET_MS = Math.ceil(EXPECTED_OPEN_CI_MS * PERFORMANCE_BUDGET_HEADRO
const PAGE_BUDGET_MS = Math.ceil(EXPECTED_PAGE_CI_MS * PERFORMANCE_BUDGET_HEADROOM)
const TRAJECTORY_BUDGET_MS = Math.ceil(EXPECTED_TRAJECTORY_CI_MS * PERFORMANCE_BUDGET_HEADROOM)
const REPLAY_DURATION_MS = (DELTAS + 4) * PACE_MS
/** Endpoints without paced replay waits, so CPU scaling applies to their whole duration. */
const SCALED_ENDPOINTS = ['open', 'page', 'trajectory', 'streamTask'] as const
/** Estimated storage wait: the Host reads the seeded Session log, which the page cache usually holds. */
const IO_SHARE = 0.05
async function painted(page: Page): Promise<void> {
// Two rAF callbacks include a rendering opportunity, not a GPU presentation timestamp.
@@ -439,5 +444,7 @@ it('opens, pages, navigates and streams into a 240-turn browser history', async
open: OPEN_BUDGET_MS, page: PAGE_BUDGET_MS, trajectory: TRAJECTORY_BUDGET_MS,
}
console.log(JSON.stringify({ benchmark: 'long-session-browser/median', turns: HISTORY_TURNS, deltas: DELTAS, paceMs: PACE_MS, samples, aggregate, referenceMs: REFERENCE, expectedOpenCiMs: EXPECTED_OPEN_CI_MS, expectedPageCiMs: EXPECTED_PAGE_CI_MS, expectedTrajectoryCiMs: EXPECTED_TRAJECTORY_CI_MS, budgets }))
recordTimings('long-session-browser', IO_SHARE, Object.fromEntries(SCALED_ENDPOINTS.map(key => [key, aggregate[key]!])), budgets)
recordPeakMemory('long-session-browser', { pageJsHeapMb: Math.max(...samples.map(sample => sample.heapMb)) })
for (const [key, value] of Object.entries(aggregate)) expectEndpointWithinBudget(value, budgets[key]!)
})
@@ -6,6 +6,7 @@ import { join } from 'node:path'
import { expect, it } from 'vitest'
import { runBuiltBenchmarkWorker } from '../support/built-worker.ts'
import { ciTimeBudget, PERFORMANCE_BUDGET_HEADROOM } from '../support/calibration.ts'
import { recordTimings } from '../support/scaling-report.ts'
import type { ProjectionListReport } from './projection-list.worker.ts'
const WORKER = join(import.meta.dirname, '..', '.dsh-build', 'session-corpus', 'projection-list.worker.js')
@@ -17,6 +18,8 @@ const LIST_REFERENCE_MS = { modest: 50, tail: 500, cheap: 150 } as const
const CALLBACK_REFERENCE_MS = 30
/** Reference retained-heap allowance; memory receives variance headroom but no CPU scaling. */
const RETAINED_HEAP_REFERENCE_BYTES = 240 * 1024 * 1024
/** Estimated storage wait in listing: projection-cache reads mostly hit the page cache of the just-written corpus. */
const IO_SHARE = 0.1
function median(values: readonly number[]): number {
return [...values].sort((left, right) => left - right)[Math.floor(values.length / 2)] as number
@@ -68,6 +71,9 @@ it.each(['modest', 'tail', 'cheap'] as const)('serves the %s projection list wit
console.log(JSON.stringify({ benchmark: `session-corpus/projection-list-${workload}`,
reports, medians, budgets, environment: { node: process.version, platform: process.platform, arch: process.arch, cpu: cpus()[0]?.model } }))
recordTimings(`session-corpus/projection-list-${workload}`, IO_SHARE, {
firstListAndJsonMs: medians.firstListAndJsonMs, repeatListAndJsonMs: medians.repeatListAndJsonMs, worstCallbackDelayMs: medians.worstCallbackDelayMs,
}, { firstListAndJsonMs: budgets.listAndJsonMs, worstCallbackDelayMs: budgets.callbackDelayMs })
for (const report of reports) {
expect(report.workload).toBe(workload)
expect(report.fixture.sessions).toBe({ modest: 50, tail: 300, cheap: 3_000 }[workload])
@@ -7,6 +7,7 @@ import { performance } from 'node:perf_hooks'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { runBuiltBenchmarkWorker } from '../support/built-worker.ts'
import { PERFORMANCE_BUDGET_HEADROOM } from '../support/calibration.ts'
import { recordPeakMemory, recordTimings } from '../support/scaling-report.ts'
import { anchorShape, ANCHOR_COUNT, sessionShape } from './corpus-shape.ts'
import { subagentRank } from './synthetic-corpus.ts'
import type {
@@ -69,6 +70,12 @@ const EXPECTED_CI_PEAK_RSS_MB = {
fork: 750,
} as const satisfies Record<keyof typeof RECORDED_CI_PEAK_RSS_MB, number>
/**
* Estimated storage-wait shares: listing and search read a just-written corpus mostly from the page cache;
* forking appends and synchronizes a new Session log.
*/
const IO_SHARE = { list: 0.1, search: 0.1, fork: 0.3 } as const
type CorpusName = keyof typeof CORPUS
function topLevelRank(rank: number): number {
@@ -221,6 +228,10 @@ describe('Session corpus operations', () => {
heapUsedMb: reports.map(report => report.memory.heapUsedMb),
}
console.log(JSON.stringify({ benchmark: `session-corpus/list-${String(CORPUS.list)}`, samples, budgets, environment: environment() }))
recordTimings(`session-corpus/list-${String(CORPUS.list)}`, IO_SHARE.list,
{ bootMs: median(samples.bootMs), firstMs: median(samples.firstMs), repeatMs: median(samples.repeatMs) },
{ bootMs: budgets.bootMs, firstMs: budgets.firstMs, repeatMs: budgets.repeatMs })
recordPeakMemory(`session-corpus/list-${String(CORPUS.list)}`, { peakRssMb: Math.max(...samples.peakRssMb) })
for (const report of reports) expect(report.itemsWithProjections).toBe(CORPUS.list)
expectWithinBudget(median(samples.bootMs), budgets.bootMs)
expectWithinBudget(median(samples.firstMs), budgets.firstMs)
@@ -237,6 +248,9 @@ describe('Session corpus operations', () => {
// One sample: the cold index build dominates this file's time.
const report = await run<SearchReport>([root('search'), 'search'])
console.log(JSON.stringify({ benchmark: 'session-corpus/search', report, budgets, environment: environment() }))
recordTimings('session-corpus/search', IO_SHARE.search, { firstMs: report.firstMs, repeatMs: report.repeatMs },
{ firstMs: budgets.firstMs, repeatMs: budgets.repeatMs })
recordPeakMemory('session-corpus/search', { peakRssMb: report.memory.peakRssMb })
expectWithinBudget(report.firstMs, budgets.firstMs)
expectWithinBudget(report.repeatMs, budgets.repeatMs)
expectWithinBudget(report.memory.peakRssMb, budgets.peakRssMb)
@@ -267,6 +281,10 @@ describe('Session corpus operations', () => {
peakRssMb: [...reports, longest].map(report => report.memory.peakRssMb),
}
console.log(JSON.stringify({ benchmark: 'session-corpus/fork', samples, budgets, environment: environment() }))
recordTimings('session-corpus/fork', IO_SHARE.fork,
{ stratumMedianMs: median(samples.stratumMedianMs), p99Ms: median(samples.p99Ms), longestMs: samples.longestMs },
{ stratumMedianMs: budgets.stratumMedianMs, p99Ms: budgets.p99Ms, longestMs: budgets.longestMs })
recordPeakMemory('session-corpus/fork', { peakRssMb: Math.max(...samples.peakRssMb) })
expectWithinBudget(median(samples.stratumMedianMs), budgets.stratumMedianMs)
expectWithinBudget(median(samples.p99Ms), budgets.p99Ms)
expectWithinBudget(samples.longestMs, budgets.longestMs)
@@ -15,6 +15,7 @@ import {
ciTimeBudget,
PERFORMANCE_BUDGET_HEADROOM,
} from '../support/calibration.ts'
import { recordTimings } from '../support/scaling-report.ts'
import type {
SessionOpenBenchmarkScenario,
SessionOpenWorkerReport,
@@ -76,6 +77,8 @@ const AGENT_RETAINED_HEAP_BUDGET_MB = Math.ceil(
)
const WORKER = join(import.meta.dirname, '..', '.dsh-build', 'session-open', 'session-open.worker.js')
/** Estimated storage wait: each fresh process reads one Session log that the page cache usually holds. */
const IO_SHARE = 0.1
type WorkerRun = BuiltBenchmarkWorkerRun<SessionOpenWorkerReport>
@@ -393,6 +396,10 @@ describe('opening a large Session for first open and post-upgrade reopen', () =>
projection: PROJECTION_BUDGET_MS,
},
}))
recordTimings(`session-open/${access.accessKind}/phases`, IO_SHARE, {
openMs: result.openMs.median, readMs: result.readMs.median,
sessionRestoreMs: result.sessionRestoreMs.median, projectionMs: result.projectionMs.median,
}, { openMs: access.openBudgetMs, readMs: READ_BUDGET_MS, sessionRestoreMs: SESSION_RESTORE_BUDGET_MS, projectionMs: PROJECTION_BUDGET_MS })
expectOpenWithinBudget(result.openMs.median, access.openBudgetMs)
expect(result.readMs.median).toBeLessThanOrEqual(READ_BUDGET_MS)
expect(result.sessionRestoreMs.median).toBeLessThanOrEqual(SESSION_RESTORE_BUDGET_MS)
@@ -417,6 +424,7 @@ describe('opening a large Session for first open and post-upgrade reopen', () =>
result,
budgetMs: access.firstHistoryBudgetMs,
}))
recordTimings(`session-open/${access.accessKind}/first-history`, IO_SHARE, { totalMs: result.totalMs.median }, { totalMs: access.firstHistoryBudgetMs })
expect(result.totalMs.median).toBeLessThanOrEqual(access.firstHistoryBudgetMs)
})
@@ -439,6 +447,7 @@ describe('opening a large Session for first open and post-upgrade reopen', () =>
budgetMs: access.agentResumeBudgetMs,
retainedHeapBudgetMb: AGENT_RETAINED_HEAP_BUDGET_MB,
}))
recordTimings(`session-open/${access.accessKind}/agent-resume`, IO_SHARE, { totalMs: result.totalMs.median }, { totalMs: access.agentResumeBudgetMs })
expect(result.totalMs.median).toBeLessThanOrEqual(access.agentResumeBudgetMs)
expect(result.retainedHeapMb.median).toBeLessThanOrEqual(AGENT_RETAINED_HEAP_BUDGET_MB)
})
+59
View File
@@ -0,0 +1,59 @@
/**
* Reviewed machine profiles for the scaled-scenario benchmark report.
*
* `cpuScore` is the PassMark single-thread rating retrieved on 2026-10-07 from cpubenchmark.net. Measured
* endpoints run on one JavaScript thread, so the report scales their CPU-bound share by the single-thread
* ratio; core count and memory size are not modelled. PassMark ratings across ISAs, and for burstable or
* shared vCPUs, are coarse.
*
* `ioLatencyUs` is a typical synchronous 4 KiB random-access latency at queue depth 1 for the profile's
* default storage. Laptop values come from published SSD reviews for the interface generation; cloud values
* come from the provider's documented latency class for its default system disk. They are estimates.
*/
/** Group shown as one row of scenario choices in the report. */
export type MachineProfileGroup = 'laptop' | 'cloud' | 'reference'
/** One modelled machine with its CPU and default-storage performance. */
export interface MachineProfile {
readonly id: string
readonly group: MachineProfileGroup
readonly label: string
readonly cpu: string
readonly cpuScore: number
readonly storage: string
readonly ioLatencyUs: number
/** Lower-case substrings of `os.cpus()[0].model` that identify the measuring machine as this profile. */
readonly cpuModelMatches?: readonly string[]
}
/** Profiles in report order. */
export const MACHINE_PROFILES: readonly MachineProfile[] = [
{ id: 'laptop-2015-win', group: 'laptop', label: '2015 Windows laptop', cpu: 'Intel Core i5-5200U', cpuScore: 1486, storage: 'SATA SSD', ioLatencyUs: 90 },
{ id: 'laptop-2015-mac', group: 'laptop', label: '2015 MacBook Pro 13"', cpu: 'Intel Core i5-5257U', cpuScore: 1737, storage: 'PCIe AHCI SSD', ioLatencyUs: 90 },
{ id: 'laptop-2019-win', group: 'laptop', label: '2019 Windows laptop', cpu: 'Intel Core i5-8265U', cpuScore: 2003, storage: 'NVMe PCIe 3.0 SSD', ioLatencyUs: 70 },
{ id: 'laptop-2023-win', group: 'laptop', label: '2023 Windows laptop', cpu: 'Intel Core i5-1335U', cpuScore: 3241, storage: 'NVMe PCIe 4.0 SSD', ioLatencyUs: 55 },
{ id: 'laptop-2023-mac', group: 'laptop', label: '2023 MacBook Air', cpu: 'Apple M2', cpuScore: 4208, storage: 'Apple SSD', ioLatencyUs: 55, cpuModelMatches: ['apple m2'] },
{ id: 'laptop-2026-win', group: 'laptop', label: '2026 Windows laptop', cpu: 'Intel Core Ultra 5 225H', cpuScore: 4247, storage: 'NVMe PCIe 4.0 SSD', ioLatencyUs: 50 },
{ id: 'laptop-2026-mac', group: 'laptop', label: '2026 MacBook', cpu: 'Apple M5', cpuScore: 6341, storage: 'Apple SSD', ioLatencyUs: 50, cpuModelMatches: ['apple m5'] },
{ id: 'aws-t3', group: 'cloud', label: 'AWS t3.medium', cpu: 'Intel Xeon Platinum 8259CL', cpuScore: 1948, storage: 'EBS gp3', ioLatencyUs: 500 },
{ id: 'aws-m6i', group: 'cloud', label: 'AWS m6i.large', cpu: 'Intel Xeon Platinum 8375C', cpuScore: 2474, storage: 'EBS gp3', ioLatencyUs: 500 },
{ id: 'aws-m7i', group: 'cloud', label: 'AWS m7i.large', cpu: 'Intel Xeon Platinum 8488C', cpuScore: 3113, storage: 'EBS gp3', ioLatencyUs: 500 },
{ id: 'aws-m7g', group: 'cloud', label: 'AWS m7g.large', cpu: 'AWS Graviton3 (Neoverse V1)', cpuScore: 1554, storage: 'EBS gp3', ioLatencyUs: 500 },
{ id: 'gcp-n2', group: 'cloud', label: 'GCP n2-standard-4', cpu: 'Intel Xeon Platinum 8273CL', cpuScore: 2200, storage: 'pd-balanced', ioLatencyUs: 800 },
{ id: 'azure-d4s-v5', group: 'cloud', label: 'Azure D4s v5', cpu: 'Intel Xeon Ice Lake (8375C rating)', cpuScore: 2474, storage: 'Premium SSD P10', ioLatencyUs: 1000 },
{ id: 'aliyun-g7', group: 'cloud', label: 'Alibaba Cloud ecs.g7.xlarge', cpu: 'Intel Xeon Ice Lake (8375C rating)', cpuScore: 2474, storage: 'ESSD PL0', ioLatencyUs: 400 },
{ id: 'github-ubuntu-24.04', group: 'reference', label: 'GitHub ubuntu-24.04 runner', cpu: 'AMD EPYC 7763', cpuScore: 2517, storage: 'Azure managed disk', ioLatencyUs: 300, cpuModelMatches: ['epyc 7763'] },
{ id: 'github-ubuntu-24.04-genoa', group: 'reference', label: 'GitHub ubuntu-24.04 runner (Genoa)', cpu: 'AMD EPYC 9V74', cpuScore: 2888, storage: 'Azure managed disk', ioLatencyUs: 300, cpuModelMatches: ['epyc 9v74'] },
{ id: 'reference-m4-pro', group: 'reference', label: 'Calibration reference', cpu: 'Apple M4 Pro', cpuScore: 4547, storage: 'Apple SSD', ioLatencyUs: 55, cpuModelMatches: ['apple m4'] },
]
/**
* Identify the measuring machine among the reviewed profiles.
* @param cpuModel - `os.cpus()[0].model` of the machine that ran the benchmarks.
* @returns the matching profile id, or `undefined` when no profile describes the machine.
*/
export function matchMachineProfile(cpuModel: string): string | undefined {
const model = cpuModel.toLowerCase()
return MACHINE_PROFILES.find(profile => profile.cpuModelMatches?.some(match => model.includes(match)) === true)?.id
}
+251
View File
@@ -0,0 +1,251 @@
/**
* Collects budgeted benchmark wall-clock endpoints and renders them as a self-contained interactive HTML
* report that rescales each endpoint to the reviewed machine profiles.
*
* Bench files call {@link recordTimings} in the Vitest worker; the Vitest global setup of
* `vitest.bench.config.ts` clears {@link REPORT_DIRECTORY} before the run and renders `report.html` after
* it, including after failed cases.
*
* A scaled endpoint is `ms × ((1 − io) × cpuScore(measured) / cpuScore(target) + io × ioLatency(target) /
* ioLatency(measured))`, where `io` is the declared share of the endpoint's wall time spent waiting on
* storage. The report lets readers change that share. Memory and dimensionless ratios are not scaled.
*
* Bench files also pass peak memory to {@link recordPeakMemory}. The report compares those unscaled peaks
* with 4 GB and 8 GB of available memory; it does not run cases under an operating-system memory limit.
*/
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { cpus } from 'node:os'
import { join } from 'node:path'
import { MACHINE_PROFILES, matchMachineProfile } from './machine-profiles.ts'
/** Directory owning `results.jsonl` and `report.html`; ignored by Git. */
export const REPORT_DIRECTORY = join(import.meta.dirname, '..', '.dsh-report')
const RESULTS_PATH = join(REPORT_DIRECTORY, 'results.jsonl')
const REPORT_PATH = join(REPORT_DIRECTORY, 'report.html')
/** One recorded wall-clock endpoint. */
export interface TimingRecord {
readonly kind: 'time'
readonly benchmark: string
readonly metric: string
readonly ms: number
readonly budgetMs?: number
readonly ioShare: number
}
/**
* Append measured endpoints of one benchmark case to the report results.
* @param benchmark - Benchmark case id, such as `session-open/current/phases`.
* @param ioShare - Declared share, from 0 to 1, of the endpoints' wall time spent waiting on storage.
* @param timings - Measured wall-clock milliseconds keyed by endpoint name; record before asserting budgets.
* @param budgetsMs - CI budgets keyed by the same endpoint names, where the case enforces one.
*/
export function recordTimings(
benchmark: string,
ioShare: number,
timings: Readonly<Record<string, number>>,
budgetsMs: Readonly<Record<string, number>> = {},
): void {
appendRecords(Object.entries(timings).map(([metric, ms]): TimingRecord => {
const budgetMs = budgetsMs[metric]
return { kind: 'time', benchmark, metric, ms, ioShare, ...budgetMs === undefined ? {} : { budgetMs } }
}))
}
/** One recorded peak memory measurement. */
export interface MemoryRecord {
readonly kind: 'memory'
readonly benchmark: string
readonly metric: string
readonly mb: number
}
/**
* Append peak memory of one benchmark case to the report results.
* @param benchmark - Benchmark case id, such as `session-corpus/search`.
* @param peaksMb - Peak megabytes keyed by measurement name, such as `peakRssMb`.
*/
export function recordPeakMemory(benchmark: string, peaksMb: Readonly<Record<string, number>>): void {
appendRecords(Object.entries(peaksMb).map(([metric, mb]): MemoryRecord => ({ kind: 'memory', benchmark, metric, mb })))
}
function appendRecords(records: readonly (TimingRecord | MemoryRecord)[]): void {
mkdirSync(REPORT_DIRECTORY, { recursive: true })
appendFileSync(RESULTS_PATH, records.map(record => JSON.stringify(record) + '\n').join(''))
}
/** Vitest global setup: discard results of an earlier run. */
export function setup(): void {
rmSync(REPORT_DIRECTORY, { recursive: true, force: true })
}
/** Vitest global teardown: render the report when any case recorded a timing. */
export function teardown(): void {
if (!existsSync(RESULTS_PATH)) return
const records = readFileSync(RESULTS_PATH, 'utf8').split('\n').filter(line => line !== '').map(line => JSON.parse(line) as TimingRecord | MemoryRecord)
const cpuModel = cpus()[0]?.model.trim() ?? 'unknown CPU'
writeFileSync(REPORT_PATH, renderReport({
records,
cpuModel,
platform: `${process.platform}-${process.arch}`,
node: process.version,
revision: process.env['GITHUB_SHA'],
generatedAt: new Date().toISOString(),
measuredProfile: matchMachineProfile(cpuModel),
}))
console.log(`benchmark report: ${REPORT_PATH}`)
}
/** Inputs embedded in one rendered report. */
export interface ReportData {
readonly records: readonly (TimingRecord | MemoryRecord)[]
readonly cpuModel: string
readonly platform: string
readonly node: string
readonly revision: string | undefined
readonly generatedAt: string
/** Profile id describing the measuring machine; the reader chooses one when absent. */
readonly measuredProfile: string | undefined
}
/**
* Render the self-contained report page.
* @param data - Recorded endpoints and the measuring environment.
* @returns HTML without external resources.
*/
export function renderReport(data: ReportData): string {
const payload = JSON.stringify({ ...data, profiles: MACHINE_PROFILES }).replaceAll('<', '\\u003c')
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DeepSeek Harness benchmark scenarios</title>
<style>
body { font: 14px/1.45 system-ui, sans-serif; margin: 24px; color: #1f2328; }
h1 { font-size: 20px; margin: 0 0 4px; }
h2 { font-size: 16px; margin: 28px 0 4px; }
.meta, .note { color: #59636e; margin: 4px 0; }
.controls { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; margin: 16px 0; }
select { font: inherit; max-width: 420px; }
svg text { font: 12px system-ui, sans-serif; fill: #1f2328; }
table { border-collapse: collapse; margin-top: 16px; font-variant-numeric: tabular-nums; }
th, td { border: 1px solid #d1d9e0; padding: 3px 6px; text-align: right; white-space: nowrap; }
th { background: #f6f8fa; font-weight: 600; }
td:first-child, th:first-child { text-align: left; position: sticky; left: 0; background: #fff; }
tr.selected td:first-child { font-weight: 700; }
tbody tr { cursor: pointer; }
.wrap { overflow-x: auto; }
.b0 { background: #dafbe1; } .b1 { background: #fff8c5; } .b2 { background: #ffebe9; } .b3 { background: #ffcecb; }
.legend span { display: inline-block; padding: 0 6px; margin-right: 6px; }
</style>
</head>
<body>
<h1>DeepSeek Harness benchmark scenarios</h1>
<p class="meta" id="meta"></p>
<h2>Machines</h2>
<p class="note">Each measured endpoint is rescaled to other machines: its CPU share by the PassMark single-thread rating ratio and its storage share by the typical 4 KiB queue-depth-1 latency ratio of the default disk. Profiles are estimates; core count and memory size are not modelled.</p>
<div class="controls">
<label>Measured on <select id="measured"></select></label>
<label>Endpoint <select id="endpoint"></select></label>
<label>Storage-bound share <input id="io" type="range" min="0" max="1" step="0.05"> <output id="io-value"></output></label>
</div>
<svg id="chart" width="960" role="img" aria-label="Scaled endpoint per machine"></svg>
<p class="legend"><span class="b0">&lt; 100 ms</span><span class="b1">&lt; 1 s</span><span class="b2">&lt; 10 s</span><span class="b3">&ge; 10 s</span> Click a row to chart it.</p>
<div class="wrap"><table id="table"></table></div>
<h2>Memory pressure</h2>
<p class="note">Measured peak memory of each case against 4 GB and 8 GB of memory available to DeepSeek Harness. Peaks are not scaled. Cases were not run under an operating-system memory limit, so swapping and reclaim costs are not measured.</p>
<svg id="memory-chart" width="960" role="img" aria-label="Peak memory against available memory"></svg>
<p class="legend"><span class="b0">&lt; 50%</span><span class="b1">&lt; 75%</span><span class="b2">&lt; 100%</span><span class="b3">&ge; 100%</span> of available memory</p>
<div class="wrap"><table id="memory-table"></table></div>
<script>
const data = ${payload};
const $ = id => document.getElementById(id);
const profiles = data.profiles;
const times = data.records.filter(r => r.kind === 'time');
const memories = data.records.filter(r => r.kind === 'memory');
const AVAILABLE_MB = [4096, 8192];
const keyOf = r => r.benchmark + ' · ' + r.metric;
const ioShares = Object.fromEntries(times.map(r => [r.benchmark, r.ioShare]));
let selected = 0;
const fmt = ms => ms >= 10000 ? (ms / 1000).toFixed(0) + ' s' : ms >= 1000 ? (ms / 1000).toFixed(1) + ' s' : ms >= 10 ? ms.toFixed(0) + ' ms' : ms.toFixed(1) + ' ms';
const fmtMb = mb => mb >= 1024 ? (mb / 1024).toFixed(1) + ' GB' : mb.toFixed(0) + ' MB';
const band = ms => ms < 100 ? 0 : ms < 1000 ? 1 : ms < 10000 ? 2 : 3;
const memoryBand = share => share < 0.5 ? 0 : share < 0.75 ? 1 : share < 1 ? 2 : 3;
const colors = ['#1a7f37', '#9a6700', '#cf222e', '#82071e'];
function scale(record, target) {
const base = profiles.find(p => p.id === $('measured').value);
const io = ioShares[record.benchmark];
return record.ms * ((1 - io) * base.cpuScore / target.cpuScore + io * target.ioLatencyUs / base.ioLatencyUs);
}
function esc(text) { const node = document.createElement('span'); node.textContent = text; return node.innerHTML; }
/** Draws one horizontal bar per row on a logarithmic axis with labelled vertical ticks. */
function bars(svg, rows, ticks, format) {
const left = 380, width = 500, rowH = 22, top = 24;
const values = rows.map(r => r.value).concat(ticks.filter(t => t.marked).map(t => t.value));
const min = Math.min(...values) / 2, max = Math.max(...values) * 2;
const x = value => left + width * Math.log(value / min) / Math.log(max / min);
let html = '';
for (const t of ticks) {
if (t.value < min || t.value > max) continue;
html += '<line x1="' + x(t.value) + '" x2="' + x(t.value) + '" y1="' + (top - 6) + '" y2="' + (top + rows.length * rowH) + '" stroke="' + (t.marked ? '#59636e' : '#d1d9e0') + '"' + (t.marked ? ' stroke-dasharray="4 3"' : '') + '/><text x="' + x(t.value) + '" y="' + (top - 10) + '" text-anchor="middle">' + esc(t.label) + '</text>';
}
rows.forEach((r, i) => {
const y = top + i * rowH;
html += '<text x="' + (left - 8) + '" y="' + (y + 15) + '" text-anchor="end">' + esc(r.label) + '</text>';
html += '<rect x="' + left + '" y="' + (y + 3) + '" width="' + Math.max(1, x(r.value) - left) + '" height="' + (rowH - 6) + '" fill="' + colors[r.band] + '"' + (r.current ? ' stroke="#1f2328" stroke-width="2"' : '') + '><title>' + esc(r.title) + '</title></rect>';
html += '<text x="' + (x(r.value) + 6) + '" y="' + (y + 15) + '">' + format(r.value) + '</text>';
});
svg.setAttribute('height', String(top + rows.length * rowH + 8));
svg.innerHTML = html;
}
$('meta').textContent = 'Measured on ' + data.cpuModel + ' (' + data.platform + ', Node ' + data.node + ')' + (data.revision ? ', revision ' + data.revision.slice(0, 12) : '') + ', ' + data.generatedAt + '.';
for (const p of profiles) $('measured').add(new Option(p.label + ' — ' + p.cpu, p.id));
$('measured').value = data.measuredProfile ?? 'github-ubuntu-24.04';
if (!data.measuredProfile) $('meta').textContent += ' No profile matches this CPU; choose the closest one.';
times.forEach((r, i) => $('endpoint').add(new Option(keyOf(r), String(i))));
function table() {
let html = '<thead><tr><th>Endpoint</th><th>Measured</th><th>CI budget</th>' + profiles.map(p => '<th title="' + esc(p.cpu + ' · ' + p.storage) + '">' + esc(p.label) + '</th>').join('') + '</tr></thead><tbody>';
times.forEach((r, i) => {
html += '<tr data-index="' + i + '"' + (i === selected ? ' class="selected"' : '') + '><td>' + esc(keyOf(r)) + '</td><td>' + fmt(r.ms) + '</td><td>' + (r.budgetMs === undefined ? '' : fmt(r.budgetMs)) + '</td>';
for (const p of profiles) { const ms = scale(r, p); html += '<td class="b' + band(ms) + '">' + fmt(ms) + '</td>'; }
html += '</tr>';
});
$('table').innerHTML = html + '</tbody>';
}
function render() {
if (times.length === 0) return;
const record = times[selected];
$('endpoint').value = String(selected);
$('io').value = String(ioShares[record.benchmark]);
$('io-value').textContent = Math.round(ioShares[record.benchmark] * 100) + '%';
bars($('chart'), profiles.map(p => {
const ms = scale(record, p);
return { label: p.label + ' · ' + p.storage, title: p.cpu, value: ms, band: band(ms), current: p.id === $('measured').value };
}), [1, 10, 100, 1000, 10000, 100000, 1000000].map(value => ({ value, label: fmt(value), marked: value === 100 || value === 1000 })), fmt);
table();
}
function renderMemory() {
if (memories.length === 0) return;
bars($('memory-chart'), memories.map(r => ({ label: keyOf(r), title: keyOf(r), value: r.mb, band: memoryBand(r.mb / AVAILABLE_MB[0]), current: false })),
[16, 64, 256, 1024].map(value => ({ value, label: fmtMb(value), marked: false })).concat(AVAILABLE_MB.map(value => ({ value, label: fmtMb(value) + ' available', marked: true }))), fmtMb);
let html = '<thead><tr><th>Case</th><th>Peak</th>' + AVAILABLE_MB.map(mb => '<th>' + fmtMb(mb) + ' available</th>').join('') + '</tr></thead><tbody>';
for (const r of memories) {
html += '<tr><td>' + esc(keyOf(r)) + '</td><td>' + fmtMb(r.mb) + '</td>';
for (const mb of AVAILABLE_MB) html += '<td class="b' + memoryBand(r.mb / mb) + '">' + Math.round(100 * r.mb / mb) + '%</td>';
html += '</tr>';
}
$('memory-table').innerHTML = html + '</tbody>';
}
$('measured').onchange = render;
$('endpoint').onchange = () => { selected = Number($('endpoint').value); render(); };
$('io').oninput = () => { ioShares[times[selected].benchmark] = Number($('io').value); render(); };
$('table').onclick = event => { const row = event.target.closest('tr[data-index]'); if (row) { selected = Number(row.dataset.index); render(); } };
render();
renderMemory();
</script>
</body>
</html>
`
}
@@ -3,6 +3,7 @@ import { join } from 'node:path'
import { expect, it } from 'vitest'
import { runBuiltBenchmarkWorker } from '../support/built-worker.ts'
import { ciTimeBudget } from '../support/calibration.ts'
import { recordTimings } from '../support/scaling-report.ts'
import type { TerminalIoReport } from './terminal-io.worker.ts'
const MIB = 1024 * 1024
@@ -11,6 +12,8 @@ const WORKER = join(import.meta.dirname, '..', '.dsh-build', 'terminal-io', 'ter
/** M5 Pro / Node 26.5 reference expectations, before shared CI scaling and headroom. */
const EXPECTED_MS = { steadyIngest: 20, steadyComplete: 50, fullComplete: 120 }
const MAX_CAPACITY_RATIO = 4
/** Terminal output is buffered in memory; no storage access is timed. */
const IO_SHARE = 0
const MAX_RETAINED_HEAP_BYTES = 16 * MIB
function median(values: readonly number[]): number {
@@ -38,6 +41,10 @@ it('bounds steady overflow cost as retained terminal capacity grows 32 times', a
const ingestBudgetMs = ciTimeBudget(EXPECTED_MS.steadyIngest)
const completeBudgetMs = ciTimeBudget(EXPECTED_MS.steadyComplete)
console.log(JSON.stringify({ scenario: 'terminal-steady', small, large, capacityRatio, ingestBudgetMs, completeBudgetMs }))
recordTimings('terminal-io/steady', IO_SHARE, {
smallIngestMs: median(small.map(row => row.ingestMs)), largeIngestMs: median(large.map(row => row.ingestMs)),
smallCompleteMs: median(small.map(row => row.completeMs)), largeCompleteMs: median(large.map(row => row.completeMs)),
}, { smallIngestMs: ingestBudgetMs, largeIngestMs: ingestBudgetMs, smallCompleteMs: completeBudgetMs, largeCompleteMs: completeBudgetMs })
expect(capacityRatio).toBeLessThanOrEqual(MAX_CAPACITY_RATIO)
for (const rows of [small, large]) {
expect(median(rows.map(row => row.ingestMs))).toBeLessThanOrEqual(ingestBudgetMs)
@@ -64,6 +71,7 @@ it('completes a five MiB terminal send with bounded retained output', async () =
const completeMedianMs = median(samples.map(row => row.completeMs))
const completeBudgetMs = ciTimeBudget(EXPECTED_MS.fullComplete)
console.log(JSON.stringify({ scenario: 'terminal-five-mib', samples, completeMedianMs, completeBudgetMs }))
recordTimings('terminal-io/five-mib', IO_SHARE, { completeMs: completeMedianMs }, { completeMs: completeBudgetMs })
expect(completeMedianMs).toBeLessThanOrEqual(completeBudgetMs)
expect(Math.max(...samples.map(row => row.retainedHeapBytes))).toBeLessThanOrEqual(MAX_RETAINED_HEAP_BYTES)
})
+2
View File
@@ -14,6 +14,8 @@ export default defineConfig({
test: {
execArgv: vitestExecArgv,
setupFiles: ['./scripts/test-proxy-environment.ts'],
// Clears recorded timings before the run and renders benchmarks/.dsh-report/report.html after it.
globalSetup: ['./benchmarks/support/scaling-report.ts'],
include: [
'benchmarks/**/*.bench.ts',
'benchmarks/**/*.bench.client.ts',