node/benchmark/fs/bench-filehandle-pull-vs-webstream.js
James M Snell e78ccd8017 benchmark: add benchmarks for experimental stream/iter
Signed-off-by: James M Snell <jasnell@gmail.com>
Assisted-By: Claude/Opus 4.6
PR-URL: https://github.com/nodejs/node/pull/62066
Reviewed-By: Robert Nagy <ronagy@icloud.com>
Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
2026-03-27 19:55:35 -07:00

201 lines
5.8 KiB
JavaScript

// Compare FileHandle.createReadStream() vs readableWebStream() vs pull()
// reading a large file through two transforms: uppercase then compress.
'use strict';
const common = require('../common.js');
const fs = require('fs');
const zlib = require('zlib');
const { Transform, Writable, pipeline } = require('stream');
const tmpdir = require('../../test/common/tmpdir');
tmpdir.refresh();
const filename = tmpdir.resolve(`.removeme-benchmark-garbage-${process.pid}`);
const bench = common.createBenchmark(main, {
api: ['classic', 'webstream', 'pull'],
compression: ['gzip', 'deflate', 'brotli', 'zstd'],
filesize: [1024 * 1024, 16 * 1024 * 1024, 64 * 1024 * 1024],
n: [5],
}, {
flags: ['--experimental-stream-iter'],
// Classic and webstream only support gzip (native zlib / CompressionStream).
// Brotli, deflate, zstd are pull-only via stream/iter transforms.
combinationFilter({ api, compression }) {
if (api === 'classic' && compression !== 'gzip') return false;
if (api === 'webstream' && compression !== 'gzip') return false;
return true;
},
});
function main({ api, compression, filesize, n }) {
// Create the fixture file with repeating lowercase ASCII
const chunk = Buffer.alloc(Math.min(filesize, 64 * 1024), 'abcdefghij');
const fd = fs.openSync(filename, 'w');
let remaining = filesize;
while (remaining > 0) {
const toWrite = Math.min(remaining, chunk.length);
fs.writeSync(fd, chunk, 0, toWrite);
remaining -= toWrite;
}
fs.closeSync(fd);
if (api === 'classic') {
benchClassic(n, filesize).then(() => cleanup());
} else if (api === 'webstream') {
benchWebStream(n, filesize).then(() => cleanup());
} else {
benchPull(n, filesize, compression).then(() => cleanup());
}
}
function cleanup() {
try { fs.unlinkSync(filename); } catch { /* ignore */ }
}
// Stateless uppercase transform (shared by all paths)
function uppercaseChunk(chunk) {
const buf = Buffer.allocUnsafe(chunk.length);
for (let i = 0; i < chunk.length; i++) {
const b = chunk[i];
buf[i] = (b >= 0x61 && b <= 0x7a) ? b - 0x20 : b;
}
return buf;
}
// ---------------------------------------------------------------------------
// Classic streams path: createReadStream -> Transform (upper) -> createGzip
// ---------------------------------------------------------------------------
async function benchClassic(n, filesize) {
await runClassic();
bench.start();
let totalBytes = 0;
for (let i = 0; i < n; i++) {
totalBytes += await runClassic();
}
bench.end(totalBytes / (1024 * 1024));
}
function runClassic() {
return new Promise((resolve, reject) => {
const rs = fs.createReadStream(filename);
const upper = new Transform({
transform(chunk, encoding, callback) {
callback(null, uppercaseChunk(chunk));
},
});
const gz = zlib.createGzip();
let totalBytes = 0;
const sink = new Writable({
write(chunk, encoding, callback) {
totalBytes += chunk.length;
callback();
},
});
pipeline(rs, upper, gz, sink, (err) => {
if (err) reject(err);
else resolve(totalBytes);
});
});
}
// ---------------------------------------------------------------------------
// WebStream path: readableWebStream -> TransformStream (upper) -> CompressionStream
// ---------------------------------------------------------------------------
async function benchWebStream(n, filesize) {
await runWebStream();
bench.start();
let totalBytes = 0;
for (let i = 0; i < n; i++) {
totalBytes += await runWebStream();
}
bench.end(totalBytes / (1024 * 1024));
}
async function runWebStream() {
const fh = await fs.promises.open(filename, 'r');
try {
const rs = fh.readableWebStream();
const upper = new TransformStream({
transform(chunk, controller) {
const buf = new Uint8Array(chunk.length);
for (let i = 0; i < chunk.length; i++) {
const b = chunk[i];
buf[i] = (b >= 0x61 && b <= 0x7a) ? b - 0x20 : b;
}
controller.enqueue(buf);
},
});
const compress = new CompressionStream('gzip');
const output = rs.pipeThrough(upper).pipeThrough(compress);
const reader = output.getReader();
let totalBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
}
return totalBytes;
} finally {
await fh.close();
}
}
// ---------------------------------------------------------------------------
// Pull/iter path: pull() with uppercase transform + selected compression
// ---------------------------------------------------------------------------
async function benchPull(n, filesize, compression) {
const iter = require('zlib/iter');
const compressFactory = {
gzip: iter.compressGzip,
deflate: iter.compressDeflate,
brotli: iter.compressBrotli,
zstd: iter.compressZstd,
}[compression];
// Warm up
await runPull(compressFactory);
bench.start();
let totalBytes = 0;
for (let i = 0; i < n; i++) {
totalBytes += await runPull(compressFactory);
}
bench.end(totalBytes / (1024 * 1024));
}
async function runPull(compressFactory) {
const fh = await fs.promises.open(filename, 'r');
try {
// Stateless transform: uppercase each chunk in the batch
const upper = (chunks) => {
if (chunks === null) return null;
const out = new Array(chunks.length);
for (let j = 0; j < chunks.length; j++) {
out[j] = uppercaseChunk(chunks[j]);
}
return out;
};
const readable = fh.pull(upper, compressFactory());
let totalBytes = 0;
for await (const chunks of readable) {
for (let i = 0; i < chunks.length; i++) {
totalBytes += chunks[i].byteLength;
}
}
return totalBytes;
} finally {
await fh.close();
}
}