mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
5f0b845211
* 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>
223 lines
8.7 KiB
JavaScript
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('');
|
|
}
|