Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions benchmark/http/heap-profiler-labels.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
'use strict';

// Benchmark: HTTP server throughput impact of heap profiler with labels.
//
// Measures requests/sec across three modes:
// - none: no profiler (baseline)
// - sampling: profiler active, no labels
// - sampling-with-labels: profiler active with labels via withHeapProfileLabels
//
// Workload per request: ~100KB V8 heap (JSON parse/stringify) + ~50KB Buffer
// to exercise both HeapProfileLabelsCallback and ProfilingArrayBufferAllocator.
//
// Run with compare.js:
// node benchmark/compare.js --old ./out/Release/node --new ./out/Release/node \
// --runs 10 --filter heap-profiler-labels --set c=50 -- http

const common = require('../common.js');
const { PORT } = require('../_http-benchmarkers.js');
const v8 = require('v8');

const bench = common.createBenchmark(main, {
mode: ['none', 'sampling', 'sampling-with-labels'],
c: [50],
duration: 10,
});

// Build a ~100KB JSON payload.
const items = [];
for (let i = 0; i < 200; i++) {
items.push({
id: i,
name: `user-${i}`,
email: `user${i}@example.com`,
role: 'admin',
metadata: { created: '2024-01-01', tags: ['a', 'b', 'c'] },
});
}
const payloadTemplate = JSON.stringify({ data: items, total: 200 });

function main({ mode, c, duration }) {
const http = require('http');

const interval = 512 * 1024; // 512 KiB, V8's default sampling interval.

let handle;
if (mode === 'sampling') {
handle = v8.startHeapProfile({ sampleInterval: interval });
} else if (mode === 'sampling-with-labels') {
handle = v8.startHeapProfile({ labels: true, sampleInterval: interval });
}

const server = http.createServer((req, res) => {
const handler = () => {
// 1. ~100KB V8 heap: JSON parse + stringify
const parsed = JSON.parse(payloadTemplate);
parsed.requestId = Math.random();
const body = JSON.stringify(parsed);

// 2. ~50KB Buffer
const buf = Buffer.alloc(50 * 1024, 0x42);

// Keep buf reference alive until response is sent.
res.writeHead(200, {
'Content-Type': 'application/json',
'Content-Length': body.length,
'X-Buf-Check': buf[0],
});
res.end(body);
};

if (mode === 'sampling-with-labels') {
v8.withHeapProfileLabels({ route: req.url }, handler);
} else {
handler();
}
});

server.listen(PORT, () => {
bench.http({
path: '/api/bench',
connections: c,
duration,
}, () => {
if (handle) {
handle.stop();
}
server.close();
});
});
}
155 changes: 155 additions & 0 deletions benchmark/http/heap-profiler-realistic.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
'use strict';

// Benchmark: HTTP app server plus DB server heap profiler overhead.
//
// Architecture: wrk → [App Server :PORT] → [DB Server :PORT+1]
//
// The app server fetches JSON rows from the DB server, parses,
// sums two columns over all rows, and returns the result. This exercises:
// - http.get (async I/O + Buffer allocation for response body)
// - JSON.parse of the DB response (V8 heap allocation)
// - Two iteration passes over the rows (intermediate values)
// - ALS label propagation across async I/O boundary
//
// Run with compare.js for statistical significance:
// node benchmark/compare.js --old ./out/Release/node --new ./out/Release/node \
// --runs 30 --filter heap-profiler-realistic --set rows=1000 -- http

const common = require('../common.js');
const { PORT } = require('../_http-benchmarkers.js');
const v8 = require('v8');
const http = require('http');

const DB_PORT = PORT + 1;

const bench = common.createBenchmark(main, {
mode: ['none', 'sampling', 'sampling-with-labels'],
rows: [100, 1000],
c: [50],
duration: 10,
});

// --- DB Server: pre-built JSON responses keyed by row count ---

