@zakkster/lite-perf-gate 1.2.1 → 1.3.0

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/PerfGate.js +86 -25
  3. package/package.json +8 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,114 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.3.0] - 2026-09-13
4
+
5
+ ### Changed
6
+
7
+ - **Positive control is a bounded 64-slot ring, not a grow-forever sink.**
8
+ The stock control stored every `{x,y,z,w}` into a module-level array to
9
+ defeat escape analysis -- but 100% survival trained V8's allocation-site
10
+ pretenuring, so the site was promoted to old space and the scavenge signal
11
+ died. At LIBRARY DEFAULTS in a fresh process the count went DOWN as the
12
+ work went UP and `runGate` refused with DETECTOR VALIDATION FAILED (PG-01).
13
+ The control now writes `__posRing[i & 63] = {x,y,z,w}`: the store still
14
+ escapes, but every object is overwritten within 64 iterations, so the
15
+ site's survival ratio stays ~0. Measured, fresh process, N=200000 k=8,
16
+ `--expose-gc --max-semi-space-size=4`:
17
+
18
+ | control | minorLo | minorHi | majorHi | retainedKB_hi |
19
+ | -------------------------- | ------- | ------- | ------- | ------------- |
20
+ | stock grow-forever sink | 2 | 1 | 1 | ~100784 |
21
+ | 64-slot ring (this release)| 2 | 21 | 0 | ~11 |
22
+
23
+ See `decisions/0001-positive-control.md`.
24
+ - **Footprint is bounded by construction.** One `measure(controlPositive)`
25
+ at defaults grew the post-gc heap ~113MB with the stock sink and poisoned
26
+ every later measurement in the same process (a subsequent negative control
27
+ then forced 6 scavenges against a ceiling of 2, PG-10). At most 64 objects
28
+ (~3KB) are retained now; post-gc growth is sub-megabyte.
29
+ - **`_controlKeepAlive()` returns ring occupancy (0..64), not a push count.**
30
+ Its purpose -- a read that touches every slot so V8 cannot sink the stores
31
+ -- is unchanged; it no longer returns a monotonically growing number.
32
+ - **Detector-validation margin is decoupled from the consumer's budget.**
33
+ The old predicate `pos.minorHi > maxScavenges + 3` coupled the evidence
34
+ the instrument owes to the budget the user set for their own code
35
+ (`maxScavenges: 40` silently demanded a 43-scavenge control). One shared
36
+ predicate `validateDetector(pos, neg, maxScav)` now governs both zgcSuite
37
+ and runGate, with its own constants: `CONTROL_FLOOR = 6` (positive control
38
+ scavenges at k*N), `CONTROL_SCALE = 2` (minorHi >= 2*minorLo when
39
+ minorLo > 0), `CONTROL_NEG_CEIL = 2` (negative ceiling, tightened by
40
+ `min(maxScavenges, 2)`). Every clause is written `!(x >= limit)` so a NaN
41
+ count fails closed. A consumer at the default `maxScavenges: 2` sees the
42
+ identical effective floor.
43
+ - **One detector-validation test replaces two.** zgcSuite measures positive
44
+ then negative in one test so the negative doubles as the in-suite
45
+ poisoning regression.
46
+
47
+ ### Added
48
+
49
+ - **Torture suite** (`npm run torture`, `test/torture.mjs` +
50
+ `test/torture/`): T1 detector matrix (bare child processes at library
51
+ defaults -- the PG-01 reproduction inverted, ring scaling at N in
52
+ {50000, 200000}, and the PG-10 poisoning regression) and T5 footprint +
53
+ 4096-cycle soak (bounded control footprint, lite-leak registration count
54
+ back to 0, flat heap band, `checkNoGc` pass over the profiler window).
55
+ T2/T3/T4 register as named skipped tiers for P2/P3/P4. Two env-gated
56
+ controls-for-the-controls prove the tiers can fail:
57
+ `TORTURE_CONTROL=stock-control` fails T1, `TORTURE_CONTROL=leaky-soak`
58
+ fails T5.
59
+ - **Two child-process self-tests** (PG-15): zgcSuite is green end-to-end at
60
+ library defaults, and goes red (DETECTOR VALIDATION FAILED / need >=6)
61
+ when the positive control is sabotaged with the stock grow-forever shape.
62
+ 26 -> 28 self-tests.
63
+ - **devDependencies** `@zakkster/lite-gc-profiler ^1.16.0` and
64
+ `@zakkster/lite-leak ^1.10.0` -- dev-only, for the torture suite; the
65
+ library still ships zero runtime dependencies and `files[]` is unchanged.
66
+ - **`.gitignore`** for `/node_modules/`, `/package-lock.json`, `.DS_Store`
67
+ (the lockfile is not committed in this suite).
68
+
69
+ ### Notes
70
+
71
+ - Every number above was measured on a single machine: darwin, Node
72
+ v26.3.1, `--expose-gc --max-semi-space-size=4`, reproduced twice. There
73
+ is no nvm here; the multi-Node matrix is a CI intent, not a claim this
74
+ release verifies. `CONTROL_FLOOR = 6` absorbs the large-semi-space
75
+ direction (a default 16MB young generation buys ~5-6 scavenges instead of
76
+ 21); scavenge count is a function of bytes allocated per semi-space byte,
77
+ not of CPU speed.
78
+ - No verdict changes (P2), no new signals such as maxMajors or external
79
+ deltas (P3), no suiteGate work (P4), no README rewrite (P5). The
80
+ measurement window in `meterOnce` and suiteGate's visit are byte-identical
81
+ to 1.2.2.
82
+ - The demo `<title>` no longer embeds a version; it went stale every release.
83
+
84
+ ## [1.2.2] - 2026-09-13
85
+
86
+ - **Version truth**: `VERSION` reads `1.2.2` and agrees with
87
+ `package.json` and this file. The published 1.2.1 tarball shipped
88
+ `VERSION = '1.2.0'`; see the 1.2.1 entry below.
89
+ - **`prepublishOnly: npm test`** -- the publish path now runs the suite.
90
+ The guard test caught the 1.2.1 slip locally; nothing forced it to run
91
+ before `npm publish`. That gap, not the string, was the defect.
92
+ - **Self-test**: the VERSION test no longer pins a literal; it asserts
93
+ only `VERSION === package.json.version`. The literal forced a test edit
94
+ every release, which is the friction that got skipped.
95
+ - Housekeeping: `var -> let/const` in `PerfGate.js`, trailing newline at
96
+ EOF restored, demo `<title>` version.
97
+ - No API change and no behavior change to any exported function.
98
+
99
+ ## [1.2.1] - 2026-07-08
100
+
101
+ Retroactive entry, written 2026-09-13: 1.2.1 was published with no
102
+ changelog entry.
103
+
104
+ - Housekeeping only: `var -> let/const` cleanup in `PerfGate.js` and the
105
+ `package.json` bump. Verified by unpacking the published tarball: no
106
+ API change and no behavior change relative to 1.2.0.
107
+ - **Known defect, fixed in 1.2.2**: the tarball shipped
108
+ `export const VERSION = '1.2.0';` and a CHANGELOG ending at 1.2.0. The
109
+ self-test that asserts VERSION against the manifest would have caught
110
+ it; there was no `prepublishOnly` to make it run.
111
+
3
112
  ## [1.2.0] - 2026-07-07
