diff --git a/packages/treecrdt-benchmark/src/index.ts b/packages/treecrdt-benchmark/src/index.ts index 7faecb4c..b3dfa1f2 100644 --- a/packages/treecrdt-benchmark/src/index.ts +++ b/packages/treecrdt-benchmark/src/index.ts @@ -1,6 +1,5 @@ import type { TreecrdtAdapter, SerializeNodeId, SerializeReplica, Operation } from "@treecrdt/interface"; import { nodeIdToBytes16, replicaIdToBytes } from "@treecrdt/interface/ids"; -import { envInt, quantile } from "./stats.js"; import type { WorkloadName } from "./workloads.js"; export type BenchmarkResult = { @@ -14,8 +13,6 @@ export type BenchmarkResult = { export type BenchmarkWorkload = { name: string; totalOps?: number; - iterations?: number; - warmupIterations?: number; prepare?: () => Promise | void; run: (adapter: TreecrdtAdapter) => Promise }>; cleanup?: () => Promise | void; @@ -47,33 +44,21 @@ export async function runBenchmark( ): Promise { const totalOps = workload.totalOps ?? -1; - const envIterations = envInt("BENCH_ITERATIONS"); - const envWarmup = envInt("BENCH_WARMUP"); - const rawIterations = Math.max(1, workload.iterations ?? envIterations ?? 1); - const minIterationsForTiny = - totalOps >= 1 && totalOps <= 100 ? 10 : totalOps <= 1000 ? 7 : 1; - const iterations = Math.max(minIterationsForTiny, rawIterations); - const warmupIterations = Math.max(0, workload.warmupIterations ?? envWarmup ?? (iterations > 1 ? 1 : 0)); - - const samplesMs: number[] = []; + const adapter = await adapterFactory(); + let durationMs: number; let lastExtra: Record | undefined; - - for (let i = 0; i < warmupIterations + iterations; i += 1) { - const adapter = await adapterFactory(); - try { - if (workload.prepare) await workload.prepare(); - const start = performance.now(); - const runResult = await workload.run(adapter); - const end = performance.now(); - if (runResult && typeof runResult === "object" && runResult.extra) lastExtra = runResult.extra; - if (workload.cleanup) await workload.cleanup(); - if (i >= warmupIterations) samplesMs.push(end - start); - } finally { - if (adapter.close) await adapter.close(); - } + try { + if (workload.prepare) await workload.prepare(); + const start = performance.now(); + const runResult = await workload.run(adapter); + const end = performance.now(); + durationMs = end - start; + if (runResult && typeof runResult === "object" && runResult.extra) lastExtra = runResult.extra; + if (workload.cleanup) await workload.cleanup(); + } finally { + if (adapter.close) await adapter.close(); } - const durationMs = quantile(samplesMs, 0.5); const opsPerSec = totalOps > 0 && durationMs > 0 ? (totalOps / durationMs) * 1000 @@ -85,18 +70,7 @@ export async function runBenchmark( totalOps, durationMs, opsPerSec, - extra: - lastExtra || samplesMs.length > 1 - ? { - ...(lastExtra ?? {}), - iterations, - warmupIterations, - samplesMs, - p95Ms: quantile(samplesMs, 0.95), - minMs: Math.min(...samplesMs), - maxMs: Math.max(...samplesMs), - } - : undefined, + extra: lastExtra ?? undefined, }; } @@ -278,6 +252,5 @@ export async function runWorkloads( } export { DEFAULT_BENCH_SIZES, WORKLOAD_NAMES, type WorkloadName } from "./workloads.js"; -export { benchTiming } from "./timing.js"; export * from "./sync.js"; export * from "./stats.js"; diff --git a/packages/treecrdt-benchmark/src/sync.ts b/packages/treecrdt-benchmark/src/sync.ts index fe4e9229..f492077e 100644 --- a/packages/treecrdt-benchmark/src/sync.ts +++ b/packages/treecrdt-benchmark/src/sync.ts @@ -1,7 +1,6 @@ import type { Operation, OperationKind, ReplicaId } from "@treecrdt/interface"; import { nodeIdToBytes16 } from "@treecrdt/interface/ids"; import { envIntList } from "./stats.js"; -import { benchTiming } from "./timing.js"; export type SyncBenchWorkload = | "sync-all" @@ -27,14 +26,6 @@ export function syncBenchRootChildrenSizesFromEnv(): number[] { return envIntList("SYNC_BENCH_ROOT_CHILDREN_SIZES") ?? Array.from(DEFAULT_SYNC_BENCH_ROOT_CHILDREN_SIZES); } -export function syncBenchTiming(opts: { defaultIterations?: number } = {}): { iterations: number; warmupIterations: number } { - return benchTiming({ - iterationsEnv: ["SYNC_BENCH_ITERATIONS", "BENCH_ITERATIONS"], - warmupEnv: ["SYNC_BENCH_WARMUP", "BENCH_WARMUP"], - defaultIterations: opts.defaultIterations ?? 10, - }); -} - export type SyncFilter = | { all: Record } | { children: { parent: Uint8Array } }; diff --git a/packages/treecrdt-benchmark/src/timing.ts b/packages/treecrdt-benchmark/src/timing.ts deleted file mode 100644 index 7cb8c1b3..00000000 --- a/packages/treecrdt-benchmark/src/timing.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { envInt } from "./stats.js"; - -type EnvKey = string | readonly string[]; - -function firstEnvInt(keys: EnvKey): number | undefined { - if (typeof keys === "string") return envInt(keys); - for (const key of keys) { - const val = envInt(key); - if (val !== undefined) return val; - } - return undefined; -} - -export function benchTiming(opts: { - iterationsEnv?: EnvKey; - warmupEnv?: EnvKey; - defaultIterations?: number; -} = {}): { iterations: number; warmupIterations: number } { - const iterationsEnv = opts.iterationsEnv ?? "BENCH_ITERATIONS"; - const warmupEnv = opts.warmupEnv ?? "BENCH_WARMUP"; - const iterations = Math.max(1, firstEnvInt(iterationsEnv) ?? opts.defaultIterations ?? 1); - const warmupIterations = Math.max(0, firstEnvInt(warmupEnv) ?? (iterations > 1 ? 1 : 0)); - return { iterations, warmupIterations }; -} - diff --git a/packages/treecrdt-core/benches/core.rs b/packages/treecrdt-core/benches/core.rs index 9198a6d7..d9f5ee83 100644 --- a/packages/treecrdt-core/benches/core.rs +++ b/packages/treecrdt-core/benches/core.rs @@ -5,7 +5,7 @@ use std::time::Instant; use treecrdt_core::{Lamport, LamportClock, MemoryStorage, NodeId, ReplicaId, TreeCrdt}; -const BENCH_CONFIG: &[(u64, u64)] = &[(100, 10), (1_000, 10), (10_000, 10)]; +const BENCH_COUNTS: &[u64] = &[100, 1_000, 10_000]; #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] @@ -27,10 +27,6 @@ struct Output { struct Extra { count: u64, mode: &'static str, - #[serde(skip_serializing_if = "Option::is_none")] - iterations: Option, - #[serde(skip_serializing_if = "Option::is_none")] - avg_duration_ms: Option, } fn hex_id(n: u64) -> NodeId { @@ -69,42 +65,28 @@ fn run_benchmark(replica: &ReplicaId, count: u64) -> f64 { fn main() { let mut out_dir: Option = None; - let mut custom_config: Option> = None; + let mut custom_counts: Option> = None; for arg in env::args().skip(1) { if let Some(val) = arg.strip_prefix("--count=") { let count = val.parse().unwrap_or(500); - custom_config = Some(vec![(count, 1)]); + custom_counts = Some(vec![count]); } else if let Some(val) = arg.strip_prefix("--counts=") { - let parsed: Vec<(u64, u64)> = val - .split(',') - .filter_map(|s| s.trim().parse::().ok()) - .map(|c| (c, 1)) - .collect(); + let parsed: Vec = val.split(',').filter_map(|s| s.trim().parse().ok()).collect(); if !parsed.is_empty() { - custom_config = Some(parsed); + custom_counts = Some(parsed); } } else if let Some(val) = arg.strip_prefix("--out-dir=") { out_dir = Some(PathBuf::from(val)); } } - let config = custom_config.as_deref().unwrap_or(BENCH_CONFIG); + let counts = custom_counts.as_deref().unwrap_or(BENCH_COUNTS); let out_dir = out_dir.unwrap_or_else(default_out_dir); fs::create_dir_all(&out_dir).expect("mkdirs"); let replica = ReplicaId::new(b"core"); - for &(count, iterations) in config { - let (duration_ms, iterations_opt, avg_duration_ms) = if iterations > 1 { - let mut durations: Vec = - (0..iterations).map(|_| run_benchmark(&replica, count)).collect(); - durations.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - let median = durations[durations.len() / 2]; - (median, Some(iterations), Some(median)) - } else { - // Single run - let duration = run_benchmark(&replica, count); - (duration, None, None) - }; + for &count in counts { + let duration_ms = run_benchmark(&replica, count); let workload_name = format!("insert-move-{}", count); let out_path = out_dir.join(format!("memory-{}.json", workload_name)); @@ -125,8 +107,6 @@ fn main() { extra: Extra { count, mode: "sequential", - iterations: iterations_opt, - avg_duration_ms, }, source_file: Some(out_path.display().to_string()), }; diff --git a/packages/treecrdt-sqlite-node/scripts/bench-sync.ts b/packages/treecrdt-sqlite-node/scripts/bench-sync.ts index 8ef23738..07ad56c4 100644 --- a/packages/treecrdt-sqlite-node/scripts/bench-sync.ts +++ b/packages/treecrdt-sqlite-node/scripts/bench-sync.ts @@ -8,7 +8,6 @@ import { SYNC_BENCH_DEFAULT_CODEWORDS_PER_MESSAGE, SYNC_BENCH_DEFAULT_MAX_CODEWORDS, maxLamport, - quantile, type SyncBenchWorkload, } from "@treecrdt/benchmark"; import { repoRootFromImportMeta, writeResult } from "@treecrdt/benchmark/node"; @@ -28,58 +27,33 @@ import { } from "../dist/index.js"; type StorageKind = "memory" | "file"; -type ConfigEntry = [number, number]; -const SYNC_BENCH_CONFIG: ReadonlyArray = [ - [100, 10], - [1_000, 5], - [10_000, 10], -]; +const SYNC_BENCH_COUNTS: readonly number[] = [100, 1_000, 10_000]; +const SYNC_BENCH_ROOT_COUNTS: readonly number[] = [1110]; -const SYNC_BENCH_ROOT_CONFIG: ReadonlyArray = [[1110, 10]]; - -function envInt(name: string): number | undefined { - const raw = process.env[name]; - if (raw == null || raw === "") return undefined; - const n = Number(raw); - return Number.isFinite(n) ? n : undefined; -} - -function parseConfigFromArgv(argv: string[]): Array | null { - let customConfig: Array | null = null; - const defaultIterations = Math.max(1, envInt("BENCH_ITERATIONS") ?? 1); +function parseCountsFromArgv(argv: string[]): number[] | null { for (const arg of argv) { if (arg.startsWith("--count=")) { const val = arg.slice("--count=".length).trim(); const count = val ? Number(val) : 500; - customConfig = [[Number.isFinite(count) && count > 0 ? count : 500, defaultIterations]]; - break; + return [Number.isFinite(count) && count > 0 ? count : 500]; } if (arg.startsWith("--counts=")) { - const vals = arg + const nums = arg .slice("--counts=".length) .split(",") - .map((s) => s.trim()) - .filter((s) => s.length > 0); - const parsed = vals - .map((s) => { - const n = Number(s); - return Number.isFinite(n) && n > 0 ? n : null; - }) - .filter((n): n is number => n != null) - .map((c) => [c, defaultIterations] as ConfigEntry); - if (parsed.length > 0) customConfig = parsed; - break; + .map((s) => Number(s.trim())) + .filter((n) => Number.isFinite(n) && n > 0); + if (nums.length > 0) return nums; } } - return customConfig; + return null; } type BenchCase = { storage: StorageKind; workload: SyncBenchWorkload; size: number; - iterations: number; }; type SyncBenchResult = { @@ -211,15 +185,7 @@ async function runBenchCase( benchCase: BenchCase ): Promise { const bench = buildSyncBenchCase({ workload: benchCase.workload, size: benchCase.size }); - const { size, iterations } = benchCase; - - const samplesMs: number[] = []; - for (let i = 0; i < iterations; i += 1) { - samplesMs.push(await runBenchOnce(repoRoot, benchCase, bench)); - } - - const durationMs = - iterations > 1 ? quantile(samplesMs, 0.5) : samplesMs[0] ?? 0; + const durationMs = await runBenchOnce(repoRoot, benchCase, bench); const opsPerSec = durationMs > 0 ? (bench.totalOps / durationMs) * 1000 : Infinity; return { @@ -229,15 +195,9 @@ async function runBenchCase( opsPerSec, extra: { ...bench.extra, - count: size, + count: benchCase.size, codewordsPerMessage: SYNC_BENCH_DEFAULT_CODEWORDS_PER_MESSAGE, maxCodewords: SYNC_BENCH_DEFAULT_MAX_CODEWORDS, - iterations: iterations > 1 ? iterations : undefined, - avgDurationMs: iterations > 1 ? durationMs : undefined, - samplesMs, - p95Ms: quantile(samplesMs, 0.95), - minMs: Math.min(...samplesMs), - maxMs: Math.max(...samplesMs), }, }; } @@ -246,19 +206,19 @@ async function main() { const argv = process.argv.slice(2); const repoRoot = repoRootFromImportMeta(import.meta.url, 3); - const config = parseConfigFromArgv(argv) ?? [...SYNC_BENCH_CONFIG]; - const rootConfig = [...SYNC_BENCH_ROOT_CONFIG]; + const counts = parseCountsFromArgv(argv) ?? [...SYNC_BENCH_COUNTS]; + const rootCounts = [...SYNC_BENCH_ROOT_COUNTS]; const cases: BenchCase[] = []; for (const storage of ["memory", "file"] as const) { for (const workload of DEFAULT_SYNC_BENCH_WORKLOADS) { - for (const [size, iterations] of config) { - cases.push({ storage, workload, size, iterations }); + for (const size of counts) { + cases.push({ storage, workload, size }); } } for (const workload of DEFAULT_SYNC_BENCH_ROOT_CHILDREN_WORKLOADS) { - for (const [size, iterations] of rootConfig) { - cases.push({ storage, workload, size, iterations }); + for (const size of rootCounts) { + cases.push({ storage, workload, size }); } } } diff --git a/packages/treecrdt-sqlite-node/scripts/bench.ts b/packages/treecrdt-sqlite-node/scripts/bench.ts index c221a019..e3b07a7f 100644 --- a/packages/treecrdt-sqlite-node/scripts/bench.ts +++ b/packages/treecrdt-sqlite-node/scripts/bench.ts @@ -1,68 +1,44 @@ import fs from "node:fs/promises"; import path from "node:path"; import Database from "better-sqlite3"; -import { makeWorkload, quantile, runBenchmark } from "@treecrdt/benchmark"; +import { makeWorkload, runBenchmark } from "@treecrdt/benchmark"; import { repoRootFromImportMeta, writeResult } from "@treecrdt/benchmark/node"; import { createSqliteNodeApi, loadTreecrdtExtension } from "../dist/index.js"; type StorageKind = "memory" | "file"; -const INSERT_MOVE_BENCH_CONFIG: ReadonlyArray<[number, number]> = [ - [100, 10], - [1_000, 10], - [10_000, 10], -]; +const INSERT_MOVE_BENCH_COUNTS: readonly number[] = [100, 1_000, 10_000]; const WORKLOAD: "insert-move" = "insert-move"; const STORAGES: ReadonlyArray = ["memory", "file"]; -function envInt(name: string): number | undefined { - const raw = process.env[name]; - if (raw == null || raw === "") return undefined; - const n = Number(raw); - return Number.isFinite(n) ? n : undefined; -} - -function parseConfigFromArgv(argv: string[]): Array<[number, number]> | null { - let customConfig: Array<[number, number]> | null = null; - const defaultIterations = Math.max(1, envInt("BENCH_ITERATIONS") ?? 1); +function parseCountsFromArgv(argv: string[]): number[] | null { for (const arg of argv) { if (arg.startsWith("--count=")) { const val = arg.slice("--count=".length).trim(); const count = val ? Number(val) : 500; - customConfig = [[Number.isFinite(count) && count > 0 ? count : 500, defaultIterations]]; - break; + return [Number.isFinite(count) && count > 0 ? count : 500]; } if (arg.startsWith("--counts=")) { - const vals = arg + const nums = arg .slice("--counts=".length) .split(",") - .map((s) => s.trim()) - .filter((s) => s.length > 0); - const parsed = vals - .map((s) => { - const n = Number(s); - return Number.isFinite(n) && n > 0 ? n : null; - }) - .filter((n): n is number => n != null) - .map((c) => [c, defaultIterations] as [number, number]); - if (parsed.length > 0) customConfig = parsed; - break; + .map((s) => Number(s.trim())) + .filter((n) => Number.isFinite(n) && n > 0); + if (nums.length > 0) return nums; } } - return customConfig; + return null; } async function main() { const argv = process.argv.slice(2); const repoRoot = repoRootFromImportMeta(import.meta.url, 3); - const config: Array<[number, number]> = parseConfigFromArgv(argv) ?? [...INSERT_MOVE_BENCH_CONFIG]; + const counts = parseCountsFromArgv(argv) ?? [...INSERT_MOVE_BENCH_COUNTS]; - for (const [size, iterations] of config) { + for (const size of counts) { const workload = makeWorkload(WORKLOAD, size); - workload.iterations = 1; - workload.warmupIterations = 0; for (const storage of STORAGES) { const adapterFactory = async () => { @@ -89,35 +65,7 @@ async function main() { }; }; - let result: Awaited>; - if (iterations > 1) { - const durations: number[] = []; - for (let i = 0; i < iterations; i += 1) { - const r = await runBenchmark(adapterFactory, workload); - durations.push(r.durationMs); - } - const medianDurationMs = quantile(durations, 0.5); - const totalOps = workload.totalOps ?? -1; - result = { - name: workload.name, - totalOps, - durationMs: medianDurationMs, - opsPerSec: - totalOps > 0 && medianDurationMs > 0 - ? (totalOps / medianDurationMs) * 1000 - : medianDurationMs > 0 - ? 1000 / medianDurationMs - : Infinity, - extra: { - count: totalOps > 0 ? totalOps : undefined, - iterations, - avgDurationMs: medianDurationMs, - samplesMs: durations, - }, - }; - } else { - result = await runBenchmark(adapterFactory, workload); - } + const result = await runBenchmark(adapterFactory, workload); const outFile = path.join(repoRoot, "benchmarks", "sqlite-node", `${storage}-${result.name}.json`); const payload = await writeResult(result, { diff --git a/packages/treecrdt-wa-sqlite/e2e/src/bench.ts b/packages/treecrdt-wa-sqlite/e2e/src/bench.ts index 21828ead..ed186558 100644 --- a/packages/treecrdt-wa-sqlite/e2e/src/bench.ts +++ b/packages/treecrdt-wa-sqlite/e2e/src/bench.ts @@ -42,7 +42,7 @@ export async function runWaSqliteBench( console.info(`[bench] starting run storage=${storage} baseUrl=${baseUrl}`); return new Promise((resolve, reject) => { const worker = new Worker(new URL("./opfs-worker.ts", import.meta.url), { type: "module" }); - // Tiny workloads now run 5+ iterations (new adapter per iteration); OPFS create is slow, so allow more time. + // OPFS create can be slow; allow enough time for all workloads. const timeout = setTimeout(() => { worker.terminate(); reject(new Error("wa-sqlite bench worker timed out")); diff --git a/packages/treecrdt-wa-sqlite/e2e/src/opfs-worker.ts b/packages/treecrdt-wa-sqlite/e2e/src/opfs-worker.ts index 9737b3e5..785c90bf 100644 --- a/packages/treecrdt-wa-sqlite/e2e/src/opfs-worker.ts +++ b/packages/treecrdt-wa-sqlite/e2e/src/opfs-worker.ts @@ -110,7 +110,7 @@ async function runWaSqliteBenchInWorker( for (const workload of workloadDefs) { console.info(`[opfs-worker] workload ${workload.name} start`); - // Factory must return a NEW adapter each time: runBenchmark calls it per iteration and closes after each. + // Factory must return a NEW adapter each time: runBenchmark creates one adapter per workload and closes it after the run. const adapterFactory = () => createAdapter(storage, baseUrl); const res = await runWorkloads(adapterFactory, [workload]); const [result] = res; diff --git a/packages/treecrdt-wa-sqlite/e2e/src/sync.ts b/packages/treecrdt-wa-sqlite/e2e/src/sync.ts index 1c050cc7..8549cb03 100644 --- a/packages/treecrdt-wa-sqlite/e2e/src/sync.ts +++ b/packages/treecrdt-wa-sqlite/e2e/src/sync.ts @@ -5,11 +5,9 @@ import { makeOp, maxLamport, nodeIdFromInt, - quantile, SYNC_BENCH_DEFAULT_CODEWORDS_PER_MESSAGE, SYNC_BENCH_DEFAULT_MAX_CODEWORDS, SYNC_BENCH_DEFAULT_SUBSCRIBE_CODEWORDS_PER_MESSAGE, - syncBenchTiming, type SyncBenchWorkload, } from "@treecrdt/benchmark"; import type { Operation } from "@treecrdt/interface"; @@ -432,15 +430,7 @@ async function runBenchCase( size: number ): Promise { const bench = buildSyncBenchCase({ workload, size }); - const { iterations, warmupIterations } = syncBenchTiming(); - - const samplesMs: number[] = []; - for (let i = 0; i < warmupIterations + iterations; i += 1) { - const ms = await runBenchOnce(storage, workload, size, bench); - if (i >= warmupIterations) samplesMs.push(ms); - } - - const durationMs = quantile(samplesMs, 0.5); + const durationMs = await runBenchOnce(storage, workload, size, bench); const opsPerSec = durationMs > 0 ? (bench.totalOps / durationMs) * 1000 : Infinity; return { implementation: "wa-sqlite", @@ -454,12 +444,6 @@ async function runBenchCase( ...bench.extra, codewordsPerMessage: SYNC_BENCH_DEFAULT_CODEWORDS_PER_MESSAGE, maxCodewords: SYNC_BENCH_DEFAULT_MAX_CODEWORDS, - iterations, - warmupIterations, - samplesMs, - p95Ms: quantile(samplesMs, 0.95), - minMs: Math.min(...samplesMs), - maxMs: Math.max(...samplesMs), }, }; } diff --git a/packages/treecrdt-wasm-js/scripts/bench.ts b/packages/treecrdt-wasm-js/scripts/bench.ts index 1b7d7aad..d9dd3a4c 100644 --- a/packages/treecrdt-wasm-js/scripts/bench.ts +++ b/packages/treecrdt-wasm-js/scripts/bench.ts @@ -1,9 +1,5 @@ import path from "node:path"; -import { - benchTiming, - buildWorkloads, - runWorkloads, -} from "@treecrdt/benchmark"; +import { buildWorkloads, runWorkloads } from "@treecrdt/benchmark"; import { parseBenchCliArgs, repoRootFromImportMeta, writeResult } from "@treecrdt/benchmark/node"; import { createWasmAdapter } from "../dist/index.js"; @@ -13,14 +9,7 @@ async function main() { }); const repoRoot = repoRootFromImportMeta(import.meta.url, 3); - const timing = benchTiming({ defaultIterations: 7 }); const workloadDefs = buildWorkloads(opts.workloads, opts.sizes); - for (const w of workloadDefs) { - const totalOps = w.totalOps ?? 0; - w.iterations = totalOps >= 10000 ? 10 : timing.iterations; - w.warmupIterations = timing.warmupIterations; - } - const results = await runWorkloads(() => createWasmAdapter(), workloadDefs); for (const result of results) { const outFile = opts.outFile ?? path.join(repoRoot, "benchmarks", "wasm", `${result.name}.json`); diff --git a/scripts/aggregate-bench.mjs b/scripts/aggregate-bench.mjs index b2ff4aa7..9b02b8f2 100644 --- a/scripts/aggregate-bench.mjs +++ b/scripts/aggregate-bench.mjs @@ -29,22 +29,18 @@ async function walk(dir) { } function toMarkdown(rows) { - const header = ["Implementation", "Storage", "Workload", "Mode", "Iterations", "TotalOps", "Median (ms)", "P95 (ms)", "Ops/s", "File"]; + const header = ["Implementation", "Storage", "Workload", "Mode", "TotalOps", "Duration (ms)", "Ops/s", "File"]; const lines = [header.join(" | "), header.map(() => "---").join(" | ")]; for (const row of rows) { const mode = row.extra?.mode ?? "-"; - const iterations = row.extra?.iterations ?? 1; - const p95Ms = row.extra?.p95Ms; lines.push( [ row.implementation ?? "-", row.storage ?? "-", row.workload ?? row.name ?? "-", mode, - iterations, row.totalOps ?? "-", row.durationMs?.toFixed?.(2) ?? "-", - typeof p95Ms === "number" ? p95Ms.toFixed(2) : "-", row.opsPerSec?.toFixed?.(2) ?? "-", row.relativePath ?? "-", ].join(" | ")