@zakkster/lite-perf-gate 1.2.2 → 1.4.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 +337 -0
- package/PerfGate.d.ts +86 -3
- package/PerfGate.js +666 -100
- package/llms.txt +69 -10
- package/package.json +8 -1
package/PerfGate.js
CHANGED
|
@@ -2,22 +2,32 @@
|
|
|
2
2
|
* @zakkster/lite-perf-gate
|
|
3
3
|
* Zero-GC and performance regression gate for node:test.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Five measurement signals, each for what it can actually see:
|
|
6
6
|
* 1. Scavenge count (perf_hooks 'gc', minor) -- the reliable detector
|
|
7
|
-
* of TRANSIENT allocation. Retained-heap misses it
|
|
8
|
-
* snapshot); the V8 sampling profiler misses it
|
|
7
|
+
* of TRANSIENT young-generation allocation. Retained-heap misses it
|
|
8
|
+
* (freed before snapshot); the V8 sampling profiler misses it
|
|
9
|
+
* (reports live-at-stop).
|
|
9
10
|
* 2. Custom counters (user-supplied statsOf) -- exact engine internals.
|
|
10
11
|
* 3. Retained-heap delta (memoryUsage + gc) -- leak detector.
|
|
12
|
+
* 4. Old-gen activity (perf_hooks 'gc', major + incremental) -- catches
|
|
13
|
+
* churn that promotes or triggers old-space marking, which the scavenge
|
|
14
|
+
* counter alone cannot see (PG-03a). This is a GATE signal, not a
|
|
15
|
+
* profiler: when it trips, diagnose the cause with @zakkster/lite-gc-profiler.
|
|
16
|
+
* 5. External/arrayBuffers delta (memoryUsage().arrayBuffers + gc) -- catches
|
|
17
|
+
* backing-store memory that heapUsed excludes (PG-03b). Same boundary:
|
|
18
|
+
* it names that external memory grew and points at lite-gc-profiler.
|
|
11
19
|
*
|
|
12
|
-
* Pass/fail uses scaling: measure at N and k*N. Zero-alloc =>
|
|
13
|
-
* at both; allocation => scavenges scale with total bytes.
|
|
14
|
-
*
|
|
20
|
+
* Pass/fail uses scaling on scavenges: measure at N and k*N. Zero-alloc =>
|
|
21
|
+
* ~0 scavenges at both; allocation => scavenges scale with total bytes. The
|
|
22
|
+
* old-gen and external lanes are ABSOLUTE (rare events, so a scaling lane on
|
|
23
|
+
* a 0-or-1 signal is noise -- decisions/0003). Controls validate the detector
|
|
24
|
+
* on every run.
|
|
15
25
|
*
|
|
16
26
|
* Copyright (c) 2026 Zahary Shinikchiev <shinikchiev@yahoo.com>
|
|
17
27
|
* MIT License
|
|
18
28
|
*/
|
|
19
29
|
|
|
20
|
-
export const VERSION = '1.
|
|
30
|
+
export const VERSION = '1.4.0';
|
|
21
31
|
|
|
22
32
|
import {PerformanceObserver, constants} from 'node:perf_hooks';
|
|
23
33
|
import {setTimeout as sleep} from 'node:timers/promises';
|
|
@@ -26,18 +36,22 @@ import assert from 'node:assert/strict';
|
|
|
26
36
|
|
|
27
37
|
const MINOR = constants.NODE_PERFORMANCE_GC_MINOR;
|
|
28
38
|
const MAJOR = constants.NODE_PERFORMANCE_GC_MAJOR;
|
|
39
|
+
const INCR = constants.NODE_PERFORMANCE_GC_INCREMENTAL;
|
|
40
|
+
const WEAK = constants.NODE_PERFORMANCE_GC_WEAKCB;
|
|
29
41
|
|
|
30
42
|
// ---------------------------------------------------------------------------
|
|
31
43
|
// GC counter
|
|
32
44
|
// ---------------------------------------------------------------------------
|
|
33
45
|
|
|
34
46
|
function makeGcCounter() {
|
|
35
|
-
const c = {minor: 0, major: 0};
|
|
47
|
+
const c = {minor: 0, major: 0, incremental: 0, weakcb: 0};
|
|
36
48
|
const obs = new PerformanceObserver(function (list) {
|
|
37
49
|
for (const e of list.getEntries()) {
|
|
38
50
|
const k = e.detail ? e.detail.kind : e.kind;
|
|
39
51
|
if (k === MINOR) c.minor++;
|
|
40
52
|
else if (k === MAJOR) c.major++;
|
|
53
|
+
else if (k === INCR) c.incremental++;
|
|
54
|
+
else if (k === WEAK) c.weakcb++;
|
|
41
55
|
}
|
|
42
56
|
});
|
|
43
57
|
obs.observe({entryTypes: ['gc']});
|
|
@@ -77,6 +91,171 @@ if (typeof process !== 'undefined' && process.env && process.env.PERF_GATE_FLUSH
|
|
|
77
91
|
if (envMs > 0 && envMs < 60000) DEFAULT_FLUSH_MS = envMs;
|
|
78
92
|
}
|
|
79
93
|
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Fail-closed doors (v1.4.0) -- all cold path
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
const NO_GC_MSG = 'lite-perf-gate: measure: globalThis.gc is not a function -- ' +
|
|
99
|
+
'the retained signal cannot be measured and the scavenge counts are unsettled. ' +
|
|
100
|
+
'Run: node --expose-gc --max-semi-space-size=4 <entry> ' +
|
|
101
|
+
'(or node --expose-gc --max-semi-space-size=4 --test <file>). ' +
|
|
102
|
+
'Pass allowNoGc: true to gate scavenges and counters only.';
|
|
103
|
+
const NO_RETAINED = 'retained is ungateable without --expose-gc ' +
|
|
104
|
+
'(allowNoGc: true with an explicit maxRetainedKB) -- drop one of them.';
|
|
105
|
+
const NO_ARRAYBUFFERS = 'arrayBuffers is ungateable without --expose-gc ' +
|
|
106
|
+
'(allowNoGc: true with an explicit maxArrayBuffersKB) -- drop one of them.';
|
|
107
|
+
const EMPTY_HINT = ' -- pass allowEmpty: true for a controls-only detector smoke run.';
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The ONE option/threshold/scenario door. measure, verdict, zgcSuite and
|
|
111
|
+
* runGate all call it; nothing bypasses it and there is no skip flag.
|
|
112
|
+
* Cold path by construction -- no caller is a hot body.
|
|
113
|
+
*
|
|
114
|
+
* @param {'measure'|'verdict'|'zgcSuite'|'runGate'} where message prefix + mode
|
|
115
|
+
* @param {object|undefined|null} cfg measure options / verdict thresholds / gate config
|
|
116
|
+
* @param {Scenario|null} scenario the single scenario for measure(), else null
|
|
117
|
+
* @returns {{N: number, k: number, flushMs: number, allowNoGc: boolean, allowEmpty: boolean}}
|
|
118
|
+
* @throws {TypeError|RangeError} naming the field and the offending value
|
|
119
|
+
*/
|
|
120
|
+
function validateGate(where, cfg, scenario) {
|
|
121
|
+
const P = 'lite-perf-gate: ' + where + ': ';
|
|
122
|
+
if (cfg !== undefined && cfg !== null && typeof cfg !== 'object') {
|
|
123
|
+
throw new TypeError(P + 'config must be an object (got ' + typeof cfg + ')');
|
|
124
|
+
}
|
|
125
|
+
const c = cfg || {};
|
|
126
|
+
|
|
127
|
+
let N = 200000;
|
|
128
|
+
if (c.N !== undefined) {
|
|
129
|
+
if (!Number.isInteger(c.N) || c.N < 1) {
|
|
130
|
+
throw new RangeError(P + 'N must be an integer >= 1 (got ' + String(c.N) + ')');
|
|
131
|
+
}
|
|
132
|
+
N = c.N;
|
|
133
|
+
}
|
|
134
|
+
let k = 8;
|
|
135
|
+
if (c.k !== undefined) {
|
|
136
|
+
if (!Number.isInteger(c.k) || c.k < 2) {
|
|
137
|
+
throw new RangeError(P + 'k must be an integer >= 2 (got ' + String(c.k) + ')');
|
|
138
|
+
}
|
|
139
|
+
k = c.k;
|
|
140
|
+
}
|
|
141
|
+
let flushMs = DEFAULT_FLUSH_MS;
|
|
142
|
+
if (c.flushMs !== undefined) {
|
|
143
|
+
if (typeof c.flushMs !== 'number' || !isFinite(c.flushMs) || c.flushMs < 0) {
|
|
144
|
+
throw new RangeError(P + 'flushMs must be a finite number >= 0 (got ' + String(c.flushMs) + ')');
|
|
145
|
+
}
|
|
146
|
+
flushMs = c.flushMs; // 0 is legal and IS honored (PG-09)
|
|
147
|
+
}
|
|
148
|
+
if (c.allowNoGc !== undefined && c.allowNoGc !== true && c.allowNoGc !== false) {
|
|
149
|
+
throw new TypeError(P + 'allowNoGc must be true or false (got ' + String(c.allowNoGc) + ')');
|
|
150
|
+
}
|
|
151
|
+
if (c.allowEmpty !== undefined && c.allowEmpty !== true && c.allowEmpty !== false) {
|
|
152
|
+
throw new TypeError(P + 'allowEmpty must be true or false (got ' + String(c.allowEmpty) + ')');
|
|
153
|
+
}
|
|
154
|
+
const allowNoGc = c.allowNoGc === true;
|
|
155
|
+
const allowEmpty = c.allowEmpty === true;
|
|
156
|
+
|
|
157
|
+
if (where !== 'measure') {
|
|
158
|
+
if (c.maxScavenges !== undefined &&
|
|
159
|
+
(typeof c.maxScavenges !== 'number' || !isFinite(c.maxScavenges) || c.maxScavenges < 0)) {
|
|
160
|
+
throw new RangeError(P + 'maxScavenges must be a finite number >= 0 (got ' + String(c.maxScavenges) + ')');
|
|
161
|
+
}
|
|
162
|
+
if (c.maxRetainedKB !== undefined &&
|
|
163
|
+
(typeof c.maxRetainedKB !== 'number' || !isFinite(c.maxRetainedKB) || c.maxRetainedKB < 0)) {
|
|
164
|
+
throw new RangeError(P + 'maxRetainedKB must be a finite number >= 0 (got ' + String(c.maxRetainedKB) + ')');
|
|
165
|
+
}
|
|
166
|
+
if (c.maxOldGen !== undefined &&
|
|
167
|
+
(typeof c.maxOldGen !== 'number' || !isFinite(c.maxOldGen) || c.maxOldGen < 0)) {
|
|
168
|
+
throw new RangeError(P + 'maxOldGen must be a finite number >= 0 (got ' + String(c.maxOldGen) + ')');
|
|
169
|
+
}
|
|
170
|
+
if (c.maxArrayBuffersKB !== undefined &&
|
|
171
|
+
(typeof c.maxArrayBuffersKB !== 'number' || !isFinite(c.maxArrayBuffersKB) || c.maxArrayBuffersKB < 0)) {
|
|
172
|
+
throw new RangeError(P + 'maxArrayBuffersKB must be a finite number >= 0 (got ' + String(c.maxArrayBuffersKB) + ')');
|
|
173
|
+
}
|
|
174
|
+
if (c.counters !== undefined && c.counters !== null) {
|
|
175
|
+
if (typeof c.counters !== 'object') {
|
|
176
|
+
throw new TypeError(P + 'counters must be an object of numeric maxima (got ' + typeof c.counters + ')');
|
|
177
|
+
}
|
|
178
|
+
for (const key in c.counters) {
|
|
179
|
+
const t = c.counters[key];
|
|
180
|
+
if (typeof t !== 'number' || !isFinite(t)) {
|
|
181
|
+
throw new RangeError(P + 'counters.' + key + ' must be a finite number (got ' + String(t) + ')');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (allowNoGc && c.maxRetainedKB !== undefined) throw new RangeError(P + NO_RETAINED);
|
|
186
|
+
if (allowNoGc && c.maxArrayBuffersKB !== undefined) throw new RangeError(P + NO_ARRAYBUFFERS);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let list = null;
|
|
190
|
+
let labels = null;
|
|
191
|
+
if (where === 'measure') {
|
|
192
|
+
list = [scenario];
|
|
193
|
+
labels = ['scenario'];
|
|
194
|
+
} else if (where === 'zgcSuite' || where === 'runGate') {
|
|
195
|
+
const sc = c.scenarios;
|
|
196
|
+
if (!Array.isArray(sc)) {
|
|
197
|
+
throw new TypeError(P + 'scenarios must be an array (got ' +
|
|
198
|
+
(sc === undefined ? 'undefined' : typeof sc) + ')' + EMPTY_HINT);
|
|
199
|
+
}
|
|
200
|
+
if (sc.length === 0 && !allowEmpty) {
|
|
201
|
+
throw new RangeError(P + 'scenarios must be a non-empty array' + EMPTY_HINT);
|
|
202
|
+
}
|
|
203
|
+
list = [];
|
|
204
|
+
labels = [];
|
|
205
|
+
for (let i = 0; i < sc.length; i++) {
|
|
206
|
+
list.push(sc[i]);
|
|
207
|
+
labels.push('scenarios[' + i + ']');
|
|
208
|
+
}
|
|
209
|
+
if (c.mustFail !== undefined) {
|
|
210
|
+
if (!Array.isArray(c.mustFail)) {
|
|
211
|
+
throw new TypeError(P + 'mustFail must be an array (got ' + typeof c.mustFail + ')');
|
|
212
|
+
}
|
|
213
|
+
for (let i = 0; i < c.mustFail.length; i++) {
|
|
214
|
+
list.push(c.mustFail[i]);
|
|
215
|
+
labels.push('mustFail[' + i + ']');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (c.positiveControl !== undefined) {
|
|
219
|
+
list.push(c.positiveControl);
|
|
220
|
+
labels.push('positiveControl');
|
|
221
|
+
}
|
|
222
|
+
if (c.negativeControl !== undefined) {
|
|
223
|
+
list.push(c.negativeControl);
|
|
224
|
+
labels.push('negativeControl');
|
|
225
|
+
}
|
|
226
|
+
if (c.largeControl !== undefined) {
|
|
227
|
+
list.push(c.largeControl);
|
|
228
|
+
labels.push('largeControl');
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (list !== null) {
|
|
232
|
+
for (let i = 0; i < list.length; i++) {
|
|
233
|
+
const s = list[i];
|
|
234
|
+
const L = labels[i];
|
|
235
|
+
if (!s || typeof s !== 'object') {
|
|
236
|
+
throw new TypeError(P + L + ' must be a Scenario object (got ' +
|
|
237
|
+
(s === null ? 'null' : typeof s) + ')');
|
|
238
|
+
}
|
|
239
|
+
if (typeof s.name !== 'string' || s.name.length === 0) {
|
|
240
|
+
throw new TypeError(P + L + '.name must be a non-empty string (got ' + String(s.name) + ')');
|
|
241
|
+
}
|
|
242
|
+
if (typeof s.setup !== 'function') {
|
|
243
|
+
throw new TypeError(P + L + ' "' + s.name + '": setup must be a function (got ' + typeof s.setup + ')');
|
|
244
|
+
}
|
|
245
|
+
if (typeof s.hot !== 'function') {
|
|
246
|
+
throw new TypeError(P + L + ' "' + s.name + '": hot must be a function (got ' + typeof s.hot + ')');
|
|
247
|
+
}
|
|
248
|
+
if (s.statsOf !== undefined && typeof s.statsOf !== 'function') {
|
|
249
|
+
throw new TypeError(P + L + ' "' + s.name + '": statsOf must be a function when present (got ' + typeof s.statsOf + ')');
|
|
250
|
+
}
|
|
251
|
+
if (s.teardown !== undefined && typeof s.teardown !== 'function') {
|
|
252
|
+
throw new TypeError(P + L + ' "' + s.name + '": teardown must be a function when present (got ' + typeof s.teardown + ')');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return {N: N, k: k, flushMs: flushMs, allowNoGc: allowNoGc, allowEmpty: allowEmpty};
|
|
257
|
+
}
|
|
258
|
+
|
|
80
259
|
// ---------------------------------------------------------------------------
|
|
81
260
|
// Core measurement
|
|
82
261
|
// ---------------------------------------------------------------------------
|
|
@@ -87,7 +266,9 @@ async function meterOnce(scenario, iters, flushMs) {
|
|
|
87
266
|
await gc2();
|
|
88
267
|
|
|
89
268
|
const statsBefore = scenario.statsOf ? scenario.statsOf(state) : null;
|
|
90
|
-
const
|
|
269
|
+
const mu0 = process.memoryUsage();
|
|
270
|
+
const heapBefore = mu0.heapUsed;
|
|
271
|
+
const abBefore = mu0.arrayBuffers;
|
|
91
272
|
|
|
92
273
|
const gcc = makeGcCounter();
|
|
93
274
|
scenario.hot(state, iters);
|
|
@@ -96,8 +277,16 @@ async function meterOnce(scenario, iters, flushMs) {
|
|
|
96
277
|
const major = gcc.c.major;
|
|
97
278
|
gcc.close();
|
|
98
279
|
|
|
280
|
+
// Old-gen activity = major + incremental. incremental is read AFTER
|
|
281
|
+
// gcc.close() on purpose: the measurement window (makeGcCounter -> close)
|
|
282
|
+
// stays byte-identical to v1.4.0, and the observer callback has already
|
|
283
|
+
// tallied every 'gc' entry delivered during the flush sleep. decisions/0003.
|
|
284
|
+
const oldGen = major + gcc.c.incremental;
|
|
285
|
+
|
|
99
286
|
await gc2();
|
|
100
|
-
const
|
|
287
|
+
const mu1 = process.memoryUsage();
|
|
288
|
+
const heapAfter = mu1.heapUsed;
|
|
289
|
+
const abAfter = mu1.arrayBuffers;
|
|
101
290
|
const statsAfter = scenario.statsOf ? scenario.statsOf(state) : null;
|
|
102
291
|
if (scenario.teardown) scenario.teardown(state);
|
|
103
292
|
|
|
@@ -111,7 +300,12 @@ async function meterOnce(scenario, iters, flushMs) {
|
|
|
111
300
|
}
|
|
112
301
|
}
|
|
113
302
|
|
|
114
|
-
return {
|
|
303
|
+
return {
|
|
304
|
+
minor: minor, major: major, oldGen: oldGen,
|
|
305
|
+
retainedKB: (heapAfter - heapBefore) / 1024,
|
|
306
|
+
arrayBuffersKB: (abAfter - abBefore) / 1024,
|
|
307
|
+
counters: counters
|
|
308
|
+
};
|
|
115
309
|
}
|
|
116
310
|
|
|
117
311
|
// ---------------------------------------------------------------------------
|
|
@@ -136,25 +330,31 @@ async function meterOnce(scenario, iters, flushMs) {
|
|
|
136
330
|
* allocation via scavenge scaling.
|
|
137
331
|
*
|
|
138
332
|
* @param {Scenario} scenario
|
|
139
|
-
* @param {{ N?: number, k?: number, flushMs?: number }} [options]
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
333
|
+
* @param {{ N?: number, k?: number, flushMs?: number, allowNoGc?: boolean }} [options]
|
|
334
|
+
* N integer >= 1 (default 200000), k integer >= 2 (default 8), flushMs a
|
|
335
|
+
* finite number >= 0 (0 is legal and honored). flushMs is how long to wait
|
|
336
|
+
* after the hot loop before reading the GC observer buffer. Default 100ms
|
|
337
|
+
* (or PERF_GATE_FLUSH_MS env var). Bump if you see zero scavenges on
|
|
338
|
+
* scenarios that should allocate -- noisy CI runners may need 250-500ms.
|
|
339
|
+
* allowNoGc: run without --expose-gc; the retained signal is dropped and
|
|
340
|
+
* the result carries retainedReliable: false. Bad values throw.
|
|
144
341
|
* @returns {Promise<MeasureResult>}
|
|
145
342
|
*/
|
|
146
343
|
export async function measure(scenario, options) {
|
|
147
|
-
const
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
const lo = await meterOnce(scenario, N, flushMs);
|
|
151
|
-
const hi = await meterOnce(scenario, k * N, flushMs);
|
|
344
|
+
const o = validateGate('measure', options, scenario);
|
|
345
|
+
const gcOk = typeof globalThis.gc === 'function';
|
|
346
|
+
if (!gcOk && !o.allowNoGc) throw new Error(NO_GC_MSG);
|
|
347
|
+
const lo = await meterOnce(scenario, o.N, o.flushMs);
|
|
348
|
+
const hi = await meterOnce(scenario, o.k * o.N, o.flushMs);
|
|
152
349
|
return {
|
|
153
|
-
name: scenario.name, N: N, k: k,
|
|
350
|
+
name: scenario.name, N: o.N, k: o.k,
|
|
154
351
|
minorLo: lo.minor, minorHi: hi.minor,
|
|
155
352
|
majorLo: lo.major, majorHi: hi.major,
|
|
353
|
+
oldGenLo: lo.oldGen, oldGenHi: hi.oldGen,
|
|
156
354
|
retainedKB_lo: lo.retainedKB, retainedKB_hi: hi.retainedKB,
|
|
157
|
-
|
|
355
|
+
arrayBuffersKB_lo: lo.arrayBuffersKB, arrayBuffersKB_hi: hi.arrayBuffersKB,
|
|
356
|
+
counters_lo: lo.counters, counters_hi: hi.counters,
|
|
357
|
+
retainedReliable: gcOk
|
|
158
358
|
};
|
|
159
359
|
}
|
|
160
360
|
|
|
@@ -162,22 +362,48 @@ export async function measure(scenario, options) {
|
|
|
162
362
|
// Built-in controls
|
|
163
363
|
// ---------------------------------------------------------------------------
|
|
164
364
|
|
|
165
|
-
|
|
365
|
+
// The positive control must defeat TWO V8 adaptations at once:
|
|
366
|
+
// 1. escape analysis / scalar replacement -- an object that never escapes
|
|
367
|
+
// is never allocated, so the control must store it somewhere real;
|
|
368
|
+
// 2. allocation-site pretenuring -- a site whose objects keep SURVIVING is
|
|
369
|
+
// promoted to old space, and the scavenge signal dies (PG-01).
|
|
370
|
+
// A 64-slot ring satisfies both: the store escapes, and every object is
|
|
371
|
+
// overwritten within 64 iterations, so the site's survival ratio stays ~0.
|
|
372
|
+
// Measured, fresh process, N=200000 k=8, --expose-gc --max-semi-space-size=4:
|
|
373
|
+
// minorLo=2 minorHi=21 majorHi=0 retainedKB_hi~11 (stock sink: 2 -> 1, ~100MB).
|
|
374
|
+
// See decisions/0001-positive-control.md.
|
|
375
|
+
|
|
376
|
+
const CONTROL_RING_SIZE = 64;
|
|
377
|
+
const CONTROL_RING_MASK = CONTROL_RING_SIZE - 1;
|
|
378
|
+
const __posRing = new Array(CONTROL_RING_SIZE).fill(null);
|
|
166
379
|
|
|
167
|
-
/**
|
|
380
|
+
/**
|
|
381
|
+
* @internal -- the read that keeps the positive-control ring alive.
|
|
382
|
+
* Touches every slot so V8 cannot sink or eliminate the stores in
|
|
383
|
+
* controlPositive.hot, and returns the live occupancy. The sink is bounded
|
|
384
|
+
* by construction: after any number of iterations at most 64 objects (~3KB)
|
|
385
|
+
* are retained, so one measurement can never poison the next (PG-10).
|
|
386
|
+
* @returns {number} occupied ring slots, 0..64
|
|
387
|
+
*/
|
|
168
388
|
export function _controlKeepAlive() {
|
|
169
|
-
|
|
389
|
+
let live = 0;
|
|
390
|
+
for (let i = 0; i < CONTROL_RING_SIZE; i++) {
|
|
391
|
+
const o = __posRing[i];
|
|
392
|
+
if (o !== null && o.w >= o.x) live++;
|
|
393
|
+
}
|
|
394
|
+
return live;
|
|
170
395
|
}
|
|
171
396
|
|
|
172
|
-
/** Positive control: allocates
|
|
397
|
+
/** Positive control: allocates one short-lived heap object per iteration. */
|
|
173
398
|
export const controlPositive = {
|
|
174
|
-
name: 'CONTROL+ (
|
|
399
|
+
name: 'CONTROL+ ({x,y,z,w} per iter into a 64-slot ring)',
|
|
175
400
|
setup: function () {
|
|
176
401
|
return {};
|
|
177
402
|
},
|
|
178
403
|
hot: function (_s, n) {
|
|
179
|
-
for (let i = 0; i < n; i++)
|
|
180
|
-
|
|
404
|
+
for (let i = 0; i < n; i++) {
|
|
405
|
+
__posRing[i & CONTROL_RING_MASK] = {x: i, y: i + 1, z: i + 2, w: i + 3};
|
|
406
|
+
}
|
|
181
407
|
}
|
|
182
408
|
};
|
|
183
409
|
|
|
@@ -194,6 +420,153 @@ export const controlNegative = {
|
|
|
194
420
|
}
|
|
195
421
|
};
|
|
196
422
|
|
|
423
|
+
// controlLarge -- the detector control for the EXTERNAL/arrayBuffers signal
|
|
424
|
+
// (signal 5). The census (decisions/0003) proved the two things that fix this
|
|
425
|
+
// shape: (1) 256KB-string LO churn -- the planned controlLarge candidate --
|
|
426
|
+
// trips NEITHER new signal on Node v26.3.1 (oldGen 0, arrayBuffers 0), so it
|
|
427
|
+
// is not a control; (2) the oldGen positive control at suite defaults
|
|
428
|
+
// (Float64Array churn) costs ~2730ms and jitters 2744..2944, i.e. a per-run
|
|
429
|
+
// oldGen floor is noise amplification (Axis C's own rejection). A mask-gated
|
|
430
|
+
// ACCUMULATING 64KB-ArrayBuffer pool, by contrast, is measured DETERMINISTIC
|
|
431
|
+
// at 832KB arrayBuffers (6/6 reps), oldGen 0, ~200ms at the pinned
|
|
432
|
+
// {N:200000,k:8} window below -- 13x the 64KB gate, so the same measurement
|
|
433
|
+
// both validates the detector and demonstrates the gate catches it. That is
|
|
434
|
+
// the clean, cheap, always-on detector for the external lane; the oldGen
|
|
435
|
+
// lane's detector is the negative-control old-gen clause (no false positive)
|
|
436
|
+
// plus torture T3's must-catch C2 fixture (true positive). Axis D landed on an
|
|
437
|
+
// adapted D1: always-on placement, arrayBuffers clause.
|
|
438
|
+
//
|
|
439
|
+
// RESIDUE (decisions/0003 "controlLarge residue"): a retained ArrayBuffer pool
|
|
440
|
+
// raises V8's scheduled-scavenge accounting, which then fires PERIODIC
|
|
441
|
+
// scavenges INSIDE the next sequential measurement window -- mechanism proven
|
|
442
|
+
// H-a: the spurious entries' startTime lands in-window, not late delivery.
|
|
443
|
+
// This is not uncollected garbage, no gc clears it (forcing collections made
|
|
444
|
+
// it WORSE), and NO pool size is immune (256KB still poisoned 2/50). The
|
|
445
|
+
// residue is forward-only, so the fix is ORDERING: controlLarge is measured
|
|
446
|
+
// AFTER every scavenge-gated measurement. See measureLargeControl().
|
|
447
|
+
const CONTROL_LARGE_MASK = 131071; // one 64KB ArrayBuffer every 131072 iters
|
|
448
|
+
const CONTROL_LARGE_BYTES = 65536;
|
|
449
|
+
const CONTROL_LARGE_N = 200000; // pinned so the control's evidence does
|
|
450
|
+
const CONTROL_LARGE_K = 8; // not get cheaper with a consumer's N.
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Large-object / external control: retains a bounded, mask-gated pool of 64KB
|
|
454
|
+
* ArrayBuffers so the arrayBuffers delta at k*N is large and repeatable
|
|
455
|
+
* (~832KB, 13x the default gate) at a fixed footprint regardless of n. Trips
|
|
456
|
+
* signal 5. Cleanup is owned two ways: teardown() drops the pool, and the
|
|
457
|
+
* detector call sites isolate its scheduled-scavenge residue by ORDERING --
|
|
458
|
+
* controlLarge is measured LAST, so nothing scavenge-gated follows it (the
|
|
459
|
+
* residue is never drained; draining makes it worse). decisions/0003.
|
|
460
|
+
*/
|
|
461
|
+
export const controlLarge = {
|
|
462
|
+
name: 'CONTROL_LARGE (64KB ArrayBuffer every 131072 iters, accumulating pool)',
|
|
463
|
+
setup: function () {
|
|
464
|
+
return {pool: []};
|
|
465
|
+
},
|
|
466
|
+
hot: function (s, n) {
|
|
467
|
+
const p = s.pool;
|
|
468
|
+
for (let i = 0; i < n; i++) {
|
|
469
|
+
if ((i & CONTROL_LARGE_MASK) === 0) p.push(new ArrayBuffer(CONTROL_LARGE_BYTES));
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
// Drop every backing store so the pool is collectable the instant the pass
|
|
473
|
+
// ends. Necessary but not sufficient alone (meterOnce runs teardown after
|
|
474
|
+
// its final gc2); the next measurement is isolated by ORDERING -- measuring
|
|
475
|
+
// controlLarge LAST, not by draining the residue (draining makes it worse).
|
|
476
|
+
// decisions/0003 "controlLarge residue".
|
|
477
|
+
teardown: function (s) {
|
|
478
|
+
const p = s.pool;
|
|
479
|
+
for (let i = 0; i < p.length; i++) p[i] = null;
|
|
480
|
+
s.pool = null;
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Measure controlLarge at its pinned window. The ONE place both detector call
|
|
486
|
+
* sites (zgcSuite, runGate) go through -- no diverging measurement logic.
|
|
487
|
+
*
|
|
488
|
+
* controlLarge leaves a scheduled-scavenge residue (H-a, decisions/0003
|
|
489
|
+
* "controlLarge residue") that poisons the NEXT scavenge-gated measurement.
|
|
490
|
+
* Forcing collections does NOT clear it -- measured, it made poisoning WORSE
|
|
491
|
+
* (drain 8/20/40 -> 3/8/9 caught of 10 cold processes). The residue is
|
|
492
|
+
* forward-only, so the fix is ORDERING, not draining: this control is measured
|
|
493
|
+
* AFTER every scavenge-gated measurement. runGate measures it last (below);
|
|
494
|
+
* zgcSuite measures it in the detector test, whose node:test-block boundary
|
|
495
|
+
* dissipates the residue before the first scenario test (verified 15/15).
|
|
496
|
+
*
|
|
497
|
+
* @param {Scenario} largeCtrl
|
|
498
|
+
* @param {number} flushMs
|
|
499
|
+
* @param {boolean} allowNoGc
|
|
500
|
+
* @returns {Promise<MeasureResult>}
|
|
501
|
+
*/
|
|
502
|
+
async function measureLargeControl(largeCtrl, flushMs, allowNoGc) {
|
|
503
|
+
return measure(largeCtrl,
|
|
504
|
+
{N: CONTROL_LARGE_N, k: CONTROL_LARGE_K, flushMs: flushMs, allowNoGc: allowNoGc});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ---------------------------------------------------------------------------
|
|
508
|
+
// Detector validation
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
|
|
511
|
+
// Detector-validation floors, DECOUPLED from the consumer's scenario
|
|
512
|
+
// thresholds on purpose: the evidence the instrument owes does not get
|
|
513
|
+
// cheaper because a consumer raised maxScavenges. decisions/0001.
|
|
514
|
+
const CONTROL_FLOOR = 6; // positive control: scavenges at k*N
|
|
515
|
+
const CONTROL_SCALE = 2; // positive control: minorHi / minorLo
|
|
516
|
+
const CONTROL_NEG_CEIL = 2; // negative control: hard ceiling
|
|
517
|
+
// Large-control floor: floor(observed 832KB / 3) -- a 3x margin under the
|
|
518
|
+
// deterministic reading, and 4x over the default maxArrayBuffersKB, so the
|
|
519
|
+
// same measurement both validates the detector and demonstrates the gate would
|
|
520
|
+
// catch it. decisions/0003.
|
|
521
|
+
const CONTROL_LARGE_FLOOR = 277; // arrayBuffers KB at k*N
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* The ONE detector-validation predicate. zgcSuite and runGate both call it;
|
|
525
|
+
* neither forks it. Comparisons are written so a NaN/undefined count fails
|
|
526
|
+
* closed (`!(x >= f)` / `!(x <= c)`).
|
|
527
|
+
*
|
|
528
|
+
* @param {MeasureResult} pos positive-control measurement
|
|
529
|
+
* @param {MeasureResult} neg negative-control measurement
|
|
530
|
+
* @param {MeasureResult} large controlLarge measurement (external lane)
|
|
531
|
+
* @param {number} maxScav suite scavenge threshold (tightens neg only)
|
|
532
|
+
* @returns {{ ok: boolean, reason: string | null }}
|
|
533
|
+
*/
|
|
534
|
+
function validateDetector(pos, neg, large, maxScav) {
|
|
535
|
+
const flags = ' Run with --expose-gc --max-semi-space-size=4.';
|
|
536
|
+
if (!(pos.minorHi >= CONTROL_FLOOR)) {
|
|
537
|
+
return {ok: false, reason: 'positive control forced ' + pos.minorHi +
|
|
538
|
+
' scavenges at ' + pos.k + 'N (need >=' + CONTROL_FLOOR + ').' + flags};
|
|
539
|
+
}
|
|
540
|
+
if (pos.minorLo > 0 && !(pos.minorHi >= CONTROL_SCALE * pos.minorLo)) {
|
|
541
|
+
return {ok: false, reason: 'positive control did not scale: ' +
|
|
542
|
+
pos.minorLo + ' at N, ' + pos.minorHi + ' at ' + pos.k +
|
|
543
|
+
'N (need >=' + (CONTROL_SCALE * pos.minorLo) + ').' + flags};
|
|
544
|
+
}
|
|
545
|
+
const negCeil = maxScav < CONTROL_NEG_CEIL ? maxScav : CONTROL_NEG_CEIL;
|
|
546
|
+
if (!(neg.minorHi <= negCeil)) {
|
|
547
|
+
return {ok: false, reason: 'negative control forced ' + neg.minorHi +
|
|
548
|
+
' scavenges (need <=' + negCeil + ') -- noisy process, or a prior ' +
|
|
549
|
+
'measurement poisoned it.' + flags};
|
|
550
|
+
}
|
|
551
|
+
// Old-gen floor on the NEGATIVE control: the census (decisions/0003) shows
|
|
552
|
+
// C5 pure arithmetic fires 0 old-gen collections across 100 reps, so a
|
|
553
|
+
// nonzero here means a noisy instrument, not a clean hot path -- refuse to
|
|
554
|
+
// judge the oldgen lane on it. `!(<=)` fails closed on NaN/undefined.
|
|
555
|
+
if (!(neg.oldGenHi <= 0)) {
|
|
556
|
+
return {ok: false, reason: 'negative control forced ' + neg.oldGenHi +
|
|
557
|
+
' old-gen collections (need 0) -- noisy process, the oldgen lane ' +
|
|
558
|
+
'cannot be trusted.' + flags};
|
|
559
|
+
}
|
|
560
|
+
if (!(large.arrayBuffersKB_hi >= CONTROL_LARGE_FLOOR)) {
|
|
561
|
+
const got = typeof large.arrayBuffersKB_hi === 'number'
|
|
562
|
+
? large.arrayBuffersKB_hi.toFixed(0) : String(large.arrayBuffersKB_hi);
|
|
563
|
+
return {ok: false, reason: 'large control forced ' + got +
|
|
564
|
+
'KB arrayBuffers at ' + large.k + 'N (need >=' + CONTROL_LARGE_FLOOR +
|
|
565
|
+
'KB) -- the external signal is blind.' + flags};
|
|
566
|
+
}
|
|
567
|
+
return {ok: true, reason: null};
|
|
568
|
+
}
|
|
569
|
+
|
|
197
570
|
// ---------------------------------------------------------------------------
|
|
198
571
|
// Verdict
|
|
199
572
|
// ---------------------------------------------------------------------------
|
|
@@ -201,31 +574,89 @@ export const controlNegative = {
|
|
|
201
574
|
/**
|
|
202
575
|
* Evaluate a measurement against thresholds.
|
|
203
576
|
*
|
|
577
|
+
* Thresholds are validated at config time (bad values throw); measured
|
|
578
|
+
* signals fail closed with named reasons, in this contract format:
|
|
579
|
+
* '<signal>: not a number (fail closed)' -- scavenges / retained / counter
|
|
580
|
+
* '<key>: no such counter measured (statsOf keys: ...)' -- typo'd counter key
|
|
581
|
+
* 'counters: thresholds set (...) but the scenario measured no counters
|
|
582
|
+
* -- add statsOf (fail closed)' -- counters_hi === null
|
|
583
|
+
*
|
|
204
584
|
* @param {MeasureResult} r
|
|
205
585
|
* @param {object} [thresholds]
|
|
206
|
-
* @param {number} [thresholds.maxScavenges=2]
|
|
207
|
-
* @param {number} [thresholds.maxRetainedKB=64]
|
|
586
|
+
* @param {number} [thresholds.maxScavenges=2] finite >= 0, else throws
|
|
587
|
+
* @param {number} [thresholds.maxRetainedKB=64] finite >= 0, else throws
|
|
208
588
|
* @param {Record<string, number>} [thresholds.counters]
|
|
209
|
-
* Per-counter maximum allowed delta. E.g. `{ poolGrowths: 0 }`.
|
|
589
|
+
* Per-counter maximum allowed delta. E.g. `{ poolGrowths: 0 }`. Each
|
|
590
|
+
* maximum must be a finite number, else throws.
|
|
210
591
|
* @returns {{ pass: boolean, reasons: string[] }}
|
|
211
592
|
*/
|
|
212
593
|
export function verdict(r, thresholds) {
|
|
594
|
+
validateGate('verdict', thresholds, null);
|
|
595
|
+
if (thresholds && thresholds.maxRetainedKB !== undefined && r && r.retainedReliable === false) {
|
|
596
|
+
throw new RangeError('lite-perf-gate: verdict: ' + NO_RETAINED);
|
|
597
|
+
}
|
|
598
|
+
if (thresholds && thresholds.maxArrayBuffersKB !== undefined && r && r.retainedReliable === false) {
|
|
599
|
+
throw new RangeError('lite-perf-gate: verdict: ' + NO_ARRAYBUFFERS);
|
|
600
|
+
}
|
|
213
601
|
const maxScav = (thresholds && thresholds.maxScavenges !== undefined) ? thresholds.maxScavenges : 2;
|
|
214
602
|
const maxRetKB = (thresholds && thresholds.maxRetainedKB !== undefined) ? thresholds.maxRetainedKB : 64;
|
|
603
|
+
const maxOldGen = (thresholds && thresholds.maxOldGen !== undefined) ? thresholds.maxOldGen : 0;
|
|
604
|
+
const maxAbKB = (thresholds && thresholds.maxArrayBuffersKB !== undefined) ? thresholds.maxArrayBuffersKB : 64;
|
|
215
605
|
const ct = (thresholds && thresholds.counters) || null;
|
|
216
606
|
const reasons = [];
|
|
217
607
|
|
|
218
|
-
if (r.minorHi
|
|
608
|
+
if (typeof r.minorHi !== 'number' || r.minorHi !== r.minorHi) {
|
|
609
|
+
reasons.push('scavenges: not a number (fail closed)');
|
|
610
|
+
} else if (r.minorHi > maxScav) {
|
|
219
611
|
reasons.push('scavenges: ' + r.minorHi + ' > ' + maxScav + ' (transient allocation)');
|
|
220
612
|
}
|
|
221
|
-
if (r.
|
|
222
|
-
|
|
613
|
+
if (r.retainedReliable !== false) {
|
|
614
|
+
if (typeof r.retainedKB_hi !== 'number' || r.retainedKB_hi !== r.retainedKB_hi) {
|
|
615
|
+
reasons.push('retained: not a number (fail closed)');
|
|
616
|
+
} else if (r.retainedKB_hi > maxRetKB) {
|
|
617
|
+
reasons.push('retained: ' + r.retainedKB_hi.toFixed(0) + 'KB > ' + maxRetKB + 'KB');
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
// Old-gen lane (major + incremental). GATED on presence for back-compat:
|
|
621
|
+
// hand-built results (suiteGate's per-budget call) omit oldGenHi and must
|
|
622
|
+
// not gain a reason (decision 0002 policy 2). Reliable without --expose-gc:
|
|
623
|
+
// it comes from the observer, not memoryUsage, so it is NOT skipped when
|
|
624
|
+
// retainedReliable is false.
|
|
625
|
+
if (r.oldGenHi !== undefined) {
|
|
626
|
+
if (typeof r.oldGenHi !== 'number' || r.oldGenHi !== r.oldGenHi) {
|
|
627
|
+
reasons.push('oldgen: not a number (fail closed)');
|
|
628
|
+
} else if (r.oldGenHi > maxOldGen) {
|
|
629
|
+
reasons.push('oldgen: ' + r.oldGenHi + ' > ' + maxOldGen +
|
|
630
|
+
' (old-gen GC activity -- diagnose with @zakkster/lite-gc-profiler)');
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
// External/arrayBuffers lane. GATED on presence, and -- like retained --
|
|
634
|
+
// skipped when retainedReliable is false, because the delta is bracketed
|
|
635
|
+
// by the same gc2() calls that no-op without --expose-gc.
|
|
636
|
+
if (r.arrayBuffersKB_hi !== undefined && r.retainedReliable !== false) {
|
|
637
|
+
if (typeof r.arrayBuffersKB_hi !== 'number' || r.arrayBuffersKB_hi !== r.arrayBuffersKB_hi) {
|
|
638
|
+
reasons.push('arrayBuffers: not a number (fail closed)');
|
|
639
|
+
} else if (r.arrayBuffersKB_hi > maxAbKB) {
|
|
640
|
+
reasons.push('arrayBuffers: ' + r.arrayBuffersKB_hi.toFixed(0) + 'KB > ' + maxAbKB +
|
|
641
|
+
'KB (external memory -- diagnose with @zakkster/lite-gc-profiler)');
|
|
642
|
+
}
|
|
223
643
|
}
|
|
224
|
-
if (ct !== null
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
644
|
+
if (ct !== null) {
|
|
645
|
+
if (r.counters_hi === null || r.counters_hi === undefined) {
|
|
646
|
+
reasons.push('counters: thresholds set (' + Object.keys(ct).join(', ') +
|
|
647
|
+
') but the scenario measured no counters -- add statsOf (fail closed)');
|
|
648
|
+
} else {
|
|
649
|
+
const ks = Object.keys(r.counters_hi);
|
|
650
|
+
for (const key in ct) {
|
|
651
|
+
const actual = r.counters_hi[key];
|
|
652
|
+
if (actual === undefined) {
|
|
653
|
+
reasons.push(key + ': no such counter measured (statsOf keys: ' +
|
|
654
|
+
(ks.length ? ks.join(', ') : 'none') + ')');
|
|
655
|
+
} else if (typeof actual !== 'number' || !isFinite(actual)) {
|
|
656
|
+
reasons.push(key + ': not a number (fail closed)');
|
|
657
|
+
} else if (actual > ct[key]) {
|
|
658
|
+
reasons.push(key + ': ' + actual + ' > ' + ct[key]);
|
|
659
|
+
}
|
|
229
660
|
}
|
|
230
661
|
}
|
|
231
662
|
}
|
|
@@ -246,6 +677,12 @@ export function formatResult(r) {
|
|
|
246
677
|
'\n scavenges N:' + String(r.minorLo).padStart(3) +
|
|
247
678
|
' ' + r.k + 'N:' + String(r.minorHi).padStart(3) +
|
|
248
679
|
' retained ' + r.retainedKB_hi.toFixed(0) + 'KB';
|
|
680
|
+
if (typeof r.oldGenHi === 'number' && r.oldGenHi > 0) {
|
|
681
|
+
s += ' oldgen ' + r.oldGenHi;
|
|
682
|
+
}
|
|
683
|
+
if (typeof r.arrayBuffersKB_hi === 'number' && r.arrayBuffersKB_hi > 0.5) {
|
|
684
|
+
s += ' arrayBuffers ' + r.arrayBuffersKB_hi.toFixed(0) + 'KB';
|
|
685
|
+
}
|
|
249
686
|
if (r.counters_hi !== null) {
|
|
250
687
|
for (const key in r.counters_hi) {
|
|
251
688
|
s += ' ' + key + ' \u0394' + String(r.counters_hi[key]).padStart(6);
|
|
@@ -288,32 +725,49 @@ export function formatResult(r) {
|
|
|
288
725
|
* @param {Scenario} [config.negativeControl] Override negative control.
|
|
289
726
|
* @param {Scenario[]} [config.mustFail]
|
|
290
727
|
* Scenarios that MUST trip the gate (injected allocation self-tests).
|
|
728
|
+
* @param {number} [config.flushMs]
|
|
729
|
+
* Wait (ms) after the hot loop before reading the GC observer buffer.
|
|
730
|
+
* Default 100 (or PERF_GATE_FLUSH_MS env var). 0 is legal and honored.
|
|
731
|
+
* @param {boolean} [config.allowEmpty=false]
|
|
732
|
+
* Permit an empty scenarios array for a controls-only detector smoke run
|
|
733
|
+
* -- the ONLY reason this option exists.
|
|
734
|
+
* @param {boolean} [config.allowNoGc=false]
|
|
735
|
+
* Run without --expose-gc; the retained rule is not applied and an
|
|
736
|
+
* explicit maxRetainedKB throws.
|
|
291
737
|
*/
|
|
292
738
|
export function zgcSuite(config) {
|
|
293
|
-
const
|
|
294
|
-
const
|
|
739
|
+
const o = validateGate('zgcSuite', config, null);
|
|
740
|
+
const cf = config || {};
|
|
741
|
+
const scenarios = cf.scenarios;
|
|
742
|
+
const opts = {N: o.N, k: o.k, flushMs: o.flushMs, allowNoGc: o.allowNoGc};
|
|
295
743
|
const thresholds = {
|
|
296
|
-
maxScavenges:
|
|
297
|
-
maxRetainedKB:
|
|
298
|
-
|
|
744
|
+
maxScavenges: cf.maxScavenges !== undefined ? cf.maxScavenges : 2,
|
|
745
|
+
maxRetainedKB: o.allowNoGc ? undefined : (cf.maxRetainedKB !== undefined ? cf.maxRetainedKB : 64),
|
|
746
|
+
maxOldGen: cf.maxOldGen !== undefined ? cf.maxOldGen : 0,
|
|
747
|
+
maxArrayBuffersKB: o.allowNoGc ? undefined : (cf.maxArrayBuffersKB !== undefined ? cf.maxArrayBuffersKB : 64),
|
|
748
|
+
counters: cf.counters || null
|
|
299
749
|
};
|
|
300
|
-
const posCtrl =
|
|
301
|
-
const negCtrl =
|
|
302
|
-
const
|
|
750
|
+
const posCtrl = cf.positiveControl || controlPositive;
|
|
751
|
+
const negCtrl = cf.negativeControl || controlNegative;
|
|
752
|
+
const largeCtrl = cf.largeControl || controlLarge;
|
|
753
|
+
const mustFail = cf.mustFail || [];
|
|
303
754
|
const maxScav = thresholds.maxScavenges;
|
|
304
755
|
|
|
305
|
-
test(
|
|
306
|
-
|
|
756
|
+
test(scenarios.length === 0
|
|
757
|
+
? 'perf-gate: detector validation only (allowEmpty, 0 scenarios)'
|
|
758
|
+
: 'perf-gate: detector validation (positive + negative + large controls)', async function () {
|
|
759
|
+
const pos = await measure(posCtrl, opts);
|
|
307
760
|
_controlKeepAlive();
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
761
|
+
const neg = await measure(negCtrl, opts);
|
|
762
|
+
// controlLarge is measured LAST in this detector test; its
|
|
763
|
+
// scheduled-scavenge residue (never drained -- draining makes it worse)
|
|
764
|
+
// is isolated by that ordering plus the node:test block boundary, so
|
|
765
|
+
// the scenario test blocks that follow are not poisoned (H-a,
|
|
766
|
+
// decisions/0003 "controlLarge residue").
|
|
767
|
+
const large = await measureLargeControl(largeCtrl, o.flushMs, o.allowNoGc);
|
|
768
|
+
const v = validateDetector(pos, neg, large, maxScav);
|
|
769
|
+
assert.ok(v.ok, 'DETECTOR VALIDATION FAILED: ' + v.reason +
|
|
770
|
+
'\n ' + formatResult(pos) + '\n ' + formatResult(neg) + '\n ' + formatResult(large));
|
|
317
771
|
});
|
|
318
772
|
|
|
319
773
|
for (let i = 0; i < scenarios.length; i++) {
|
|
@@ -346,61 +800,87 @@ export function zgcSuite(config) {
|
|
|
346
800
|
// ---------------------------------------------------------------------------
|
|
347
801
|
|
|
348
802
|
/**
|
|
349
|
-
* Run a gate check and print a human-readable report.
|
|
350
|
-
*
|
|
803
|
+
* Run a gate check and print a human-readable report.
|
|
804
|
+
*
|
|
805
|
+
* Returns `{ passed, code, results }`. `code`: 0 pass, 1 scenario or
|
|
806
|
+
* must-fail failure, 2 detector validation failed; `passed === (code === 0)`.
|
|
807
|
+
* runGate never touches `process.exitCode` -- map `result.code` to your exit
|
|
808
|
+
* code: `process.exit((await runGate(cfg)).code)`.
|
|
351
809
|
*
|
|
352
810
|
* @param {object} config Same shape as zgcSuite.
|
|
353
|
-
* @returns {Promise<{ passed: boolean, results: MeasureResult[] }>}
|
|
811
|
+
* @returns {Promise<{ passed: boolean, code: 0 | 1 | 2, results: MeasureResult[] }>}
|
|
354
812
|
*/
|
|
355
813
|
export async function runGate(config) {
|
|
356
|
-
const
|
|
357
|
-
const
|
|
814
|
+
const o = validateGate('runGate', config, null);
|
|
815
|
+
const cf = config || {};
|
|
816
|
+
const scenarios = cf.scenarios;
|
|
817
|
+
const opts = {N: o.N, k: o.k, flushMs: o.flushMs, allowNoGc: o.allowNoGc};
|
|
358
818
|
const thresholds = {
|
|
359
|
-
maxScavenges:
|
|
360
|
-
maxRetainedKB:
|
|
361
|
-
|
|
819
|
+
maxScavenges: cf.maxScavenges !== undefined ? cf.maxScavenges : 2,
|
|
820
|
+
maxRetainedKB: o.allowNoGc ? undefined : (cf.maxRetainedKB !== undefined ? cf.maxRetainedKB : 64),
|
|
821
|
+
maxOldGen: cf.maxOldGen !== undefined ? cf.maxOldGen : 0,
|
|
822
|
+
maxArrayBuffersKB: o.allowNoGc ? undefined : (cf.maxArrayBuffersKB !== undefined ? cf.maxArrayBuffersKB : 64),
|
|
823
|
+
counters: cf.counters || null
|
|
362
824
|
};
|
|
363
|
-
const posCtrl =
|
|
364
|
-
const negCtrl =
|
|
365
|
-
const
|
|
825
|
+
const posCtrl = cf.positiveControl || controlPositive;
|
|
826
|
+
const negCtrl = cf.negativeControl || controlNegative;
|
|
827
|
+
const largeCtrl = cf.largeControl || controlLarge;
|
|
828
|
+
const mustFail = cf.mustFail || [];
|
|
366
829
|
const maxScav = thresholds.maxScavenges;
|
|
367
830
|
const N = opts.N;
|
|
368
831
|
const k = opts.k;
|
|
369
832
|
|
|
370
833
|
console.log('Zero-GC gate \u2014 scavenge scaling (N=' + N + ', ' + k + 'N=' + (k * N) + ')\n');
|
|
371
834
|
|
|
835
|
+
// Measure every scavenge-gated pass FIRST, while the process is clean, and
|
|
836
|
+
// controlLarge LAST. controlLarge's retained external memory raises V8's
|
|
837
|
+
// scheduled-scavenge accounting, which poisons the NEXT measurement's minor
|
|
838
|
+
// count (H-a, decisions/0003 "controlLarge residue"); no gc drains it, so
|
|
839
|
+
// ordering is the fix -- nothing scavenge-gated may follow it. Detector
|
|
840
|
+
// validation therefore runs after measurement, still returning code 2 and
|
|
841
|
+
// discarding results if the instrument is broken.
|
|
372
842
|
const pos = await measure(posCtrl, opts);
|
|
373
843
|
const neg = await measure(negCtrl, opts);
|
|
844
|
+
|
|
845
|
+
const results = [];
|
|
846
|
+
for (let i = 0; i < scenarios.length; i++) {
|
|
847
|
+
results.push(await measure(scenarios[i], opts));
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const mfResults = [];
|
|
851
|
+
for (let j = 0; j < mustFail.length; j++) {
|
|
852
|
+
mfResults.push(await measure(mustFail[j], opts));
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const large = await measureLargeControl(largeCtrl, o.flushMs, o.allowNoGc);
|
|
856
|
+
_controlKeepAlive();
|
|
857
|
+
|
|
374
858
|
console.log('== controls ==');
|
|
375
859
|
console.log(formatResult(pos));
|
|
376
860
|
console.log(formatResult(neg));
|
|
377
|
-
|
|
861
|
+
console.log(formatResult(large));
|
|
378
862
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
return {passed: false, results: []};
|
|
863
|
+
const dv = validateDetector(pos, neg, large, maxScav);
|
|
864
|
+
if (!dv.ok) {
|
|
865
|
+
console.log('\n!! DETECTOR VALIDATION FAILED: ' + dv.reason);
|
|
866
|
+
return {passed: false, code: 2, results: []};
|
|
384
867
|
}
|
|
385
868
|
console.log('\ndetector validated: positive forced ' + pos.minorHi +
|
|
386
|
-
' scavenges
|
|
869
|
+
' scavenges at ' + k + 'N (' + pos.minorLo + ' at N, floor ' +
|
|
870
|
+
CONTROL_FLOOR + '), negative forced ' + neg.minorHi + '.\n');
|
|
387
871
|
|
|
388
872
|
console.log('== scenarios ==');
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
const r = await measure(scenarios[i], opts);
|
|
392
|
-
results.push(r);
|
|
393
|
-
console.log(formatResult(r));
|
|
873
|
+
for (let i = 0; i < results.length; i++) {
|
|
874
|
+
console.log(formatResult(results[i]));
|
|
394
875
|
}
|
|
395
876
|
|
|
396
877
|
let mustFailOK = true;
|
|
397
|
-
if (
|
|
878
|
+
if (mfResults.length > 0) {
|
|
398
879
|
console.log('\n== must-fail (gate self-test) ==');
|
|
399
|
-
for (let j = 0; j <
|
|
400
|
-
const
|
|
401
|
-
const mfv = verdict(mf, thresholds);
|
|
880
|
+
for (let j = 0; j < mfResults.length; j++) {
|
|
881
|
+
const mfv = verdict(mfResults[j], thresholds);
|
|
402
882
|
if (mfv.pass) mustFailOK = false;
|
|
403
|
-
console.log((mfv.pass ? 'MISSED ' : 'CAUGHT ') + formatResult(
|
|
883
|
+
console.log((mfv.pass ? 'MISSED ' : 'CAUGHT ') + formatResult(mfResults[j]));
|
|
404
884
|
}
|
|
405
885
|
}
|
|
406
886
|
|
|
@@ -420,7 +900,11 @@ export async function runGate(config) {
|
|
|
420
900
|
|
|
421
901
|
const passed = failures.length === 0 && mustFailOK;
|
|
422
902
|
if (passed) {
|
|
423
|
-
|
|
903
|
+
if (results.length === 0) {
|
|
904
|
+
console.log('\nZERO-GC GATE: PASS \u2014 detector only (allowEmpty, 0 scenarios gated).');
|
|
905
|
+
} else {
|
|
906
|
+
console.log('\nZERO-GC GATE: PASS \u2014 ' + results.length + '/' + results.length + ' scenarios.');
|
|
907
|
+
}
|
|
424
908
|
} else {
|
|
425
909
|
const why = failures.map(function (f) {
|
|
426
910
|
return f.name;
|
|
@@ -428,7 +912,8 @@ export async function runGate(config) {
|
|
|
428
912
|
if (!mustFailOK) why.push('must-fail scenario was not caught');
|
|
429
913
|
console.log('\nZERO-GC GATE: FAIL \u2014 ' + why.join('; '));
|
|
430
914
|
}
|
|
431
|
-
|
|
915
|
+
const code = passed ? 0 : 1;
|
|
916
|
+
return {passed: passed, code: code, results: results};
|
|
432
917
|
}
|
|
433
918
|
|
|
434
919
|
// ---------------------------------------------------------------------------
|
|
@@ -450,19 +935,27 @@ export async function runGate(config) {
|
|
|
450
935
|
|
|
451
936
|
const SPP_OP_CONT = 0x0F01;
|
|
452
937
|
|
|
453
|
-
const SUITE_REDUCES = {count: 1, sum: 1, max: 1, mean: 1, last: 1};
|
|
454
|
-
const SUITE_SLOTS = {t: 1, a: 2, b: 3};
|
|
938
|
+
const SUITE_REDUCES = {__proto__: null, count: 1, sum: 1, max: 1, mean: 1, last: 1};
|
|
939
|
+
const SUITE_SLOTS = {__proto__: null, t: 1, a: 2, b: 3};
|
|
455
940
|
|
|
456
941
|
function suiteMatcher(b, i) {
|
|
457
942
|
if (b.packed !== undefined) {
|
|
458
943
|
if (!Number.isInteger(b.packed) || b.packed < 0 || b.packed > 0xFFFFFFFF) {
|
|
459
944
|
throw new RangeError('suiteGate: budget[' + i + '] packed must be a u32');
|
|
460
945
|
}
|
|
946
|
+
if ((b.packed & 0xFFFF) === SPP_OP_CONT) {
|
|
947
|
+
throw new RangeError('suiteGate: budget[' + i + '] targets CONT (0x0F01): ' +
|
|
948
|
+
'CONT records are never budget targets (SPP v1)');
|
|
949
|
+
}
|
|
461
950
|
return {exact: b.packed >>> 0, op: -1};
|
|
462
951
|
}
|
|
463
952
|
if (b.op === undefined || !Number.isInteger(b.op) || b.op < 0 || b.op > 0xFFFF) {
|
|
464
953
|
throw new RangeError('suiteGate: budget[' + i + '] needs a u16 op (with optional stream), or packed');
|
|
465
954
|
}
|
|
955
|
+
if (b.op === SPP_OP_CONT) {
|
|
956
|
+
throw new RangeError('suiteGate: budget[' + i + '] targets CONT (0x0F01): ' +
|
|
957
|
+
'CONT records are never budget targets (SPP v1)');
|
|
958
|
+
}
|
|
466
959
|
if (b.stream !== undefined) {
|
|
467
960
|
if (!Number.isInteger(b.stream) || b.stream < 0 || b.stream > 0xFFFF) {
|
|
468
961
|
throw new RangeError('suiteGate: budget[' + i + '] stream must be a u16');
|
|
@@ -510,7 +1003,8 @@ export function suiteGate(config) {
|
|
|
510
1003
|
const match = new Array(n);
|
|
511
1004
|
const slot = new Array(n);
|
|
512
1005
|
const reduce = new Array(n);
|
|
513
|
-
const
|
|
1006
|
+
const minCnt = new Array(n);
|
|
1007
|
+
const seen = Object.create(null);
|
|
514
1008
|
for (let i = 0; i < n; i++) {
|
|
515
1009
|
const b = budgets[i];
|
|
516
1010
|
if (!b || typeof b.name !== 'string' || b.name.length === 0) {
|
|
@@ -529,9 +1023,18 @@ export function suiteGate(config) {
|
|
|
529
1023
|
if (SUITE_REDUCES[rd] === undefined) {
|
|
530
1024
|
throw new RangeError('suiteGate: budget "' + b.name + '" reduce must be count, sum, max, mean, or last');
|
|
531
1025
|
}
|
|
1026
|
+
let mc = 0;
|
|
1027
|
+
if (b.minCount !== undefined) {
|
|
1028
|
+
if (!Number.isInteger(b.minCount) || b.minCount < 0) {
|
|
1029
|
+
throw new RangeError('suiteGate: budget "' + b.name +
|
|
1030
|
+
'" minCount must be an integer >= 0 (got ' + String(b.minCount) + ')');
|
|
1031
|
+
}
|
|
1032
|
+
mc = b.minCount;
|
|
1033
|
+
}
|
|
532
1034
|
match[i] = suiteMatcher(b, i);
|
|
533
1035
|
slot[i] = SUITE_SLOTS[sl];
|
|
534
1036
|
reduce[i] = rd;
|
|
1037
|
+
minCnt[i] = mc;
|
|
535
1038
|
}
|
|
536
1039
|
|
|
537
1040
|
const count = new Float64Array(n);
|
|
@@ -554,14 +1057,58 @@ export function suiteGate(config) {
|
|
|
554
1057
|
}
|
|
555
1058
|
}
|
|
556
1059
|
|
|
1060
|
+
// Cold thrower (D-A / D-A'): the loop and visitChecked hold the index, so
|
|
1061
|
+
// visit()'s body stays byte-identical to the pre-door build and pays no index tax.
|
|
1062
|
+
function badRecord(idx, p) {
|
|
1063
|
+
throw new RangeError('suiteGate: record ' + idx + ': packed ' + String(p) +
|
|
1064
|
+
' is not a u32 -- an SPP record is 4 numbers [packed, t, a, b]');
|
|
1065
|
+
}
|
|
1066
|
+
// D-B (forEach lane, EVERY invocation): a slot that is not a number is a
|
|
1067
|
+
// corrupt record. A native Array/TypedArray forEach binds (value, index,
|
|
1068
|
+
// array) onto (packed, t, a, b); a non-native sink can also emit a non-
|
|
1069
|
+
// number slot at any index (null/'3'/true finitely coerce and pass a
|
|
1070
|
+
// budget silently -- 'null is not zero'). Checked on every record, not
|
|
1071
|
+
// just record 0.
|
|
1072
|
+
function badSlot(idx, t, a, b) {
|
|
1073
|
+
const bad = typeof t !== 'number' ? 't (' + typeof t + ')'
|
|
1074
|
+
: typeof a !== 'number' ? 'a (' + typeof a + ')'
|
|
1075
|
+
: 'b (' + typeof b + ')';
|
|
1076
|
+
throw new RangeError('suiteGate: record ' + idx + ': slot ' + bad +
|
|
1077
|
+
' is not a number -- forEach must invoke cb(packed, t, a, b) with ' +
|
|
1078
|
+
'four numbers (a native Array/TypedArray forEach binds ' +
|
|
1079
|
+
'(value, index, array) instead)');
|
|
1080
|
+
}
|
|
1081
|
+
let seenRec = 0;
|
|
1082
|
+
function visitChecked(packed, t, a, b) {
|
|
1083
|
+
if (packed !== packed >>> 0) badRecord(seenRec, packed);
|
|
1084
|
+
if (typeof t !== 'number' || typeof a !== 'number' || typeof b !== 'number') {
|
|
1085
|
+
badSlot(seenRec, t, a, b);
|
|
1086
|
+
}
|
|
1087
|
+
seenRec++;
|
|
1088
|
+
visit(packed, t, a, b);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// D-C (cold dispatch): a typed-array view that is not a same-realm
|
|
1092
|
+
// Float64Array dies before one record is read, naming its constructor.
|
|
1093
|
+
// A cross-realm Float64Array fails `instanceof` and lands here too --
|
|
1094
|
+
// ArrayBuffer.isView is realm-agnostic, so it is caught, not routed to the
|
|
1095
|
+
// arity-collision forEach path.
|
|
1096
|
+
if (ArrayBuffer.isView(source) && !(source instanceof Float64Array)) {
|
|
1097
|
+
throw new RangeError('suiteGate: record 0: source is a ' +
|
|
1098
|
+
source.constructor.name + ' view from another realm, or a ' +
|
|
1099
|
+
'non-Float64Array view -- pass a same-realm Float64Array SPP slab ' +
|
|
1100
|
+
'(a foreign or wrong-typed view forEach binds (value, index, array))');
|
|
1101
|
+
}
|
|
557
1102
|
if (source && typeof source.forEach === 'function' && !(source instanceof Float64Array)) {
|
|
558
|
-
source.forEach(
|
|
1103
|
+
source.forEach(visitChecked);
|
|
559
1104
|
} else if (source instanceof Float64Array) {
|
|
560
1105
|
if (source.length % 4 !== 0) {
|
|
561
1106
|
throw new RangeError('suiteGate: slab length must be divisible by 4');
|
|
562
1107
|
}
|
|
563
1108
|
for (let r = 0; r < source.length; r += 4) {
|
|
564
|
-
|
|
1109
|
+
const p = source[r];
|
|
1110
|
+
if (p !== p >>> 0) badRecord(r >> 2, p); // D-A, the measured door
|
|
1111
|
+
visit(p, source[r + 1], source[r + 2], source[r + 3]);
|
|
565
1112
|
}
|
|
566
1113
|
} else {
|
|
567
1114
|
throw new TypeError('suiteGate: source must be a Float64Array slab or a forEach record source');
|
|
@@ -579,19 +1126,36 @@ export function suiteGate(config) {
|
|
|
579
1126
|
else if (reduce[i] === 'mean') value = count[i] > 0 ? sum[i] / count[i] : 0;
|
|
580
1127
|
else value = count[i] > 0 ? last[i] : 0;
|
|
581
1128
|
|
|
582
|
-
const counters = {};
|
|
1129
|
+
const counters = {__proto__: null};
|
|
583
1130
|
counters[b.name] = value;
|
|
584
|
-
const ct = {};
|
|
1131
|
+
const ct = {__proto__: null};
|
|
585
1132
|
ct[b.name] = b.max;
|
|
586
1133
|
const v = verdict(
|
|
587
1134
|
{name: b.name, minorHi: 0, majorHi: 0, retainedKB_hi: 0, counters_hi: counters},
|
|
588
1135
|
{counters: ct}
|
|
589
1136
|
);
|
|
590
|
-
|
|
591
|
-
|
|
1137
|
+
const rs = v.reasons.slice(); // cold; per budget, not per record
|
|
1138
|
+
let bPass = v.pass;
|
|
1139
|
+
const mc = minCnt[i];
|
|
1140
|
+
if (mc > 0) {
|
|
1141
|
+
// minCount is a MINIMUM on count; verdict() is max-only, so express
|
|
1142
|
+
// it as a maximum of 0 on the SHORTFALL (mc - count) in a SECOND,
|
|
1143
|
+
// separate verdict() call -- the fixed internal key 'shortfall'
|
|
1144
|
+
// lives in its own namespace and cannot alias a user budget name.
|
|
1145
|
+
const mv = verdict(
|
|
1146
|
+
{name: b.name, minorHi: 0, majorHi: 0, retainedKB_hi: 0,
|
|
1147
|
+
counters_hi: {__proto__: null, shortfall: mc - count[i]}},
|
|
1148
|
+
{counters: {__proto__: null, shortfall: 0}});
|
|
1149
|
+
if (!mv.pass) {
|
|
1150
|
+
bPass = false;
|
|
1151
|
+
rs.push(b.name + ': matched ' + count[i] + ' < minCount ' + mc);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
if (!bPass) pass = false;
|
|
1155
|
+
for (let ri = 0; ri < rs.length; ri++) reasons.push(rs[ri]);
|
|
592
1156
|
perBudget.push({
|
|
593
1157
|
name: b.name, value: value, count: count[i], max: b.max,
|
|
594
|
-
pass:
|
|
1158
|
+
minCount: mc, pass: bPass, reasons: rs
|
|
595
1159
|
});
|
|
596
1160
|
}
|
|
597
1161
|
|
|
@@ -625,7 +1189,9 @@ function ndjsonMeasure(r, meta, lines) {
|
|
|
625
1189
|
type: 'measure', name: r.name, N: r.N, k: r.k,
|
|
626
1190
|
minorLo: r.minorLo, minorHi: r.minorHi,
|
|
627
1191
|
majorLo: r.majorLo, majorHi: r.majorHi,
|
|
1192
|
+
oldGenLo: r.oldGenLo, oldGenHi: r.oldGenHi,
|
|
628
1193
|
retainedKB_lo: r.retainedKB_lo, retainedKB_hi: r.retainedKB_hi,
|
|
1194
|
+
arrayBuffersKB_lo: r.arrayBuffersKB_lo, arrayBuffersKB_hi: r.arrayBuffersKB_hi,
|
|
629
1195
|
counters_lo: r.counters_lo, counters_hi: r.counters_hi
|
|
630
1196
|
})));
|
|
631
1197
|
}
|