function buildDBResponse(n) {
const categories = ['electronics', 'clothing', 'food', 'books', 'tools'];
const rows = [];
for (let i = 0; i < n; i++) {
rows.push({
id: i,
amount: Math.round(Math.random() * 10000) / 100,
quantity: Math.floor(Math.random() * 500),
name: `user-${String(i).padStart(6, '0')}`,
email: `user${i}@example.com`,
category: categories[i % categories.length],
});
}
const body = JSON.stringify({ rows, total: n });
return { body, len: Buffer.byteLength(body) };
}

// --- App Server helpers ---

function fetchFromDB(rows) {
return new Promise((resolve, reject) => {
const req = http.get(
`http://127.0.0.1:${DB_PORT}/?rows=${rows}`,
(res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString()));
} catch (e) {
reject(e);
}
});
},
);
req.on('error', reject);
});
}

function processRows(data) {
const { rows } = data;
// Two passes over the rows (column aggregation).
let totalAmount = 0;
for (let i = 0; i < rows.length; i++) {
totalAmount += rows[i].amount;
}
let totalQuantity = 0;
for (let i = 0; i < rows.length; i++) {
totalQuantity += rows[i].quantity;
}
return {
totalAmount: Math.round(totalAmount * 100) / 100,
totalQuantity,
count: rows.length,
};
}

function main({ mode, rows, c, duration }) {
// Pre-build DB responses, including for a row count passed on the command
// line. Without this the server falls back to the 1000-row response and
// silently measures something other than what was asked for.
const dbResponses = {};
for (const n of new Set([100, 1000, rows])) {
dbResponses[n] = buildDBResponse(n);
}

// Start DB server.
const dbServer = http.createServer((req, res) => {
const url = new URL(req.url, `http://127.0.0.1:${DB_PORT}`);
const n = parseInt(url.searchParams.get('rows') || '1000', 10);
const resp = dbResponses[n] || dbResponses[1000];
res.writeHead(200, {
'Content-Type': 'application/json',
'Content-Length': resp.len,
});
res.end(resp.body);
});

dbServer.listen(DB_PORT, () => {
const interval = 512 * 1024;
let handle;
if (mode === 'sampling') {
handle = v8.startHeapProfile({ sampleInterval: interval });
} else if (mode === 'sampling-with-labels') {
handle = v8.startHeapProfile({ labels: true, sampleInterval: interval });
}

// Start app server.
const appServer = http.createServer((req, res) => {
const handler = async () => {
const data = await fetchFromDB(rows);
const result = processRows(data);
const body = JSON.stringify(result);
res.writeHead(200, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
});
res.end(body);
};

if (mode === 'sampling-with-labels') {
v8.withHeapProfileLabels({ route: req.url }, handler);
} else {
handler();
}
});

appServer.listen(PORT, () => {
bench.http({
path: '/api/data',
connections: c,
duration,
}, () => {
if (handle) {
handle.stop();
}
appServer.close();
dbServer.close();
});
});
});
}
81 changes: 81 additions & 0 deletions benchmark/v8/heap-profiler-labels-resolution.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
'use strict';

// Benchmark: cost of getAllocationProfile() label resolution.
//
// Builds up live samples under withHeapProfileLabels() across N unique
// label sets, then measures wall-clock time to call getAllocationProfile()
// repeatedly. Varying samples_per_unique_label_set shows whether resolution
// cost scales with sample count or unique label sets.
//
// Run standalone:
// node benchmark/v8/heap-profiler-labels-resolution.js
//
// Run with compare.js for statistical analysis:
// node benchmark/compare.js --old ./node-baseline --new ./node-cached \
// --filter heap-profiler-labels-resolution
//
// Memory: each retained sample corresponds to ~sampleInterval bytes of
// live heap. With the 512 KiB default and a target of 5,000 samples the
// workload retains ~4 GiB after compensating for the V8 sampler dropping
// ~37% of one-sampleInterval-sized allocations. Hence
// --max-old-space-size=6144.

const common = require('../common.js');
const v8 = require('v8');