4
113
 
5
114
  - **`toNDJSON(x, meta?)`** -- NDJSON verdict output for CI artifacts.
@@ -10,6 +119,10 @@
10
119
 
11
120
  ## [1.1.0] - 2026-07-07
12
121
 
122
+ > Never published standalone. The registry has 1.0.0, 1.0.1, 1.2.0,
123
+ > 1.2.1 -- no 1.1.0. The features below first reached the registry
124
+ > inside 1.2.0.
125
+
13
126
  - **`suiteGate(config)`** -- SPP stream-fed budgets. Consumes a Float64Array
14
127
  slab or any forEach record source (a lite-scope memory sink qualifies),
15
128
  reduces per-budget metrics (count/sum/max/mean/last over slot t/a/b,
package/PerfGate.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * MIT License
18
18
  */
19
19
 
20
- export const VERSION = '1.2.0';
20
+ export const VERSION = '1.3.0';
21
21
 
22
22
  import {PerformanceObserver, constants} from 'node:perf_hooks';
23
23
  import {setTimeout as sleep} from 'node:timers/promises';
@@ -162,22 +162,48 @@ export async function measure(scenario, options) {
162
162
  // Built-in controls
163
163
  // ---------------------------------------------------------------------------
164
164
 
165
- const __posKeep = [];
165
+ // The positive control must defeat TWO V8 adaptations at once:
166
+ // 1. escape analysis / scalar replacement -- an object that never escapes
167
+ // is never allocated, so the control must store it somewhere real;
168
+ // 2. allocation-site pretenuring -- a site whose objects keep SURVIVING is
169
+ // promoted to old space, and the scavenge signal dies (PG-01).
170
+ // A 64-slot ring satisfies both: the store escapes, and every object is
171
+ // overwritten within 64 iterations, so the site's survival ratio stays ~0.
172
+ // Measured, fresh process, N=200000 k=8, --expose-gc --max-semi-space-size=4:
173
+ // minorLo=2 minorHi=21 majorHi=0 retainedKB_hi~11 (stock sink: 2 -> 1, ~100MB).
174
+ // See decisions/0001-positive-control.md.
175
+
176
+ const CONTROL_RING_SIZE = 64;
177
+ const CONTROL_RING_MASK = CONTROL_RING_SIZE - 1;
178
+ const __posRing = new Array(CONTROL_RING_SIZE).fill(null);
166
179
 
167
- /** @internal -- reference to keep the positive-control sink alive. */
180
+ /**
181
+ * @internal -- the read that keeps the positive-control ring alive.
182
+ * Touches every slot so V8 cannot sink or eliminate the stores in
183
+ * controlPositive.hot, and returns the live occupancy. The sink is bounded
184
+ * by construction: after any number of iterations at most 64 objects (~3KB)
185
+ * are retained, so one measurement can never poison the next (PG-10).
186
+ * @returns {number} occupied ring slots, 0..64
187
+ */
168
188
  export function _controlKeepAlive() {
169
- return __posKeep.length;
189
+ let live = 0;
190
+ for (let i = 0; i < CONTROL_RING_SIZE; i++) {
191
+ const o = __posRing[i];
192
+ if (o !== null && o.w >= o.x) live++;
193
+ }
194
+ return live;
170
195
  }
171
196
 
172
- /** Positive control: allocates a heap object per iteration. */
197
+ /** Positive control: allocates one short-lived heap object per iteration. */
173
198
  export const controlPositive = {
174
- name: 'CONTROL+ (allocates {x,y,z,w} per iter)',
199
+ name: 'CONTROL+ ({x,y,z,w} per iter into a 64-slot ring)',
175
200
  setup: function () {
176
201
  return {};
177
202
  },
178
203
  hot: function (_s, n) {
179
- for (let i = 0; i < n; i++) __posKeep.push({x: i, y: i + 1, z: i + 2, w: i + 3});
180
- if (__posKeep.length > 3000000) __posKeep.length = 0;
204
+ for (let i = 0; i < n; i++) {
205
+ __posRing[i & CONTROL_RING_MASK] = {x: i, y: i + 1, z: i + 2, w: i + 3};
206
+ }
181
207
  }
182
208
  };
183
209
 
@@ -194,6 +220,46 @@ export const controlNegative = {
194
220
  }
195
221
  };
196
222
 
223
+ // ---------------------------------------------------------------------------
224
+ // Detector validation
225
+ // ---------------------------------------------------------------------------
226
+
227
+ // Detector-validation floors, DECOUPLED from the consumer's scenario
228
+ // thresholds on purpose: the evidence the instrument owes does not get
229
+ // cheaper because a consumer raised maxScavenges. decisions/0001.
230
+ const CONTROL_FLOOR = 6; // positive control: scavenges at k*N
231
+ const CONTROL_SCALE = 2; // positive control: minorHi / minorLo
232
+ const CONTROL_NEG_CEIL = 2; // negative control: hard ceiling
233
+
234
+ /**
235
+ * The ONE detector-validation predicate. zgcSuite and runGate both call it;
236
+ * neither forks it. Comparisons are written so a NaN count fails closed.
237
+ *
238
+ * @param {MeasureResult} pos positive-control measurement
239
+ * @param {MeasureResult} neg negative-control measurement
240
+ * @param {number} maxScav suite scavenge threshold (tightens neg only)
241
+ * @returns {{ ok: boolean, reason: string | null }}
242
+ */
243
+ function validateDetector(pos, neg, maxScav) {
244
+ const flags = ' Run with --expose-gc --max-semi-space-size=4.';
245
+ if (!(pos.minorHi >= CONTROL_FLOOR)) {
246
+ return {ok: false, reason: 'positive control forced ' + pos.minorHi +
247
+ ' scavenges at ' + pos.k + 'N (need >=' + CONTROL_FLOOR + ').' + flags};
248
+ }
249
+ if (pos.minorLo > 0 && !(pos.minorHi >= CONTROL_SCALE * pos.minorLo)) {
250
+ return {ok: false, reason: 'positive control did not scale: ' +
251
+ pos.minorLo + ' at N, ' + pos.minorHi + ' at ' + pos.k +
252
+ 'N (need >=' + (CONTROL_SCALE * pos.minorLo) + ').' + flags};
253
+ }
254
+ const negCeil = maxScav < CONTROL_NEG_CEIL ? maxScav : CONTROL_NEG_CEIL;
255
+ if (!(neg.minorHi <= negCeil)) {
256
+ return {ok: false, reason: 'negative control forced ' + neg.minorHi +
257
+ ' scavenges (need <=' + negCeil + ') -- noisy process, or a prior ' +
258
+ 'measurement poisoned it.' + flags};
259
+ }
260
+ return {ok: true, reason: null};
261
+ }
262
+
197
263
  // ---------------------------------------------------------------------------
198
264
  // Verdict
199
265
  // ---------------------------------------------------------------------------
@@ -302,18 +368,13 @@ export function zgcSuite(config) {
302
368
  const mustFail = config.mustFail || [];
303
369
  const maxScav = thresholds.maxScavenges;
304
370
 
305
- test('perf-gate: detector sees a known allocation (positive control)', async function () {
306
- const r = await measure(posCtrl, opts);
371
+ test('perf-gate: detector validation (positive + negative controls)', async function () {
372
+ const pos = await measure(posCtrl, opts);
307
373
  _controlKeepAlive();
308
- assert.ok(r.minorHi > maxScav + 3,
309
- 'positive control should force scavenges, saw ' + r.minorHi +
310
- ' (need >' + (maxScav + 3) + '). Run with --expose-gc --max-semi-space-size=4.');
311
- });
312
-
313
- test('perf-gate: detector reads ~0 for a non-allocating loop (negative control)', async function () {
314
- const r = await measure(negCtrl, opts);
315
- assert.ok(r.minorHi <= maxScav,
316
- 'no-op control forced ' + r.minorHi + ' scavenges (need <=' + maxScav + ')');
374
+ const neg = await measure(negCtrl, opts);
375
+ const v = validateDetector(pos, neg, maxScav);
376
+ assert.ok(v.ok, 'DETECTOR VALIDATION FAILED: ' + v.reason +
377
+ '\n ' + formatResult(pos) + '\n ' + formatResult(neg));
317
378
  });
318
379
 
319
380
  for (let i = 0; i < scenarios.length; i++) {
@@ -376,14 +437,14 @@ export async function runGate(config) {
376
437
  console.log(formatResult(neg));
377
438
  _controlKeepAlive();
378
439
 
379
- if (pos.minorHi <= maxScav + 3 || neg.minorHi > maxScav) {
380
- console.log('\n!! DETECTOR VALIDATION FAILED: positive ' + pos.minorHi +
381
- ' (need >' + (maxScav + 3) + '), negative ' + neg.minorHi +
382
- ' (need <=' + maxScav + ').');
440
+ const dv = validateDetector(pos, neg, maxScav);
441
+ if (!dv.ok) {
442
+ console.log('\n!! DETECTOR VALIDATION FAILED: ' + dv.reason);
383
443
  return {passed: false, results: []};
384
444
  }
385
445
  console.log('\ndetector validated: positive forced ' + pos.minorHi +
386
- ' scavenges, negative forced ' + neg.minorHi + '.\n');
446
+ ' scavenges at ' + k + 'N (' + pos.minorLo + ' at N, floor ' +
447
+ CONTROL_FLOOR + '), negative forced ' + neg.minorHi + '.\n');
387
448
 
388
449
  console.log('== scenarios ==');
389
450
  const results = [];
@@ -656,4 +717,4 @@ export function toNDJSON(x, meta) {
656
717
  }
657
718
  }
658
719
  return lines.join('\n') + '\n';
659
- }
720
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-perf-gate",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
5
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
6
  "type": "module",
@@ -25,7 +25,13 @@
25
25
  "CHANGELOG.md"
26
26
  ],
27
27
  "scripts": {
28
- "test": "node --expose-gc --max-semi-space-size=4 --test test/self.test.mjs"
28
+ "test": "node --expose-gc --max-semi-space-size=4 --test test/self.test.mjs",
29
+ "torture": "node --expose-gc --max-semi-space-size=4 test/torture.mjs",
30
+ "prepublishOnly": "npm test"
31
+ },
32
+ "devDependencies": {
33
+ "@zakkster/lite-gc-profiler": "^1.16.0",
34
+ "@zakkster/lite-leak": "^1.10.0"
29
35
  },
30
36
  "keywords": [
31
37
  "benchmark",