@zakkster/lite-perf-gate 1.2.2 → 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 +81 -0
  2. package/PerfGate.js +85 -24
  3. package/package.json +6 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,86 @@
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
+
3
84
  ## [1.2.2] - 2026-09-13
4
85
 
5
86
  - **Version truth**: `VERSION` reads `1.2.2` and agrees with
package/PerfGate.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * MIT License
18
18
  */
19
19
 
20
- export const VERSION = '1.2.2';
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 = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-perf-gate",
3
- "version": "1.2.2",
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",
@@ -26,8 +26,13 @@
26
26
  ],
27
27
  "scripts": {
28
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",
29
30
  "prepublishOnly": "npm test"
30
31
  },
32
+ "devDependencies": {
33
+ "@zakkster/lite-gc-profiler": "^1.16.0",
34
+ "@zakkster/lite-leak": "^1.10.0"
35
+ },
31
36
  "keywords": [
32
37
  "benchmark",
33
38
  "regression",