const SAMPLE_INTERVAL = 512 * 1024; // V8 default
const RETAINED_SAMPLES_TARGET = 5000;
// V8's sampler picks each allocation of size A with probability
// 1 - exp(-A / sampleInterval). For A = sampleInterval that's ~63%, so
// allocate ~1.6x as many chunks as the desired sample count.
const SAMPLE_PROBABILITY = 1 - Math.exp(-1);

const bench = common.createBenchmark(main, {
samples_per_unique_label_set: [1, 10, 100, 1000],
n: [20],
}, {
flags: ['--max-old-space-size=6144'],
});

function main({ samples_per_unique_label_set: samplesPerLabel, n }) {
const uniqueLabelSets = Math.max(
1, Math.ceil(RETAINED_SAMPLES_TARGET / samplesPerLabel),
);
const chunksPerLabel = Math.max(
1, Math.ceil(samplesPerLabel / SAMPLE_PROBABILITY),
);
// Each chunk is one sampleInterval of JS heap (a JSArray of Smi slots).
// JSArray over plain strings here because String.repeat() of a single
// ASCII char appears to bypass the sampler's allocation observers in
// large-object-space, while typed JSArray allocations are reliably
// sampled.
const SLOTS_PER_CHUNK = SAMPLE_INTERVAL / 8;

const handle = v8.startHeapProfile({ labels: true, sampleInterval: SAMPLE_INTERVAL });

// The sampling profiler tracks each sample with a weak global; once
// the underlying object is GC'd the sample is dropped. Holding strong
// references in this retainer keeps samples live throughout the
// measurement loop below.
const retainer = [];
for (let i = 0; i < uniqueLabelSets; i++) {
const labels = { route: `/route-${i}`, method: 'GET' };
v8.withHeapProfileLabels(labels, () => {
for (let j = 0; j < chunksPerLabel; j++) {
retainer.push(new Array(SLOTS_PER_CHUNK).fill(0));
}
});
}

bench.start();
for (let i = 0; i < n; i++) {
const profile = handle.getAllocationProfile();
// Defensive use of the result so the call is not eliminated.
if (!profile) throw new Error('profile missing');
}
bench.end(n);

handle.stop();
// Touch retainer post-bench so it stays live across the measurement.
if (retainer.length === 0) throw new Error('retainer leaked');
}
60 changes: 60 additions & 0 deletions benchmark/v8/heap-profiler-labels.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
'use strict';

// Benchmark: overhead of V8 sampling heap profiler with and without labels.
//
// Measures per-allocation cost across three modes:
// - none: no profiler running (baseline)
// - sampling: profiler active, no labels callback
// - sampling-with-labels: profiler active with labels via withHeapProfileLabels
//
// Run standalone:
// node benchmark/v8/heap-profiler-labels.js
//
// Run with compare.js for statistical analysis:
// node benchmark/compare.js --old ./node-baseline --new ./node-with-labels \
// --filter heap-profiler-labels

const common = require('../common.js');
const v8 = require('v8');

const bench = common.createBenchmark(main, {
mode: ['none', 'sampling', 'sampling-with-labels'],
n: [1e6],
});

function main({ mode, n }) {
const interval = 512 * 1024; // 512 KiB, V8's default sampling interval.

let handle;
if (mode === 'sampling') {
handle = v8.startHeapProfile({ sampleInterval: interval });
} else if (mode === 'sampling-with-labels') {
handle = v8.startHeapProfile({ labels: true, sampleInterval: interval });
}

if (mode === 'sampling-with-labels') {
v8.withHeapProfileLabels({ route: '/bench' }, () => {
runWorkload(n);
});
} else {
runWorkload(n);
}

if (handle) {
handle.stop();
}
}

function runWorkload(n) {
const arr = [];
bench.start();
for (let i = 0; i < n; i++) {
// Allocate objects with string properties. Each object is ~100-200
// bytes on the V8 heap.
arr.push({ id: i, name: `item-${i}`, value: Math.random() });
// Retain the last 1000 objects to keep steady-state GC pressure
// without unbounded growth.
if (arr.length > 1000) arr.shift();
}
bench.end(n);
}
Loading