Files
vercel__workflow/packages/core/scripts/benchmark-compression-cpu.mjs
Pranay Prakash 5f0b845211 RFC: compress serialized payload refs — zstd (gzip fallback), specVersion 5 (#2394)
* feat(core,world): gzip-compress serialized payloads behind specVersion 5

Add a composable 'gzip' format prefix layer to the serialization
pipeline (compress before encrypt: encr(gzip(devl))), cutting stored
payload bytes by ~70-87% on real-world-style workloads. Compression is
gated on run specVersion 5 (new SPEC_VERSION_SUPPORTS_COMPRESSION) and
on target-deployment capabilities for cross-deployment writes; payloads
under 1KB or that don't compress meaningfully are stored unchanged.
Reads dispatch on the format prefix so both compressed and uncompressed
data are always readable. WORKFLOW_DISABLE_COMPRESSION=1 disables
writes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(core): add CPU/perf compression benchmark + shared workloads

Split the compression benchmark into reproducible size and CPU scripts
sharing deterministic workloads (lib/workloads.mjs). The CPU benchmark
measures serialize/deserialize overhead per payload, total CPU across
thousands of events, and compares gzip levels/brotli/deflate. Documents
how to run the size, CPU, and end-to-end (bench.bench.ts) benchmarks
against local and Vercel in scripts/README.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(world-vercel): advertise specVersion 5 to enable compression on Vercel

Now that workflow-server declares spec-5 support (vercel/workflow-server#520),
bump the Vercel world's advertised specVersion from 4 to 5 so new Vercel runs
are stamped spec 5 and become eligible for gzip payload compression. Payloads
stay opaque to the server (compression is client-side); spec 5 is a superset of
spec 4, so initial run attributes still work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(core): emit OTel span attributes for compression impact

Track gzip payload compression on both the serialize (write) and
deserialize (read) paths via span attributes:
workflow.serialization.{operation,compressed,uncompressed_bytes,
stored_bytes,compression_ratio}. Sizes are measured at the compression
boundary (pre-encryption), so they reflect compression's effect rather
than the at-rest size.

The compression codec stays pure — compress/decompress optionally
populate a CompressionStats sink, threaded through CodecOptions to the
mode serializers and read by the dehydrate/hydrate wrappers, which set
attributes on the active span. Telemetry failures are swallowed so they
can never break the serialize/deserialize data path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(core,web-shared): prefer zstd compression codec (gzip fallback)

Switch the payload compression codec to zstd, which benchmarks 3–7×
faster than gzip at an equal-or-better ratio on representative workloads
(compression runs at every step boundary, so the write CPU is a per-step
tax). zstd uses node:zlib (>= 22.15); gzip via the portable
CompressionStream remains the fallback when zstd is unavailable, and
WORKFLOW_COMPRESSION_CODEC=gzip forces it. Reads dispatch on the format
prefix, so 'zstd' and 'gzip' payloads are both always decodable.

zstd is Node-only (Web CompressionStream has no zstd), so the browser
o11y read path registers a WASM-backed decoder (@tootallnate/zstd-wasm)
via a new registerZstdDecoder hook; node:zlib handles Node-side reads
(runtime replay, CLI, server o11y). A new workflow.serialization.codec
span attribute reports which codec applied. gzip and zstd read support
co-ship, so the existing specVersion-5 capability gate is unchanged.

Verified end-to-end: spec-5 runs store zstd-prefixed payloads on disk
and replay/complete correctly; the WASM decoder round-trips node:zlib
zstd output. Benchmarks updated to compare zstd vs gzip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 23:27:47 +00:00

223 lines
8.7 KiB
JavaScript

// Benchmark: CPU cost of payload compression in the serialization layer.
//
// Compression is a pure client-side CPU cost added to the serialize
// (write) and deserialize (read) paths — it is WORLD-INDEPENDENT. The
// world (local / vercel / postgres) only changes the *baseline* you
// compare against (filesystem vs network+encryption+S3), so the absolute
// numbers here hold regardless of backend, and the *relative* impact is
// largest on the local world (fast baseline) and smallest on Vercel
// (network + encryption dominate). See scripts/README.md.
//
// Usage (from packages/core, after `pnpm build`):
// node scripts/benchmark-compression-cpu.mjs
//
// Three sections:
// 1. Per-payload serialize + deserialize cost via the real shipping
// path (the SDK's preferred codec — zstd when node:zlib has it,
// else gzip), off vs on. Force gzip with WORKFLOW_COMPRESSION_CODEC=gzip.
// 2. Stress: total CPU to (de)serialize thousands of event payloads,
// modelling a long workflow + replay.
// 3. Algorithm comparison (node:zlib sync APIs) — informational, to
// compare gzip levels / zstd levels / brotli / deflate.
import zlib from 'node:zlib';
import * as step from '../dist/serialization/step.js';
import { ecommerceOrder, WORKLOADS } from './lib/workloads.mjs';
const encoder = new TextEncoder();
function rawBytes(value) {
if (value instanceof Uint8Array) return value.byteLength;
return encoder.encode(JSON.stringify(value)).byteLength;
}
/** Time an async fn: warm up, then run until both minIters and minMs met. */
async function timeAsync(
fn,
{ warmup = 30, minIters = 50, minMs = 1500 } = {}
) {
for (let i = 0; i < warmup; i++) await fn();
let iters = 0;
const start = performance.now();
let elapsed = 0;
do {
await fn();
iters++;
elapsed = performance.now() - start;
} while (iters < minIters || elapsed < minMs);
return { usPerOp: (elapsed * 1000) / iters, iters };
}
/** Time a sync fn the same way. */
function timeSync(fn, { warmup = 30, minIters = 50, minMs = 1000 } = {}) {
for (let i = 0; i < warmup; i++) fn();
let iters = 0;
const start = performance.now();
let elapsed = 0;
do {
fn();
iters++;
elapsed = performance.now() - start;
} while (iters < minIters || elapsed < minMs);
return { usPerOp: (elapsed * 1000) / iters, iters };
}
const mbPerSec = (bytes, usPerOp) => bytes / (usPerOp / 1e6) / (1024 * 1024);
const pct = (a, b) => `${(((a - b) / b) * 100).toFixed(1)}%`;
// ---------------------------------------------------------------------------
// 1. Per-payload serialize + deserialize cost (real shipping path)
// ---------------------------------------------------------------------------
const writeCodec = process.env.WORKFLOW_COMPRESSION_CODEC || 'zstd (default)';
console.log(
`## Serialize + deserialize CPU cost (real shipping path, codec: ${writeCodec})`
);
console.log('');
console.log(
'| Workload | ser off | ser on | ser Δ | deser off | deser on | deser Δ | compress MB/s |'
);
console.log('| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |');
for (const [name, value] of WORKLOADS) {
const bytes = rawBytes(value);
const serOff = await timeAsync(() => step.serialize(value, undefined, {}));
const serOn = await timeAsync(() =>
step.serialize(value, undefined, { compression: true })
);
const uncompressed = await step.serialize(value, undefined, {});
const compressed = await step.serialize(value, undefined, {
compression: true,
});
const deserOff = await timeAsync(() =>
step.deserialize(uncompressed, undefined, {})
);
const deserOn = await timeAsync(() =>
step.deserialize(compressed, undefined, {})
);
console.log(
`| ${name} | ${serOff.usPerOp.toFixed(1)}µs | ${serOn.usPerOp.toFixed(1)}µs | +${pct(serOn.usPerOp, serOff.usPerOp)} | ${deserOff.usPerOp.toFixed(1)}µs | ${deserOn.usPerOp.toFixed(1)}µs | +${pct(deserOn.usPerOp, deserOff.usPerOp)} | ${mbPerSec(bytes, serOn.usPerOp - serOff.usPerOp).toFixed(0)} |`
);
}
// ---------------------------------------------------------------------------
// 2. Stress: thousands of event payloads (long workflow + replay)
// ---------------------------------------------------------------------------
//
// A long workflow writes each step payload once and re-reads (replays)
// every prior payload on each cold start. We model the serialization CPU
// for N events: N serializes + N deserializes, off vs on. The "added"
// number is the total extra CPU compression spends across the whole run.
console.log('');
console.log('## Stress: total serialization CPU for N event payloads');
console.log('');
console.log(
'(e-commerce order payload, ~6.6 KB each — representative step output)'
);
console.log('');
console.log(
'| Events | off (ser+deser) | on (ser+deser) | added CPU | per event |'
);
console.log('| ---: | ---: | ---: | ---: | ---: |');
const stressValue = ecommerceOrder();
const stressUncompressed = await step.serialize(stressValue, undefined, {});
const stressCompressed = await step.serialize(stressValue, undefined, {
compression: true,
});
for (const n of [1000, 5000, 10000]) {
// Time one ser+deser cycle for each mode, then multiply by N. Timing
// each cycle (rather than looping N inline) keeps GC pressure realistic
// and the per-op cost stable.
const offCycle = await timeAsync(async () => {
const s = await step.serialize(stressValue, undefined, {});
await step.deserialize(s, undefined, {});
});
const onCycle = await timeAsync(async () => {
const s = await step.serialize(stressValue, undefined, {
compression: true,
});
await step.deserialize(s, undefined, {});
});
const offTotalMs = (offCycle.usPerOp * n) / 1000;
const onTotalMs = (onCycle.usPerOp * n) / 1000;
console.log(
`| ${n.toLocaleString()} | ${offTotalMs.toFixed(0)}ms | ${onTotalMs.toFixed(0)}ms | +${(onTotalMs - offTotalMs).toFixed(0)}ms | +${(onCycle.usPerOp - offCycle.usPerOp).toFixed(1)}µs |`
);
}
void stressUncompressed;
void stressCompressed;
// ---------------------------------------------------------------------------
// 3. Algorithm comparison (node:zlib sync) — informational
// ---------------------------------------------------------------------------
//
// Production uses Web CompressionStream('gzip') ≈ zlib gzip level 6. These
// sync numbers let us compare candidate codecs for a future format prefix
// (e.g. a `zsd1` zstd codec) without committing to one. Measured on the
// devalue-serialized payload bytes (what the layer actually compresses).
console.log('');
console.log('## Algorithm comparison (node:zlib sync, informational)');
console.log('');
// zstd entries are gated on availability (node:zlib >= 22.15). The
// production gzip path ships via the Web CompressionStream (≈ gzip -6); the
// node:zlib gzip rows here isolate pure codec speed from that stream
// overhead, and are the apples-to-apples comparison against zstd.
const hasZstd = typeof zlib.zstdCompressSync === 'function';
const zstdAt = (level) => (b) =>
zlib.zstdCompressSync(b, {
params: { [zlib.constants.ZSTD_c_compressionLevel]: level },
});
const ALGOS = [
['gzip -1', (b) => zlib.gzipSync(b, { level: 1 }), zlib.gunzipSync],
['gzip -6 (default)', (b) => zlib.gzipSync(b, { level: 6 }), zlib.gunzipSync],
['gzip -9', (b) => zlib.gzipSync(b, { level: 9 }), zlib.gunzipSync],
...(hasZstd
? [
['zstd -3 (default)', zstdAt(3), zlib.zstdDecompressSync],
['zstd -9', zstdAt(9), zlib.zstdDecompressSync],
['zstd -19', zstdAt(19), zlib.zstdDecompressSync],
]
: []),
[
'brotli -q5',
(b) =>
zlib.brotliCompressSync(b, {
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 },
}),
zlib.brotliDecompressSync,
],
['deflate-raw', (b) => zlib.deflateRawSync(b), zlib.inflateRawSync],
];
// Use the larger text/structured payloads where codec choice matters.
const ALGO_WORKLOADS = WORKLOADS.filter(([name]) =>
/chat|API|document|Time series/.test(name)
);
for (const [name, value] of ALGO_WORKLOADS) {
const input = await step.serialize(value, undefined, {}); // devl + bytes
const inputBytes = input.byteLength;
console.log(`### ${name} (${(inputBytes / 1024).toFixed(1)} KB serialized)`);
console.log('');
console.log('| Algorithm | ratio | compress | decompress | compress MB/s |');
console.log('| --- | ---: | ---: | ---: | ---: |');
for (const [algo, compressFn, decompressFn] of ALGOS) {
const out = compressFn(input);
const ratio = ((1 - out.length / inputBytes) * 100).toFixed(1);
const c = timeSync(() => compressFn(input));
const d = timeSync(() => decompressFn(out));
console.log(
`| ${algo} | ${ratio}% | ${c.usPerOp.toFixed(1)}µs | ${d.usPerOp.toFixed(1)}µs | ${mbPerSec(inputBytes, c.usPerOp).toFixed(0)} |`
);
}
console.log('');
}