Optimize storage, GC, and upload hot paths
This commit is contained in:
@@ -0,0 +1,798 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createServer } from "node:http";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { parseArgs } from "node:util";
|
||||
import os from "node:os";
|
||||
|
||||
const MAX_DEDUP_HASHES = 10_000;
|
||||
const UPLOAD_BATCH_BYTES = 8 * 1024 * 1024;
|
||||
const CURSOR_COMPACT_AT = 4_096;
|
||||
|
||||
let blackhole = 0;
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
suite: { type: "string", default: "all" },
|
||||
warmup: { type: "string", default: "3" },
|
||||
samples: { type: "string", default: "15" },
|
||||
"queue-counts": { type: "string", default: "64,256,1024,10000,50000" },
|
||||
"progress-cases": {
|
||||
type: "string",
|
||||
default: "1:100,10:500,100:5000,1000:10000,10000:5000",
|
||||
},
|
||||
"hash-counts": { type: "string", default: "1000,10000,10001,25000" },
|
||||
"dedup-batch-size": { type: "string", default: "10000" },
|
||||
"dedup-concurrency": { type: "string", default: "4" },
|
||||
"server-latency-ms": { type: "string", default: "0" },
|
||||
"modeled-file-bytes": { type: "string", default: "65536" },
|
||||
output: { type: "string" },
|
||||
},
|
||||
strict: true,
|
||||
allowPositionals: false,
|
||||
});
|
||||
|
||||
function positiveInteger(name, raw, allowZero = false) {
|
||||
const value = Number(raw);
|
||||
const lowerBound = allowZero ? 0 : 1;
|
||||
if (!Number.isInteger(value) || value < lowerBound) {
|
||||
throw new Error(
|
||||
name + " must be an integer >= " + lowerBound + "; received " + raw,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function numberList(name, raw) {
|
||||
const parsed = raw
|
||||
.split(",")
|
||||
.filter(Boolean)
|
||||
.map((part) => positiveInteger(name, part));
|
||||
if (parsed.length === 0) throw new Error(name + " must not be empty");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function progressCases(raw) {
|
||||
const parsed = raw
|
||||
.split(",")
|
||||
.filter(Boolean)
|
||||
.map((entry) => {
|
||||
const parts = entry.split(":");
|
||||
if (parts.length !== 2)
|
||||
throw new Error("Invalid progress case: " + entry);
|
||||
return {
|
||||
items: positiveInteger("progress items", parts[0]),
|
||||
updates: positiveInteger("progress updates", parts[1]),
|
||||
};
|
||||
});
|
||||
if (parsed.length === 0) throw new Error("progress-cases must not be empty");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const config = {
|
||||
suite: values.suite,
|
||||
warmup: positiveInteger("warmup", values.warmup, true),
|
||||
samples: positiveInteger("samples", values.samples),
|
||||
queueCounts: numberList("queue-counts", values["queue-counts"]),
|
||||
progressCases: progressCases(values["progress-cases"]),
|
||||
hashCounts: numberList("hash-counts", values["hash-counts"]),
|
||||
dedupBatchSize: positiveInteger(
|
||||
"dedup-batch-size",
|
||||
values["dedup-batch-size"],
|
||||
),
|
||||
dedupConcurrency: positiveInteger(
|
||||
"dedup-concurrency",
|
||||
values["dedup-concurrency"],
|
||||
),
|
||||
serverLatencyMs: positiveInteger(
|
||||
"server-latency-ms",
|
||||
values["server-latency-ms"],
|
||||
true,
|
||||
),
|
||||
modeledFileBytes: positiveInteger(
|
||||
"modeled-file-bytes",
|
||||
values["modeled-file-bytes"],
|
||||
),
|
||||
};
|
||||
|
||||
if (!["all", "queue", "progress", "dedup"].includes(config.suite)) {
|
||||
throw new Error("suite must be all, queue, progress, or dedup");
|
||||
}
|
||||
if (config.dedupBatchSize > MAX_DEDUP_HASHES) {
|
||||
throw new Error(
|
||||
"dedup-batch-size must be <= the server limit of " + MAX_DEDUP_HASHES,
|
||||
);
|
||||
}
|
||||
|
||||
function median(sorted) {
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 0
|
||||
? (sorted[middle - 1] + sorted[middle]) / 2
|
||||
: sorted[middle];
|
||||
}
|
||||
|
||||
function summarize(samples) {
|
||||
const times = samples.map((sample) => sample.ms).sort((a, b) => a - b);
|
||||
const heaps = samples
|
||||
.map((sample) => sample.heapDeltaBytes)
|
||||
.sort((a, b) => a - b);
|
||||
const rss = samples
|
||||
.map((sample) => sample.rssDeltaBytes)
|
||||
.sort((a, b) => a - b);
|
||||
const p95Index = Math.max(0, Math.ceil(times.length * 0.95) - 1);
|
||||
return {
|
||||
sampleCount: samples.length,
|
||||
medianMs: median(times),
|
||||
p95Ms: times[p95Index],
|
||||
minMs: times[0],
|
||||
maxMs: times[times.length - 1],
|
||||
medianHeapDeltaBytes: median(heaps),
|
||||
medianRssDeltaBytes: median(rss),
|
||||
};
|
||||
}
|
||||
|
||||
function consume(result) {
|
||||
const token = Number(
|
||||
result.checksum ??
|
||||
result.ownedCount ??
|
||||
result.chunkCount ??
|
||||
result.lastPercent ??
|
||||
0,
|
||||
);
|
||||
blackhole = (blackhole ^ (token >>> 0)) >>> 0;
|
||||
}
|
||||
|
||||
async function measureOne(fn) {
|
||||
if (global.gc) global.gc();
|
||||
const before = process.memoryUsage();
|
||||
const started = performance.now();
|
||||
const result = await fn();
|
||||
const ms = performance.now() - started;
|
||||
const after = process.memoryUsage();
|
||||
consume(result);
|
||||
return {
|
||||
sample: {
|
||||
ms,
|
||||
heapDeltaBytes: after.heapUsed - before.heapUsed,
|
||||
rssDeltaBytes: after.rss - before.rss,
|
||||
},
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
async function benchmarkPair(currentFn, candidateFn, verify) {
|
||||
const checkedCurrent = await currentFn();
|
||||
const checkedCandidate = await candidateFn();
|
||||
verify(checkedCurrent, checkedCandidate);
|
||||
|
||||
for (let i = 0; i < config.warmup; i++) {
|
||||
if (i % 2 === 0) {
|
||||
consume(await currentFn());
|
||||
consume(await candidateFn());
|
||||
} else {
|
||||
consume(await candidateFn());
|
||||
consume(await currentFn());
|
||||
}
|
||||
}
|
||||
|
||||
const currentSamples = [];
|
||||
const candidateSamples = [];
|
||||
let currentResult = checkedCurrent;
|
||||
let candidateResult = checkedCandidate;
|
||||
for (let i = 0; i < config.samples; i++) {
|
||||
const order =
|
||||
i % 2 === 0
|
||||
? [
|
||||
["current", currentFn],
|
||||
["candidate", candidateFn],
|
||||
]
|
||||
: [
|
||||
["candidate", candidateFn],
|
||||
["current", currentFn],
|
||||
];
|
||||
for (const [kind, fn] of order) {
|
||||
const measured = await measureOne(fn);
|
||||
if (kind === "current") {
|
||||
currentSamples.push(measured.sample);
|
||||
currentResult = measured.result;
|
||||
} else {
|
||||
candidateSamples.push(measured.sample);
|
||||
candidateResult = measured.result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const current = summarize(currentSamples);
|
||||
const candidate = summarize(candidateSamples);
|
||||
return {
|
||||
current,
|
||||
candidate,
|
||||
speedup: current.medianMs / candidate.medianMs,
|
||||
representative: {
|
||||
current: currentResult,
|
||||
candidate: candidateResult,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeChunks(count) {
|
||||
const chunks = new Array(count);
|
||||
let offset = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const size =
|
||||
(1 + ((Math.imul(i + 1, 2_654_435_761) >>> 28) & 7)) * 32 * 1024;
|
||||
chunks[i] = { h: "chunk-" + i, s: size, offset };
|
||||
offset += size;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function foldBatch(batch, checksum) {
|
||||
let next = checksum;
|
||||
for (const chunk of batch) {
|
||||
next = Math.imul(next ^ chunk.s ^ (chunk.offset >>> 0), 16_777_619) >>> 0;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function drainWithShift(source) {
|
||||
const uploadQueue = source.slice();
|
||||
let checksum = 2_166_136_261;
|
||||
let chunkCount = 0;
|
||||
let totalBytes = 0;
|
||||
let batchCount = 0;
|
||||
|
||||
while (uploadQueue.length > 0) {
|
||||
const batch = [];
|
||||
let bytes = 0;
|
||||
while (uploadQueue.length > 0 && bytes < UPLOAD_BATCH_BYTES) {
|
||||
const chunk = uploadQueue.shift();
|
||||
batch.push(chunk);
|
||||
bytes += chunk.s;
|
||||
}
|
||||
checksum = foldBatch(batch, checksum);
|
||||
chunkCount += batch.length;
|
||||
totalBytes += bytes;
|
||||
batchCount++;
|
||||
}
|
||||
|
||||
return { checksum, chunkCount, totalBytes, batchCount };
|
||||
}
|
||||
|
||||
function drainWithCursor(source) {
|
||||
const uploadQueue = source.slice();
|
||||
let head = 0;
|
||||
let checksum = 2_166_136_261;
|
||||
let chunkCount = 0;
|
||||
let totalBytes = 0;
|
||||
let batchCount = 0;
|
||||
let compactions = 0;
|
||||
|
||||
while (head < uploadQueue.length) {
|
||||
const batch = [];
|
||||
let bytes = 0;
|
||||
while (head < uploadQueue.length && bytes < UPLOAD_BATCH_BYTES) {
|
||||
const chunk = uploadQueue[head];
|
||||
uploadQueue[head] = undefined;
|
||||
head++;
|
||||
batch.push(chunk);
|
||||
bytes += chunk.s;
|
||||
}
|
||||
checksum = foldBatch(batch, checksum);
|
||||
chunkCount += batch.length;
|
||||
totalBytes += bytes;
|
||||
batchCount++;
|
||||
|
||||
if (head === uploadQueue.length) {
|
||||
uploadQueue.length = 0;
|
||||
head = 0;
|
||||
} else if (head >= CURSOR_COMPACT_AT && head * 2 >= uploadQueue.length) {
|
||||
uploadQueue.copyWithin(0, head);
|
||||
uploadQueue.length -= head;
|
||||
head = 0;
|
||||
compactions++;
|
||||
}
|
||||
}
|
||||
|
||||
return { checksum, chunkCount, totalBytes, batchCount, compactions };
|
||||
}
|
||||
|
||||
function verifyQueue(current, candidate) {
|
||||
for (const key of ["checksum", "chunkCount", "totalBytes", "batchCount"]) {
|
||||
if (current[key] !== candidate[key]) {
|
||||
throw new Error(
|
||||
"Queue candidate changed " +
|
||||
key +
|
||||
": " +
|
||||
current[key] +
|
||||
" vs " +
|
||||
candidate[key],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeProgressEvents(items, updateCount) {
|
||||
const indices = new Uint32Array(updateCount);
|
||||
const values = new Float64Array(updateCount);
|
||||
let state = 0x9e3779b9;
|
||||
for (let i = 0; i < updateCount; i++) {
|
||||
state ^= state << 13;
|
||||
state ^= state >>> 17;
|
||||
state ^= state << 5;
|
||||
state >>>= 0;
|
||||
indices[i] = state % items;
|
||||
values[i] = (state & 1023) / 1024;
|
||||
}
|
||||
return { indices, values };
|
||||
}
|
||||
|
||||
function recordProgress(checksum, sum, total) {
|
||||
const percent = Math.round((sum / total) * 100);
|
||||
const done = Math.round(sum);
|
||||
return {
|
||||
checksum: (checksum + Math.imul(percent + 1, done + 1)) >>> 0,
|
||||
percent,
|
||||
};
|
||||
}
|
||||
|
||||
function progressWithFullScan(items, events) {
|
||||
const fractions = new Array(items).fill(0);
|
||||
let checksum = 0;
|
||||
let lastPercent = 0;
|
||||
let finalSum = 0;
|
||||
for (let update = 0; update < events.indices.length; update++) {
|
||||
fractions[events.indices[update]] = Math.min(1, events.values[update]);
|
||||
let sum = 0;
|
||||
for (const fraction of fractions) sum += fraction;
|
||||
const recorded = recordProgress(checksum, sum, items);
|
||||
checksum = recorded.checksum;
|
||||
lastPercent = recorded.percent;
|
||||
finalSum = sum;
|
||||
}
|
||||
return { checksum, lastPercent, finalSum };
|
||||
}
|
||||
|
||||
function progressWithAccumulator(items, events) {
|
||||
const fractions = new Array(items).fill(0);
|
||||
let sum = 0;
|
||||
let checksum = 0;
|
||||
let lastPercent = 0;
|
||||
for (let update = 0; update < events.indices.length; update++) {
|
||||
const index = events.indices[update];
|
||||
const next = Math.min(1, events.values[update]);
|
||||
sum += next - fractions[index];
|
||||
fractions[index] = next;
|
||||
const recorded = recordProgress(checksum, sum, items);
|
||||
checksum = recorded.checksum;
|
||||
lastPercent = recorded.percent;
|
||||
}
|
||||
return { checksum, lastPercent, finalSum: sum };
|
||||
}
|
||||
|
||||
function verifyProgress(current, candidate) {
|
||||
if (
|
||||
current.checksum !== candidate.checksum ||
|
||||
current.lastPercent !== candidate.lastPercent
|
||||
) {
|
||||
throw new Error("Progress candidate changed user-visible progress values");
|
||||
}
|
||||
if (Math.abs(current.finalSum - candidate.finalSum) > Number.EPSILON * 8) {
|
||||
throw new Error("Progress candidate changed final sum");
|
||||
}
|
||||
}
|
||||
|
||||
function makeHashes(count) {
|
||||
const hashes = new Array(count);
|
||||
for (let i = 0; i < count; i++) hashes[i] = i.toString(16).padStart(64, "0");
|
||||
return hashes;
|
||||
}
|
||||
|
||||
function isOwnedHash(hash) {
|
||||
return (Number.parseInt(hash.at(-1), 16) & 1) === 0;
|
||||
}
|
||||
|
||||
function emptyServerStats() {
|
||||
return {
|
||||
requests: 0,
|
||||
acceptedRequests: 0,
|
||||
rejectedRequests: 0,
|
||||
requestBytes: 0,
|
||||
responseBytes: 0,
|
||||
maxBatchHashes: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function startDedupServer() {
|
||||
let activeStats = emptyServerStats();
|
||||
const server = createServer(async (request, response) => {
|
||||
const parts = [];
|
||||
for await (const part of request) parts.push(part);
|
||||
const body = Buffer.concat(parts);
|
||||
const parsed = JSON.parse(body.toString("utf8"));
|
||||
const hashes = Array.isArray(parsed.hashes) ? parsed.hashes : [];
|
||||
|
||||
activeStats.requests++;
|
||||
activeStats.requestBytes += body.byteLength;
|
||||
activeStats.maxBatchHashes = Math.max(
|
||||
activeStats.maxBatchHashes,
|
||||
hashes.length,
|
||||
);
|
||||
|
||||
if (config.serverLatencyMs > 0) {
|
||||
await new Promise((resolveDelay) =>
|
||||
setTimeout(resolveDelay, config.serverLatencyMs),
|
||||
);
|
||||
}
|
||||
|
||||
let status;
|
||||
let responseBody;
|
||||
if (hashes.length > MAX_DEDUP_HASHES) {
|
||||
status = 400;
|
||||
activeStats.rejectedRequests++;
|
||||
responseBody = JSON.stringify({ error: "Too many hashes" });
|
||||
} else {
|
||||
status = 200;
|
||||
activeStats.acceptedRequests++;
|
||||
responseBody = JSON.stringify({ owned: hashes.filter(isOwnedHash) });
|
||||
}
|
||||
activeStats.responseBytes += Buffer.byteLength(responseBody);
|
||||
response.writeHead(status, { "content-type": "application/json" });
|
||||
response.end(responseBody);
|
||||
});
|
||||
|
||||
await new Promise((resolveListen, rejectListen) => {
|
||||
server.once("error", rejectListen);
|
||||
server.listen(0, "127.0.0.1", resolveListen);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string")
|
||||
throw new Error("Could not determine mock server address");
|
||||
|
||||
return {
|
||||
url: "http://127.0.0.1:" + address.port + "/api/dedup/check-batch",
|
||||
resetStats() {
|
||||
activeStats = emptyServerStats();
|
||||
return activeStats;
|
||||
},
|
||||
async close() {
|
||||
await new Promise((resolveClose, rejectClose) => {
|
||||
server.close((error) => (error ? rejectClose(error) : resolveClose()));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function postHashes(url, hashes) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hashes }),
|
||||
});
|
||||
if (!response.ok) return new Set();
|
||||
const data = await response.json().catch(() => null);
|
||||
return new Set(data?.owned ?? []);
|
||||
}
|
||||
|
||||
async function dedupCurrent(url, hashes) {
|
||||
return postHashes(url, hashes);
|
||||
}
|
||||
|
||||
async function dedupBatched(url, hashes) {
|
||||
// Production keeps the former one-request path exact for the overwhelmingly
|
||||
// common valid case: no slice, batching array, or Promise pool below the cap.
|
||||
if (hashes.length <= MAX_DEDUP_HASHES) return postHashes(url, hashes);
|
||||
|
||||
const batches = [];
|
||||
for (let start = 0; start < hashes.length; start += config.dedupBatchSize) {
|
||||
batches.push(hashes.slice(start, start + config.dedupBatchSize));
|
||||
}
|
||||
|
||||
const owned = new Set();
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
while (next < batches.length) {
|
||||
const index = next++;
|
||||
const batchOwned = await postHashes(url, batches[index]);
|
||||
for (const hash of batchOwned) owned.add(hash);
|
||||
}
|
||||
};
|
||||
await Promise.all(
|
||||
Array.from(
|
||||
{ length: Math.min(config.dedupConcurrency, batches.length) },
|
||||
worker,
|
||||
),
|
||||
);
|
||||
return owned;
|
||||
}
|
||||
|
||||
function dedupRun(server, hashes, implementation) {
|
||||
return async () => {
|
||||
const stats = server.resetStats();
|
||||
const owned = await implementation(server.url, hashes);
|
||||
let checksum = 0;
|
||||
for (const hash of owned)
|
||||
checksum = (checksum + Number.parseInt(hash.slice(-8), 16)) >>> 0;
|
||||
return {
|
||||
checksum,
|
||||
ownedCount: owned.size,
|
||||
contentBytesAvoided: owned.size * config.modeledFileBytes,
|
||||
...stats,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function verifyDedup(current, candidate, hashCount) {
|
||||
const expectedOwned = Math.ceil(hashCount / 2);
|
||||
if (hashCount <= MAX_DEDUP_HASHES) {
|
||||
if (
|
||||
current.ownedCount !== expectedOwned ||
|
||||
candidate.ownedCount !== expectedOwned ||
|
||||
current.checksum !== candidate.checksum
|
||||
) {
|
||||
throw new Error("Dedup fast path changed the ownership result");
|
||||
}
|
||||
if (
|
||||
current.requests !== 1 ||
|
||||
candidate.requests !== 1 ||
|
||||
current.rejectedRequests !== 0 ||
|
||||
candidate.rejectedRequests !== 0
|
||||
) {
|
||||
throw new Error("Dedup fast path must remain one accepted request");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (current.ownedCount !== 0 || current.rejectedRequests !== 1) {
|
||||
throw new Error(
|
||||
"Current >10k control did not reproduce the expected rejection",
|
||||
);
|
||||
}
|
||||
if (candidate.ownedCount !== expectedOwned) {
|
||||
throw new Error(
|
||||
"Batched candidate found " +
|
||||
candidate.ownedCount +
|
||||
" owned hashes; expected " +
|
||||
expectedOwned,
|
||||
);
|
||||
}
|
||||
if (
|
||||
candidate.rejectedRequests !== 0 ||
|
||||
candidate.maxBatchHashes > MAX_DEDUP_HASHES
|
||||
) {
|
||||
throw new Error("Batched candidate exceeded the server request limit");
|
||||
}
|
||||
}
|
||||
|
||||
function repeatQueueDrain(fn, repetitions) {
|
||||
let checksum = 0;
|
||||
let chunkCount = 0;
|
||||
let totalBytes = 0;
|
||||
let batchCount = 0;
|
||||
for (let iteration = 0; iteration < repetitions; iteration++) {
|
||||
const result = fn();
|
||||
checksum = (checksum + result.checksum) >>> 0;
|
||||
chunkCount += result.chunkCount;
|
||||
totalBytes += result.totalBytes;
|
||||
batchCount += result.batchCount;
|
||||
}
|
||||
return { checksum, chunkCount, totalBytes, batchCount };
|
||||
}
|
||||
|
||||
function repeatProgress(fn, repetitions) {
|
||||
let checksum = 0;
|
||||
let lastPercent = 0;
|
||||
let finalSum = 0;
|
||||
for (let iteration = 0; iteration < repetitions; iteration++) {
|
||||
const result = fn();
|
||||
checksum = (checksum + result.checksum) >>> 0;
|
||||
lastPercent = result.lastPercent;
|
||||
finalSum += result.finalSum;
|
||||
}
|
||||
return { checksum, lastPercent, finalSum };
|
||||
}
|
||||
|
||||
function formatMs(value) {
|
||||
if (value >= 100) return value.toFixed(1);
|
||||
if (value >= 10) return value.toFixed(2);
|
||||
return value.toFixed(3);
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const absolute = Math.abs(value);
|
||||
const sign = value < 0 ? "-" : "";
|
||||
if (absolute >= 1024 * 1024 * 1024)
|
||||
return sign + (absolute / (1024 * 1024 * 1024)).toFixed(2) + " GiB";
|
||||
if (absolute >= 1024 * 1024)
|
||||
return sign + (absolute / (1024 * 1024)).toFixed(2) + " MiB";
|
||||
if (absolute >= 1024) return sign + (absolute / 1024).toFixed(2) + " KiB";
|
||||
return sign + absolute.toFixed(0) + " B";
|
||||
}
|
||||
|
||||
function printPair(label, result) {
|
||||
console.log(label);
|
||||
console.log(
|
||||
" current median " +
|
||||
formatMs(result.current.medianMs) +
|
||||
" ms; p95 " +
|
||||
formatMs(result.current.p95Ms) +
|
||||
" ms; heap delta " +
|
||||
formatBytes(result.current.medianHeapDeltaBytes),
|
||||
);
|
||||
console.log(
|
||||
" candidate median " +
|
||||
formatMs(result.candidate.medianMs) +
|
||||
" ms; p95 " +
|
||||
formatMs(result.candidate.p95Ms) +
|
||||
" ms; heap delta " +
|
||||
formatBytes(result.candidate.medianHeapDeltaBytes),
|
||||
);
|
||||
console.log(" median speedup " + result.speedup.toFixed(2) + "x");
|
||||
}
|
||||
|
||||
async function runQueueSuite(output) {
|
||||
output.queue = [];
|
||||
for (const count of config.queueCounts) {
|
||||
const source = makeChunks(count);
|
||||
const repetitions = count <= 1024 ? Math.ceil(200_000 / count) : 1;
|
||||
const result = await benchmarkPair(
|
||||
() => repeatQueueDrain(() => drainWithShift(source), repetitions),
|
||||
() => repeatQueueDrain(() => drainWithCursor(source), repetitions),
|
||||
verifyQueue,
|
||||
);
|
||||
output.queue.push({
|
||||
chunkCount: count,
|
||||
repetitions,
|
||||
normalizedMedianUsPerDrain: {
|
||||
current: (result.current.medianMs * 1000) / repetitions,
|
||||
candidate: (result.candidate.medianMs * 1000) / repetitions,
|
||||
},
|
||||
...result,
|
||||
});
|
||||
printPair(
|
||||
"A queue drain, " +
|
||||
count.toLocaleString("en-US") +
|
||||
" chunks x " +
|
||||
repetitions.toLocaleString("en-US"),
|
||||
result,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function runProgressSuite(output) {
|
||||
output.progress = [];
|
||||
for (const scenario of config.progressCases) {
|
||||
const events = makeProgressEvents(scenario.items, scenario.updates);
|
||||
const repetitions =
|
||||
scenario.items <= 100 ? Math.ceil(100_000 / scenario.updates) : 1;
|
||||
const result = await benchmarkPair(
|
||||
() =>
|
||||
repeatProgress(
|
||||
() => progressWithFullScan(scenario.items, events),
|
||||
repetitions,
|
||||
),
|
||||
() =>
|
||||
repeatProgress(
|
||||
() => progressWithAccumulator(scenario.items, events),
|
||||
repetitions,
|
||||
),
|
||||
verifyProgress,
|
||||
);
|
||||
output.progress.push({
|
||||
...scenario,
|
||||
repetitions,
|
||||
normalizedMedianUsPerRun: {
|
||||
current: (result.current.medianMs * 1000) / repetitions,
|
||||
candidate: (result.candidate.medianMs * 1000) / repetitions,
|
||||
},
|
||||
...result,
|
||||
});
|
||||
printPair(
|
||||
"B aggregate progress, " +
|
||||
scenario.items.toLocaleString("en-US") +
|
||||
" files x " +
|
||||
scenario.updates.toLocaleString("en-US") +
|
||||
" updates",
|
||||
result,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function runDedupSuite(output) {
|
||||
output.dedup = [];
|
||||
const server = await startDedupServer();
|
||||
try {
|
||||
for (const hashCount of config.hashCounts) {
|
||||
const hashes = makeHashes(hashCount);
|
||||
const result = await benchmarkPair(
|
||||
dedupRun(server, hashes, dedupCurrent),
|
||||
dedupRun(server, hashes, dedupBatched),
|
||||
(current, candidate) => verifyDedup(current, candidate, hashCount),
|
||||
);
|
||||
output.dedup.push({ hashCount, ...result });
|
||||
printPair(
|
||||
"C dedup HTTP probe, " + hashCount.toLocaleString("en-US") + " hashes",
|
||||
result,
|
||||
);
|
||||
if (hashCount <= MAX_DEDUP_HASHES) {
|
||||
console.log(
|
||||
" both paths used one accepted request and found " +
|
||||
result.representative.current.ownedCount +
|
||||
" owned hashes",
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
" current rejected " +
|
||||
result.representative.current.rejectedRequests +
|
||||
" request and found " +
|
||||
result.representative.current.ownedCount +
|
||||
" owned hashes",
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
" candidate used " +
|
||||
result.representative.candidate.requests +
|
||||
" accepted batches, found " +
|
||||
result.representative.candidate.ownedCount +
|
||||
", and avoided " +
|
||||
formatBytes(result.representative.candidate.contentBytesAvoided) +
|
||||
" of modeled content upload",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
|
||||
const output = {
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
environment: {
|
||||
node: process.version,
|
||||
platform: process.platform,
|
||||
release: os.release(),
|
||||
arch: process.arch,
|
||||
cpu: os.cpus()[0]?.model ?? "unknown",
|
||||
logicalCpus: os.cpus().length,
|
||||
gcExposed: Boolean(global.gc),
|
||||
},
|
||||
config,
|
||||
notes: {
|
||||
heap: "Median heap delta is indicative only; timing is the primary microbenchmark metric.",
|
||||
dedup:
|
||||
"The current >10k path is faster only because it is rejected and returns no owned hashes.",
|
||||
},
|
||||
suites: {},
|
||||
};
|
||||
|
||||
if (!global.gc) {
|
||||
console.warn("Warning: run with --expose-gc for less noisy heap deltas.");
|
||||
}
|
||||
console.log(
|
||||
"Node " +
|
||||
process.version +
|
||||
"; warmup " +
|
||||
config.warmup +
|
||||
"; samples " +
|
||||
config.samples +
|
||||
"; GC exposed " +
|
||||
Boolean(global.gc),
|
||||
);
|
||||
|
||||
if (config.suite === "all" || config.suite === "queue")
|
||||
await runQueueSuite(output.suites);
|
||||
if (config.suite === "all" || config.suite === "progress")
|
||||
await runProgressSuite(output.suites);
|
||||
if (config.suite === "all" || config.suite === "dedup")
|
||||
await runDedupSuite(output.suites);
|
||||
|
||||
output.blackhole = blackhole;
|
||||
|
||||
if (values.output) {
|
||||
const destination = resolve(values.output);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await writeFile(destination, JSON.stringify(output, null, 2) + "\n");
|
||||
console.log("Wrote JSON result to " + destination);
|
||||
}
|
||||
Reference in New Issue
Block a user