@zakkster/lite-perf-gate 1.3.0 → 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 +256 -0
- package/PerfGate.d.ts +86 -3
- package/PerfGate.js +591 -86
- package/llms.txt +69 -10
- package/package.json +4 -2
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
|
|
|
@@ -220,6 +420,90 @@ export const controlNegative = {
|
|
|
220
420
|
}
|
|
221
421
|
};
|
|
222
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
|
+
|
|
223
507
|
// ---------------------------------------------------------------------------
|
|
224
508
|
// Detector validation
|
|
225
509
|
// ---------------------------------------------------------------------------
|
|
@@ -230,17 +514,24 @@ export const controlNegative = {
|
|
|
230
514
|
const CONTROL_FLOOR = 6; // positive control: scavenges at k*N
|
|
231
515
|
const CONTROL_SCALE = 2; // positive control: minorHi / minorLo
|
|
232
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
|
|
233
522
|
|
|
234
523
|
/**
|
|
235
524
|
* The ONE detector-validation predicate. zgcSuite and runGate both call it;
|
|
236
|
-
* neither forks it. Comparisons are written so a NaN count fails
|
|
525
|
+
* neither forks it. Comparisons are written so a NaN/undefined count fails
|
|
526
|
+
* closed (`!(x >= f)` / `!(x <= c)`).
|
|
237
527
|
*
|
|
238
|
-
* @param {MeasureResult} pos
|
|
239
|
-
* @param {MeasureResult} neg
|
|
240
|
-
* @param {
|
|
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)
|
|
241
532
|
* @returns {{ ok: boolean, reason: string | null }}
|
|
242
533
|
*/
|
|
243
|
-
function validateDetector(pos, neg, maxScav) {
|
|
534
|
+
function validateDetector(pos, neg, large, maxScav) {
|
|
244
535
|
const flags = ' Run with --expose-gc --max-semi-space-size=4.';
|
|
245
536
|
if (!(pos.minorHi >= CONTROL_FLOOR)) {
|
|
246
537
|
return {ok: false, reason: 'positive control forced ' + pos.minorHi +
|
|
@@ -257,6 +548,22 @@ function validateDetector(pos, neg, maxScav) {
|
|
|
257
548
|
' scavenges (need <=' + negCeil + ') -- noisy process, or a prior ' +
|
|
258
549
|
'measurement poisoned it.' + flags};
|
|
259
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
|
+
}
|
|
260
567
|
return {ok: true, reason: null};
|
|
261
568
|
}
|
|
262
569
|
|
|
@@ -267,31 +574,89 @@ function validateDetector(pos, neg, maxScav) {
|
|
|
267
574
|
/**
|
|
268
575
|
* Evaluate a measurement against thresholds.
|
|
269
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
|
+
*
|
|
270
584
|
* @param {MeasureResult} r
|
|
271
585
|
* @param {object} [thresholds]
|
|
272
|
-
* @param {number} [thresholds.maxScavenges=2]
|
|
273
|
-
* @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
|
|
274
588
|
* @param {Record<string, number>} [thresholds.counters]
|
|
275
|
-
* 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.
|
|
276
591
|
* @returns {{ pass: boolean, reasons: string[] }}
|
|
277
592
|
*/
|
|
278
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
|
+
}
|
|
279
601
|
const maxScav = (thresholds && thresholds.maxScavenges !== undefined) ? thresholds.maxScavenges : 2;
|
|
280
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;
|
|
281
605
|
const ct = (thresholds && thresholds.counters) || null;
|
|
282
606
|
const reasons = [];
|
|
283
607
|
|
|
284
|
-
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) {
|
|
285
611
|
reasons.push('scavenges: ' + r.minorHi + ' > ' + maxScav + ' (transient allocation)');
|
|
286
612
|
}
|
|
287
|
-
if (r.
|
|
288
|
-
|
|
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
|
+
}
|
|
289
643
|
}
|
|
290
|
-
if (ct !== null
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
+
}
|
|
295
660
|
}
|
|
296
661
|
}
|
|
297
662
|
}
|
|
@@ -312,6 +677,12 @@ export function formatResult(r) {
|
|
|
312
677
|
'\n scavenges N:' + String(r.minorLo).padStart(3) +
|
|
313
678
|
' ' + r.k + 'N:' + String(r.minorHi).padStart(3) +
|
|
314
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
|
+
}
|
|
315
686
|
if (r.counters_hi !== null) {
|
|
316
687
|
for (const key in r.counters_hi) {
|
|
317
688
|
s += ' ' + key + ' \u0394' + String(r.counters_hi[key]).padStart(6);
|
|
@@ -354,27 +725,49 @@ export function formatResult(r) {
|
|
|
354
725
|
* @param {Scenario} [config.negativeControl] Override negative control.
|
|
355
726
|
* @param {Scenario[]} [config.mustFail]
|
|
356
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.
|
|
357
737
|
*/
|
|
358
738
|
export function zgcSuite(config) {
|
|
359
|
-
const
|
|
360
|
-
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};
|
|
361
743
|
const thresholds = {
|
|
362
|
-
maxScavenges:
|
|
363
|
-
maxRetainedKB:
|
|
364
|
-
|
|
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
|
|
365
749
|
};
|
|
366
|
-
const posCtrl =
|
|
367
|
-
const negCtrl =
|
|
368
|
-
const
|
|
750
|
+
const posCtrl = cf.positiveControl || controlPositive;
|
|
751
|
+
const negCtrl = cf.negativeControl || controlNegative;
|
|
752
|
+
const largeCtrl = cf.largeControl || controlLarge;
|
|
753
|
+
const mustFail = cf.mustFail || [];
|
|
369
754
|
const maxScav = thresholds.maxScavenges;
|
|
370
755
|
|
|
371
|
-
test(
|
|
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 () {
|
|
372
759
|
const pos = await measure(posCtrl, opts);
|
|
373
760
|
_controlKeepAlive();
|
|
374
761
|
const neg = await measure(negCtrl, opts);
|
|
375
|
-
|
|
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);
|
|
376
769
|
assert.ok(v.ok, 'DETECTOR VALIDATION FAILED: ' + v.reason +
|
|
377
|
-
'\n ' + formatResult(pos) + '\n ' + formatResult(neg));
|
|
770
|
+
'\n ' + formatResult(pos) + '\n ' + formatResult(neg) + '\n ' + formatResult(large));
|
|
378
771
|
});
|
|
379
772
|
|
|
380
773
|
for (let i = 0; i < scenarios.length; i++) {
|
|
@@ -407,61 +800,87 @@ export function zgcSuite(config) {
|
|
|
407
800
|
// ---------------------------------------------------------------------------
|
|
408
801
|
|
|
409
802
|
/**
|
|
410
|
-
* Run a gate check and print a human-readable report.
|
|
411
|
-
*
|
|
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)`.
|
|
412
809
|
*
|
|
413
810
|
* @param {object} config Same shape as zgcSuite.
|
|
414
|
-
* @returns {Promise<{ passed: boolean, results: MeasureResult[] }>}
|
|
811
|
+
* @returns {Promise<{ passed: boolean, code: 0 | 1 | 2, results: MeasureResult[] }>}
|
|
415
812
|
*/
|
|
416
813
|
export async function runGate(config) {
|
|
417
|
-
const
|
|
418
|
-
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};
|
|
419
818
|
const thresholds = {
|
|
420
|
-
maxScavenges:
|
|
421
|
-
maxRetainedKB:
|
|
422
|
-
|
|
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
|
|
423
824
|
};
|
|
424
|
-
const posCtrl =
|
|
425
|
-
const negCtrl =
|
|
426
|
-
const
|
|
825
|
+
const posCtrl = cf.positiveControl || controlPositive;
|
|
826
|
+
const negCtrl = cf.negativeControl || controlNegative;
|
|
827
|
+
const largeCtrl = cf.largeControl || controlLarge;
|
|
828
|
+
const mustFail = cf.mustFail || [];
|
|
427
829
|
const maxScav = thresholds.maxScavenges;
|
|
428
830
|
const N = opts.N;
|
|
429
831
|
const k = opts.k;
|
|
430
832
|
|
|
431
833
|
console.log('Zero-GC gate \u2014 scavenge scaling (N=' + N + ', ' + k + 'N=' + (k * N) + ')\n');
|
|
432
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.
|
|
433
842
|
const pos = await measure(posCtrl, opts);
|
|
434
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
|
+
|
|
435
858
|
console.log('== controls ==');
|
|
436
859
|
console.log(formatResult(pos));
|
|
437
860
|
console.log(formatResult(neg));
|
|
438
|
-
|
|
861
|
+
console.log(formatResult(large));
|
|
439
862
|
|
|
440
|
-
const dv = validateDetector(pos, neg, maxScav);
|
|
863
|
+
const dv = validateDetector(pos, neg, large, maxScav);
|
|
441
864
|
if (!dv.ok) {
|
|
442
865
|
console.log('\n!! DETECTOR VALIDATION FAILED: ' + dv.reason);
|
|
443
|
-
return {passed: false, results: []};
|
|
866
|
+
return {passed: false, code: 2, results: []};
|
|
444
867
|
}
|
|
445
868
|
console.log('\ndetector validated: positive forced ' + pos.minorHi +
|
|
446
869
|
' scavenges at ' + k + 'N (' + pos.minorLo + ' at N, floor ' +
|
|
447
870
|
CONTROL_FLOOR + '), negative forced ' + neg.minorHi + '.\n');
|
|
448
871
|
|
|
449
872
|
console.log('== scenarios ==');
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
const r = await measure(scenarios[i], opts);
|
|
453
|
-
results.push(r);
|
|
454
|
-
console.log(formatResult(r));
|
|
873
|
+
for (let i = 0; i < results.length; i++) {
|
|
874
|
+
console.log(formatResult(results[i]));
|
|
455
875
|
}
|
|
456
876
|
|
|
457
877
|
let mustFailOK = true;
|
|
458
|
-
if (
|
|
878
|
+
if (mfResults.length > 0) {
|
|
459
879
|
console.log('\n== must-fail (gate self-test) ==');
|
|
460
|
-
for (let j = 0; j <
|
|
461
|
-
const
|
|
462
|
-
const mfv = verdict(mf, thresholds);
|
|
880
|
+
for (let j = 0; j < mfResults.length; j++) {
|
|
881
|
+
const mfv = verdict(mfResults[j], thresholds);
|
|
463
882
|
if (mfv.pass) mustFailOK = false;
|
|
464
|
-
console.log((mfv.pass ? 'MISSED ' : 'CAUGHT ') + formatResult(
|
|
883
|
+
console.log((mfv.pass ? 'MISSED ' : 'CAUGHT ') + formatResult(mfResults[j]));
|
|
465
884
|
}
|
|
466
885
|
}
|
|
467
886
|
|
|
@@ -481,7 +900,11 @@ export async function runGate(config) {
|
|
|
481
900
|
|
|
482
901
|
const passed = failures.length === 0 && mustFailOK;
|
|
483
902
|
if (passed) {
|
|
484
|
-
|
|
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
|
+
}
|
|
485
908
|
} else {
|
|
486
909
|
const why = failures.map(function (f) {
|
|
487
910
|
return f.name;
|
|
@@ -489,7 +912,8 @@ export async function runGate(config) {
|
|
|
489
912
|
if (!mustFailOK) why.push('must-fail scenario was not caught');
|
|
490
913
|
console.log('\nZERO-GC GATE: FAIL \u2014 ' + why.join('; '));
|
|
491
914
|
}
|
|
492
|
-
|
|
915
|
+
const code = passed ? 0 : 1;
|
|
916
|
+
return {passed: passed, code: code, results: results};
|
|
493
917
|
}
|
|
494
918
|
|
|
495
919
|
// ---------------------------------------------------------------------------
|
|
@@ -511,19 +935,27 @@ export async function runGate(config) {
|
|
|
511
935
|
|
|
512
936
|
const SPP_OP_CONT = 0x0F01;
|
|
513
937
|
|
|
514
|
-
const SUITE_REDUCES = {count: 1, sum: 1, max: 1, mean: 1, last: 1};
|
|
515
|
-
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};
|
|
516
940
|
|
|
517
941
|
function suiteMatcher(b, i) {
|
|
518
942
|
if (b.packed !== undefined) {
|
|
519
943
|
if (!Number.isInteger(b.packed) || b.packed < 0 || b.packed > 0xFFFFFFFF) {
|
|
520
944
|
throw new RangeError('suiteGate: budget[' + i + '] packed must be a u32');
|
|
521
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
|
+
}
|
|
522
950
|
return {exact: b.packed >>> 0, op: -1};
|
|
523
951
|
}
|
|
524
952
|
if (b.op === undefined || !Number.isInteger(b.op) || b.op < 0 || b.op > 0xFFFF) {
|
|
525
953
|
throw new RangeError('suiteGate: budget[' + i + '] needs a u16 op (with optional stream), or packed');
|
|
526
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
|
+
}
|
|
527
959
|
if (b.stream !== undefined) {
|
|
528
960
|
if (!Number.isInteger(b.stream) || b.stream < 0 || b.stream > 0xFFFF) {
|
|
529
961
|
throw new RangeError('suiteGate: budget[' + i + '] stream must be a u16');
|
|
@@ -571,7 +1003,8 @@ export function suiteGate(config) {
|
|
|
571
1003
|
const match = new Array(n);
|
|
572
1004
|
const slot = new Array(n);
|
|
573
1005
|
const reduce = new Array(n);
|
|
574
|
-
const
|
|
1006
|
+
const minCnt = new Array(n);
|
|
1007
|
+
const seen = Object.create(null);
|
|
575
1008
|
for (let i = 0; i < n; i++) {
|
|
576
1009
|
const b = budgets[i];
|
|
577
1010
|
if (!b || typeof b.name !== 'string' || b.name.length === 0) {
|
|
@@ -590,9 +1023,18 @@ export function suiteGate(config) {
|
|
|
590
1023
|
if (SUITE_REDUCES[rd] === undefined) {
|
|
591
1024
|
throw new RangeError('suiteGate: budget "' + b.name + '" reduce must be count, sum, max, mean, or last');
|
|
592
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
|
+
}
|
|
593
1034
|
match[i] = suiteMatcher(b, i);
|
|
594
1035
|
slot[i] = SUITE_SLOTS[sl];
|
|
595
1036
|
reduce[i] = rd;
|
|
1037
|
+
minCnt[i] = mc;
|
|
596
1038
|
}
|
|
597
1039
|
|
|
598
1040
|
const count = new Float64Array(n);
|
|
@@ -615,14 +1057,58 @@ export function suiteGate(config) {
|
|
|
615
1057
|
}
|
|
616
1058
|
}
|
|
617
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
|
+
}
|
|
618
1102
|
if (source && typeof source.forEach === 'function' && !(source instanceof Float64Array)) {
|
|
619
|
-
source.forEach(
|
|
1103
|
+
source.forEach(visitChecked);
|
|
620
1104
|
} else if (source instanceof Float64Array) {
|
|
621
1105
|
if (source.length % 4 !== 0) {
|
|
622
1106
|
throw new RangeError('suiteGate: slab length must be divisible by 4');
|
|
623
1107
|
}
|
|
624
1108
|
for (let r = 0; r < source.length; r += 4) {
|
|
625
|
-
|
|
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]);
|
|
626
1112
|
}
|
|
627
1113
|
} else {
|
|
628
1114
|
throw new TypeError('suiteGate: source must be a Float64Array slab or a forEach record source');
|
|
@@ -640,19 +1126,36 @@ export function suiteGate(config) {
|
|
|
640
1126
|
else if (reduce[i] === 'mean') value = count[i] > 0 ? sum[i] / count[i] : 0;
|
|
641
1127
|
else value = count[i] > 0 ? last[i] : 0;
|
|
642
1128
|
|
|
643
|
-
const counters = {};
|
|
1129
|
+
const counters = {__proto__: null};
|
|
644
1130
|
counters[b.name] = value;
|
|
645
|
-
const ct = {};
|
|
1131
|
+
const ct = {__proto__: null};
|
|
646
1132
|
ct[b.name] = b.max;
|
|
647
1133
|
const v = verdict(
|
|
648
1134
|
{name: b.name, minorHi: 0, majorHi: 0, retainedKB_hi: 0, counters_hi: counters},
|
|
649
1135
|
{counters: ct}
|
|
650
1136
|
);
|
|
651
|
-
|
|
652
|
-
|
|
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]);
|
|
653
1156
|
perBudget.push({
|
|
654
1157
|
name: b.name, value: value, count: count[i], max: b.max,
|
|
655
|
-
pass:
|
|
1158
|
+
minCount: mc, pass: bPass, reasons: rs
|
|
656
1159
|
});
|
|
657
1160
|
}
|
|
658
1161
|
|
|
@@ -686,7 +1189,9 @@ function ndjsonMeasure(r, meta, lines) {
|
|
|
686
1189
|
type: 'measure', name: r.name, N: r.N, k: r.k,
|
|
687
1190
|
minorLo: r.minorLo, minorHi: r.minorHi,
|
|
688
1191
|
majorLo: r.majorLo, majorHi: r.majorHi,
|
|
1192
|
+
oldGenLo: r.oldGenLo, oldGenHi: r.oldGenHi,
|
|
689
1193
|
retainedKB_lo: r.retainedKB_lo, retainedKB_hi: r.retainedKB_hi,
|
|
1194
|
+
arrayBuffersKB_lo: r.arrayBuffersKB_lo, arrayBuffersKB_hi: r.arrayBuffersKB_hi,
|
|
690
1195
|
counters_lo: r.counters_lo, counters_hi: r.counters_hi
|
|
691
1196
|
})));
|
|
692
1197
|
}
|