@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 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(40);
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 lo = await meterOnce(scenario, N);
124
- const hi = await meterOnce(scenario, k * N);
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
- "name": "@zakkster/lite-perf-gate",
3
- "version": "1.0.0",
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"
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
  }