diff --git a/doc/Plugins.md b/doc/Plugins.md index 4a9c63c..547b75c 100644 --- a/doc/Plugins.md +++ b/doc/Plugins.md @@ -167,3 +167,7 @@ optimization. The `V8GetOptimizationStatus` plugin collects the V8 engine's optimization status for a given function after it has been benchmarked. + +### Class: `RecordMemorySpikePlugin` + +A plugin to record memory allocation spikes in your benchmark's run. It should help you understand the speed vs memory tradeoffs you're making. diff --git a/lib/index.js b/lib/index.js index 4ae5843..a0a90ad 100644 --- a/lib/index.js +++ b/lib/index.js @@ -15,6 +15,7 @@ const { V8NeverOptimizePlugin, V8GetOptimizationStatus, V8OptimizeOnNextCallPlugin, + RecordMemorySpikePlugin, } = require('./plugins'); const { validateFunction, @@ -205,6 +206,7 @@ module.exports = { V8NeverOptimizePlugin, V8GetOptimizationStatus, V8OptimizeOnNextCallPlugin, + RecordMemorySpikePlugin, chartReport, textReport, htmlReport, diff --git a/lib/plugins.js b/lib/plugins.js index b18a631..2990d22 100644 --- a/lib/plugins.js +++ b/lib/plugins.js @@ -12,6 +12,10 @@ const { MemoryPlugin, } = require('./plugins/memory'); +const { + RecordMemorySpikePlugin +} = require('./plugins/memory-spike'); + const { validateFunction, validateArray, @@ -53,5 +57,6 @@ module.exports = { V8NeverOptimizePlugin, V8GetOptimizationStatus, V8OptimizeOnNextCallPlugin, + RecordMemorySpikePlugin, validatePlugins, }; diff --git a/lib/plugins/memory-spike.js b/lib/plugins/memory-spike.js new file mode 100644 index 0000000..9f6dde6 --- /dev/null +++ b/lib/plugins/memory-spike.js @@ -0,0 +1,109 @@ +const v8 = require("node:v8"); + +const translateHeapStats = (stats = []) => { + const result = {}; + for (const { space_name, space_used_size } of stats) { + result[space_name] = space_used_size; + } + return result; +}; + +const updateMaxEachKey = (current, update) => { + for (const key in current) { + current[key] = Math.max(current[key], update[key]); + } +}; + +const diffEachKey = (a, b, divBy = 1) => { + const result = {}; + for (const key in a) { + result[key] = (b[key] - a[key]) / divBy; + } + return result; +}; + +const avgEachKey = (items) => { + const result = {}; + for (const item of items) { + for (const key in item) { + result[key] = (result[key] || 0) + item[key]; + } + } + for (const key in result) { + result[key] /= items.length; + } + + return result; +}; + +const toHumanReadable = (obj) => { + const result = {}; + for (const key in obj) { + if (obj[key] > 0) result[key] = `+${(obj[key] / 1024).toFixed(4)} KB`; + } + return result; +}; + +globalThis.__recordMemorySpike = (frequency = 2) => { + const initial = translateHeapStats(v8.getHeapSpaceStatistics()); + const result = { ...initial }; + const collect = () => + updateMaxEachKey(result, translateHeapStats(v8.getHeapSpaceStatistics())); + const interval = setInterval(collect, frequency); + return { + collect, + getResult: () => { + clearInterval(interval); + collect(); + return [initial, result]; + }, + }; +}; + +class RecordMemorySpikePlugin { + #spikeSamples = {}; + isSupported() { + try { + new Function(`gc()`)(); + return true; + } catch (e) { + return false; + } + } + + beforeClockTemplate() { + return [`const __mem_spike__ = __recordMemorySpike();`]; + } + afterClockTemplate({ context, bench }) { + return [ + `; + ${context}.benchName=${bench}.name; + ${context}.memSpike = __mem_spike__.getResult(); + `, + ]; + } + + onCompleteBenchmark([_time, iterations, results]) { + gc(); + const [initial, result] = results.memSpike; + const diff = diffEachKey(initial, result, iterations); + if (!this.#spikeSamples[results.benchName]) { + this.#spikeSamples[results.benchName] = []; + } + this.#spikeSamples[results.benchName].push(diff); + } + + getResult(name) { + return toHumanReadable(avgEachKey(this.#spikeSamples[name])); + } + + getReport() { + process._rawDebug('grp',arguments); + + } + + toString() { + return "RecordMemorySpikePlugin"; + } +} +exports.RecordMemorySpikePlugin = RecordMemorySpikePlugin; diff --git a/package.json b/package.json index fad2318..a1ed1ee 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "", "main": "lib/index.js", "scripts": { - "test": "node --test --allow-natives-syntax" + "test": "node --test --allow-natives-syntax --expose-gc" }, "repository": { "type": "git", diff --git a/test/plugin-memspike.js b/test/plugin-memspike.js new file mode 100644 index 0000000..5f40945 --- /dev/null +++ b/test/plugin-memspike.js @@ -0,0 +1,48 @@ +// @ts-check +const { RecordMemorySpikePlugin, Suite +} = require('../lib/index'); + +const { test } = require("node:test"); +const assert = require("node:assert"); +const { setTimeout } = require("node:timers/promises"); + +const wasteMemoryForAWhile = async () => { + const a = Buffer.alloc(1024 * 1024, "a"); + await setTimeout(5); + a.at(1); // prevent optimization +}; +function noop() {} + +test("RecordMemorySpikePlugin", async (t) => { + const bench = new Suite({ + reporter: noop, + plugins: [new RecordMemorySpikePlugin()], + }); + bench + .add("sequence", async () => { + for (let i = 0; i < 20; i++) { + await wasteMemoryForAWhile(); + } + }) + .add("concurent", async () => { + await Promise.all( + Array.from({ length: 20 }, () => wasteMemoryForAWhile()), + ); + }); + + const [bench1, bench2] = await bench.run(); + console.dir( + { + bench1, + bench2, + }, + { depth: 100 }, + ); + + const { plugins: [{ result: result1 }] } = bench1; + const { plugins: [{ result: result2 }] } = bench2; + const parseResult = (str) => parseFloat(str.replace(/[^\d.-]/g, '')); + assert.ok(parseResult(result1.new_space) > parseResult(result2.new_space), "Sequence new_space should be larger than concurrent new_space"); + assert.ok(parseResult(result1.old_space) > parseResult(result2.old_space), "Sequence old_space should be larger than concurrent old_space"); + +});