@zakkster/lite-perf-gate 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/PerfGate.d.ts +7 -1
- package/PerfGate.js +35 -10
- package/README.md +12 -0
- package/llms.txt +15 -1
- package/package.json +60 -60
package/CHANGELOG.md
CHANGED
|
@@ -22,6 +22,18 @@ Three signals: scavenge count (transient allocation), custom counters
|
|
|
22
22
|
Scaling verdict: measure at N and k*N. Detector self-validation: the gate
|
|
23
23
|
refuses to judge if the controls misbehave.
|
|
24
24
|
|
|
25
|
+
### CI tuning
|
|
26
|
+
|
|
27
|
+
- `flushMs` option (default 100ms; env var `PERF_GATE_FLUSH_MS`) controls
|
|
28
|
+
how long the harness waits after the hot loop before reading the
|
|
29
|
+
perf_hooks GC observer buffer. Bump to 250-500ms on noisy CI runners
|
|
30
|
+
where event-loop stalls can drop GC entries.
|
|
31
|
+
- `gc2()` yields a `setImmediate` tick between its two `globalThis.gc()`
|
|
32
|
+
passes so any `FinalizationRegistry` cleanups scheduled by the first
|
|
33
|
+
pass run before the second. Prevents WeakRef/finalizer-driven teardown
|
|
34
|
+
(used in `lite-cleanup`, `lite-observe`, `lite-floating`) from leaving
|
|
35
|
+
the heap in an intermediate state and inflating retained deltas.
|
|
36
|
+
|
|
25
37
|
### Tests
|
|
26
38
|
|
|
27
39
|
12 self-tests covering controls, verdict logic, counter deltas, and
|
package/PerfGate.d.ts
CHANGED
|
@@ -79,6 +79,12 @@ export interface GateConfig {
|
|
|
79
79
|
negativeControl?: Scenario;
|
|
80
80
|
/** Scenarios that MUST trip the gate (injected allocation self-tests). */
|
|
81
81
|
mustFail?: Scenario[];
|
|
82
|
+
/**
|
|
83
|
+
* Wait (ms) after the hot loop before reading the GC observer buffer.
|
|
84
|
+
* Default 100 (or PERF_GATE_FLUSH_MS env var). Bump to 250-500 on
|
|
85
|
+
* noisy CI runners where event-loop stalls > 100ms may drop entries.
|
|
86
|
+
*/
|
|
87
|
+
flushMs?: number;
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
export interface GateResult {
|
|
@@ -91,7 +97,7 @@ export interface GateResult {
|
|
|
91
97
|
*/
|
|
92
98
|
export function measure(
|
|
93
99
|
scenario: Scenario,
|
|
94
|
-
options?: { N?: number; k?: number }
|
|
100
|
+
options?: { N?: number; k?: number; flushMs?: number }
|
|
95
101
|
): Promise<MeasureResult>;
|
|
96
102
|
|
|
97
103
|
/**
|
package/PerfGate.js
CHANGED
|
@@ -48,33 +48,53 @@ function makeGcCounter() {
|
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
function gc2() {
|
|
51
|
+
async function gc2() {
|
|
52
52
|
if (typeof globalThis.gc === 'function') {
|
|
53
53
|
globalThis.gc();
|
|
54
|
+
// Yield one task tick so any FinalizationRegistry cleanup callbacks
|
|
55
|
+
// registered from the first gc() get a chance to run before the
|
|
56
|
+
// second pass. Without this, WeakRef/FinalizationRegistry-driven
|
|
57
|
+
// teardown (used across the ecosystem in lite-cleanup / lite-observe
|
|
58
|
+
// / lite-floating) can leave the heap in an intermediate state
|
|
59
|
+
// between the two collections, inflating the retained delta.
|
|
60
|
+
await new Promise(function (r) { setImmediate(r); });
|
|
54
61
|
globalThis.gc();
|
|
55
62
|
}
|
|
56
63
|
}
|
|
57
64
|
|
|
65
|
+
// Default post-hot sleep before reading the GC observer buffer. The Node
|
|
66
|
+
// perf_hooks 'gc' entries are delivered asynchronously, so we need to yield
|
|
67
|
+
// long enough for the observer callback to fire. 100ms is a safe floor on
|
|
68
|
+
// most systems; noisy CI runners (shared GitHub Actions, Docker under
|
|
69
|
+
// contention) can stall the event loop for tens of milliseconds and drop
|
|
70
|
+
// entries with a shorter wait. Override per-measurement via the `flushMs`
|
|
71
|
+
// option, or globally via the PERF_GATE_FLUSH_MS environment variable.
|
|
72
|
+
var DEFAULT_FLUSH_MS = 100;
|
|
73
|
+
if (typeof process !== 'undefined' && process.env && process.env.PERF_GATE_FLUSH_MS) {
|
|
74
|
+
var envMs = parseInt(process.env.PERF_GATE_FLUSH_MS, 10);
|
|
75
|
+
if (envMs > 0 && envMs < 60000) DEFAULT_FLUSH_MS = envMs;
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
// ---------------------------------------------------------------------------
|
|
59
79
|
// Core measurement
|
|
60
80
|
// ---------------------------------------------------------------------------
|
|
61
81
|
|
|
62
|
-
async function meterOnce(scenario, iters) {
|
|
82
|
+
async function meterOnce(scenario, iters, flushMs) {
|
|
63
83
|
const state = scenario.setup();
|
|
64
84
|
scenario.hot(state, Math.min(iters, 20000));
|
|
65
|
-
gc2();
|
|
85
|
+
await gc2();
|
|
66
86
|
|
|
67
87
|
const statsBefore = scenario.statsOf ? scenario.statsOf(state) : null;
|
|
68
88
|
const heapBefore = process.memoryUsage().heapUsed;
|
|
69
89
|
|
|
70
90
|
const gcc = makeGcCounter();
|
|
71
91
|
scenario.hot(state, iters);
|
|
72
|
-
await sleep(
|
|
92
|
+
await sleep(flushMs);
|
|
73
93
|
const minor = gcc.c.minor;
|
|
74
94
|
const major = gcc.c.major;
|
|
75
95
|
gcc.close();
|
|
76
96
|
|
|
77
|
-
gc2();
|
|
97
|
+
await gc2();
|
|
78
98
|
const heapAfter = process.memoryUsage().heapUsed;
|
|
79
99
|
const statsAfter = scenario.statsOf ? scenario.statsOf(state) : null;
|
|
80
100
|
if (scenario.teardown) scenario.teardown(state);
|
|
@@ -114,14 +134,19 @@ async function meterOnce(scenario, iters) {
|
|
|
114
134
|
* allocation via scavenge scaling.
|
|
115
135
|
*
|
|
116
136
|
* @param {Scenario} scenario
|
|
117
|
-
* @param {{ N?: number, k?: number }} [options]
|
|
137
|
+
* @param {{ N?: number, k?: number, flushMs?: number }} [options]
|
|
138
|
+
* flushMs: how long to wait after the hot loop before reading the GC
|
|
139
|
+
* observer buffer. Default 100ms (or PERF_GATE_FLUSH_MS env var).
|
|
140
|
+
* Bump if you see zero scavenges on scenarios that should allocate --
|
|
141
|
+
* noisy CI runners may need 250-500ms.
|
|
118
142
|
* @returns {Promise<MeasureResult>}
|
|
119
143
|
*/
|
|
120
144
|
export async function measure(scenario, options) {
|
|
121
145
|
const N = (options && options.N) || 200000;
|
|
122
146
|
const k = (options && options.k) || 8;
|
|
123
|
-
const
|
|
124
|
-
const
|
|
147
|
+
const flushMs = (options && options.flushMs) || DEFAULT_FLUSH_MS;
|
|
148
|
+
const lo = await meterOnce(scenario, N, flushMs);
|
|
149
|
+
const hi = await meterOnce(scenario, k * N, flushMs);
|
|
125
150
|
return {
|
|
126
151
|
name: scenario.name, N: N, k: k,
|
|
127
152
|
minorLo: lo.minor, minorHi: hi.minor,
|
|
@@ -264,7 +289,7 @@ export function formatResult(r) {
|
|
|
264
289
|
*/
|
|
265
290
|
export function zgcSuite(config) {
|
|
266
291
|
const scenarios = config.scenarios;
|
|
267
|
-
const opts = {N: config.N || 200000, k: config.k || 8};
|
|
292
|
+
const opts = {N: config.N || 200000, k: config.k || 8, flushMs: config.flushMs};
|
|
268
293
|
const thresholds = {
|
|
269
294
|
maxScavenges: config.maxScavenges !== undefined ? config.maxScavenges : 2,
|
|
270
295
|
maxRetainedKB: config.maxRetainedKB !== undefined ? config.maxRetainedKB : 64,
|
|
@@ -327,7 +352,7 @@ export function zgcSuite(config) {
|
|
|
327
352
|
*/
|
|
328
353
|
export async function runGate(config) {
|
|
329
354
|
const scenarios = config.scenarios;
|
|
330
|
-
const opts = {N: config.N || 200000, k: config.k || 8};
|
|
355
|
+
const opts = {N: config.N || 200000, k: config.k || 8, flushMs: config.flushMs};
|
|
331
356
|
const thresholds = {
|
|
332
357
|
maxScavenges: config.maxScavenges !== undefined ? config.maxScavenges : 2,
|
|
333
358
|
maxRetainedKB: config.maxRetainedKB !== undefined ? config.maxRetainedKB : 64,
|
package/README.md
CHANGED
|
@@ -134,6 +134,18 @@ Two V8 gotchas the controls handle:
|
|
|
134
134
|
|
|
135
135
|
The default V8 semi-space is 16MB. At 200k iterations of 64-byte objects, you produce ~12MB -- possibly fitting without a single scavenge. `--max-semi-space-size=4` shrinks the young generation to 4MB, forcing scavenges on smaller cumulative allocation and sharpening sensitivity. The four-field positive control (`{x,y,z,w}`) provides margin even without this flag, but the flag is recommended for production gates.
|
|
136
136
|
|
|
137
|
+
## CI tuning
|
|
138
|
+
|
|
139
|
+
Node's `perf_hooks` delivers GC entries to the observer asynchronously. After the hot loop the harness waits before reading the entry count so the observer has time to flush. Default wait is **100ms**, which is safe on a dev machine but can be marginal on saturated CI runners.
|
|
140
|
+
|
|
141
|
+
If you see intermittent zero-scavenge reports on scenarios that clearly allocate:
|
|
142
|
+
|
|
143
|
+
- Bump globally via env var: `PERF_GATE_FLUSH_MS=300 node --test ...`
|
|
144
|
+
- Per-suite: `zgcSuite({ scenarios, flushMs: 300 })`
|
|
145
|
+
- Per-call: `measure(scenario, { flushMs: 500 })`
|
|
146
|
+
|
|
147
|
+
`gc2()` (the harness's forced-collection helper) does one `gc()`, yields a `setImmediate` tick to let `FinalizationRegistry` cleanups run, then does a second `gc()`. Without the yield, WeakRef/finalizer-driven teardown (used across the ecosystem in `lite-cleanup`, `lite-observe`, `lite-floating`) can leave the heap in an intermediate state between the two collections and inflate the retained-heap delta.
|
|
148
|
+
|
|
137
149
|
## License
|
|
138
150
|
|
|
139
151
|
MIT (c) Zahary Shinikchiev
|
package/llms.txt
CHANGED
|
@@ -21,8 +21,9 @@ zgcSuite(config) -- register node:test cases.
|
|
|
21
21
|
config.counters: Record<string, number> -- per-counter max delta.
|
|
22
22
|
config.mustFail: Scenario[] -- must trip the gate (self-test).
|
|
23
23
|
config.N: number (default 200000), config.k: number (default 8).
|
|
24
|
+
config.flushMs: number (default 100, or PERF_GATE_FLUSH_MS env var).
|
|
24
25
|
|
|
25
|
-
measure(scenario, {N, k}) -- raw MeasureResult.
|
|
26
|
+
measure(scenario, {N, k, flushMs}) -- raw MeasureResult.
|
|
26
27
|
verdict(result, thresholds) -- { pass: boolean, reasons: string[] }.
|
|
27
28
|
runGate(config) -- standalone report, same config as zgcSuite.
|
|
28
29
|
|
|
@@ -39,6 +40,19 @@ node --expose-gc --max-semi-space-size=4 --test your.test.mjs
|
|
|
39
40
|
|
|
40
41
|
--expose-gc is required. --max-semi-space-size=4 sharpens sensitivity.
|
|
41
42
|
|
|
43
|
+
## CI tuning
|
|
44
|
+
|
|
45
|
+
perf_hooks delivers GC entries asynchronously. Default 100ms wait after
|
|
46
|
+
hot loop is safe on dev machines but can be marginal on saturated CI
|
|
47
|
+
runners. If scenarios that should allocate report zero scavenges, bump
|
|
48
|
+
flushMs (via option) or PERF_GATE_FLUSH_MS env var to 250-500ms.
|
|
49
|
+
|
|
50
|
+
gc2() yields a setImmediate tick between its two gc() calls so any
|
|
51
|
+
FinalizationRegistry cleanups scheduled by the first run before the
|
|
52
|
+
second. Essential for ecosystems using WeakRef/FinalizationRegistry
|
|
53
|
+
(lite-cleanup / lite-observe / lite-floating) -- without it the two-pass
|
|
54
|
+
GC can leave the heap in an intermediate state and inflate retained deltas.
|
|
55
|
+
|
|
42
56
|
## Why scavenge counting
|
|
43
57
|
|
|
44
58
|
Retained-heap delta misses transient garbage (freed before snapshot).
|
package/package.json
CHANGED
|
@@ -1,63 +1,63 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
2
|
+
"name": "@zakkster/lite-perf-gate",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
5
|
+
"description": "Zero-GC and performance regression gate for node:test. Scavenge-counting, scaling verdict, detector self-validation. Proves your hot path allocates nothing -- or names what did.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"main": "./PerfGate.js",
|
|
9
|
+
"module": "./PerfGate.js",
|
|
10
|
+
"types": "./PerfGate.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./PerfGate.d.ts",
|
|
14
|
+
"node": "./PerfGate.js",
|
|
15
|
+
"import": "./PerfGate.js",
|
|
16
|
+
"default": "./PerfGate.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"PerfGate.js",
|
|
21
|
+
"PerfGate.d.ts",
|
|
22
|
+
"README.md",
|
|
23
|
+
"llms.txt",
|
|
24
|
+
"LICENSE",
|
|
25
|
+
"CHANGELOG.md"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node --expose-gc --max-semi-space-size=4 --test test/self.test.mjs"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"benchmark",
|
|
32
|
+
"regression",
|
|
33
|
+
"gate",
|
|
34
|
+
"zero-gc",
|
|
35
|
+
"gc",
|
|
36
|
+
"scavenge",
|
|
37
|
+
"allocation",
|
|
38
|
+
"performance",
|
|
39
|
+
"node-test",
|
|
40
|
+
"ci",
|
|
41
|
+
"perf-hooks"
|
|
42
|
+
],
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"license": "MIT",
|
|
47
|
+
"homepage": "https://github.com/PeshoVurtoleta/lite-perf-gate#readme",
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/PeshoVurtoleta/lite-perf-gate.git"
|
|
51
|
+
},
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/PeshoVurtoleta/lite-perf-gate/issues",
|
|
54
|
+
"email": "shinikchiev@yahoo.com"
|
|
55
|
+
},
|
|
56
|
+
"funding": {
|
|
57
|
+
"type": "github",
|
|
58
|
+
"url": "https://github.com/sponsors/PeshoVurtoleta"
|
|
59
|
+
},
|
|
60
|
+
"engines": {
|
|
61
|
+
"node": ">=18"
|
|
17
62
|
}
|
|
18
|
-
},
|
|
19
|
-
"files": [
|
|
20
|
-
"PerfGate.js",
|
|
21
|
-
"PerfGate.d.ts",
|
|
22
|
-
"README.md",
|
|
23
|
-
"llms.txt",
|
|
24
|
-
"LICENSE",
|
|
25
|
-
"CHANGELOG.md"
|
|
26
|
-
],
|
|
27
|
-
"scripts": {
|
|
28
|
-
"test": "node --expose-gc --max-semi-space-size=4 --test test/self.test.mjs"
|
|
29
|
-
},
|
|
30
|
-
"keywords": [
|
|
31
|
-
"benchmark",
|
|
32
|
-
"regression",
|
|
33
|
-
"gate",
|
|
34
|
-
"zero-gc",
|
|
35
|
-
"gc",
|
|
36
|
-
"scavenge",
|
|
37
|
-
"allocation",
|
|
38
|
-
"performance",
|
|
39
|
-
"node-test",
|
|
40
|
-
"ci",
|
|
41
|
-
"perf-hooks"
|
|
42
|
-
],
|
|
43
|
-
"publishConfig": {
|
|
44
|
-
"access": "public"
|
|
45
|
-
},
|
|
46
|
-
"license": "MIT",
|
|
47
|
-
"homepage": "https://github.com/PeshoVurtoleta/lite-perf-gate#readme",
|
|
48
|
-
"repository": {
|
|
49
|
-
"type": "git",
|
|
50
|
-
"url": "git+https://github.com/PeshoVurtoleta/lite-perf-gate.git"
|
|
51
|
-
},
|
|
52
|
-
"bugs": {
|
|
53
|
-
"url": "https://github.com/PeshoVurtoleta/lite-perf-gate/issues",
|
|
54
|
-
"email": "shinikchiev@yahoo.com"
|
|
55
|
-
},
|
|
56
|
-
"funding": {
|
|
57
|
-
"type": "github",
|
|
58
|
-
"url": "https://github.com/sponsors/PeshoVurtoleta"
|
|
59
|
-
},
|
|
60
|
-
"engines": {
|
|
61
|
-
"node": ">=18"
|
|
62
|
-
}
|
|
63
63
|
}
|