@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.
- package/CHANGELOG.md +113 -0
- package/PerfGate.js +86 -25
- 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.
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
197
|
+
/** Positive control: allocates one short-lived heap object per iteration. */
|
|
173
198
|
export const controlPositive = {
|
|
174
|
-
name: 'CONTROL+ (
|
|
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++)
|
|
180
|
-
|
|
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
|
|
306
|
-
const
|
|
371
|
+
test('perf-gate: detector validation (positive + negative controls)', async function () {
|
|
372
|
+
const pos = await measure(posCtrl, opts);
|
|
307
373
|
_controlKeepAlive();
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
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.
|
|
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",
|