@zakkster/lite-perf-gate 1.0.0 → 1.2.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 +37 -0
- package/PerfGate.d.ts +71 -1
- package/PerfGate.js +263 -11
- package/README.md +50 -0
- package/llms.txt +26 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.2.0] - 2026-07-07
|
|
4
|
+
|
|
5
|
+
- **`toNDJSON(x, meta?)`** -- NDJSON verdict output for CI artifacts.
|
|
6
|
+
Accepts a `suiteGate()` result, a `measure()` result, or an array of
|
|
7
|
+
either; emits `budget` lines before their `suite-gate` summary line and
|
|
8
|
+
`measure` lines, with optional per-run `meta` fields merged into every
|
|
9
|
+
line. Otherwise the API is frozen, per the suite roadmap.
|
|
10
|
+
|
|
11
|
+
## [1.1.0] - 2026-07-07
|
|
12
|
+
|
|
13
|
+
- **`suiteGate(config)`** -- SPP stream-fed budgets. Consumes a Float64Array
|
|
14
|
+
slab or any forEach record source (a lite-scope memory sink qualifies),
|
|
15
|
+
reduces per-budget metrics (count/sum/max/mean/last over slot t/a/b,
|
|
16
|
+
matched by packed header, stream+op, or op-only), and delegates every
|
|
17
|
+
threshold comparison to `verdict()` -- one comparison authority,
|
|
18
|
+
per-budget structured results. No package import: lite-perf-gate speaks
|
|
19
|
+
the Scope Probe Protocol (SPP v1), it does not depend on lite-scope.
|
|
20
|
+
suiteGate never touches process exit codes; runner semantics (0/1/3 in
|
|
21
|
+
the VersionMatrix scripts, 0/1/2 in `runGate`) stay in the runner layer.
|
|
22
|
+
CONT continuation records are never budget targets in v1.1.
|
|
23
|
+
- **Version discipline fix**: `VERSION` now reads `1.2.0` and the self-test
|
|
24
|
+
asserts it against `package.json` (the published 1.0.1 tarball shipped
|
|
25
|
+
`VERSION = '1.0.0'` because the old test pinned a literal instead of the
|
|
26
|
+
manifest -- that class of slip is now structurally impossible).
|
|
27
|
+
|
|
3
28
|
## [1.0.0] - 2026-07-07
|
|
4
29
|
|
|
5
30
|
Initial release. Generalized from the `@zakkster/lite-signal` zero-GC gate
|
|
@@ -22,6 +47,18 @@ Three signals: scavenge count (transient allocation), custom counters
|
|
|
22
47
|
Scaling verdict: measure at N and k*N. Detector self-validation: the gate
|
|
23
48
|
refuses to judge if the controls misbehave.
|
|
24
49
|
|
|
50
|
+
### CI tuning
|
|
51
|
+
|
|
52
|
+
- `flushMs` option (default 100ms; env var `PERF_GATE_FLUSH_MS`) controls
|
|
53
|
+
how long the harness waits after the hot loop before reading the
|
|
54
|
+
perf_hooks GC observer buffer. Bump to 250-500ms on noisy CI runners
|
|
55
|
+
where event-loop stalls can drop GC entries.
|
|
56
|
+
- `gc2()` yields a `setImmediate` tick between its two `globalThis.gc()`
|
|
57
|
+
passes so any `FinalizationRegistry` cleanups scheduled by the first
|
|
58
|
+
pass run before the second. Prevents WeakRef/finalizer-driven teardown
|
|
59
|
+
(used in `lite-cleanup`, `lite-observe`, `lite-floating`) from leaving
|
|
60
|
+
the heap in an intermediate state and inflating retained deltas.
|
|
61
|
+
|
|
25
62
|
### Tests
|
|
26
63
|
|
|
27
64
|
12 self-tests covering controls, verdict logic, counter deltas, and
|
package/PerfGate.d.ts
CHANGED
|
@@ -79,6 +79,12 @@ export interface GateConfig {
|
|
|
79
79
|
negativeControl?: Scenario;
|
|
80
80
|
/** Scenarios that MUST trip the gate (injected allocation self-tests). */
|
|
81
81
|
mustFail?: Scenario[];
|
|
82
|
+
/**
|
|
83
|
+
* Wait (ms) after the hot loop before reading the GC observer buffer.
|
|
84
|
+
* Default 100 (or PERF_GATE_FLUSH_MS env var). Bump to 250-500 on
|
|
85
|
+
* noisy CI runners where event-loop stalls > 100ms may drop entries.
|
|
86
|
+
*/
|
|
87
|
+
flushMs?: number;
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
export interface GateResult {
|
|
@@ -91,7 +97,7 @@ export interface GateResult {
|
|
|
91
97
|
*/
|
|
92
98
|
export function measure(
|
|
93
99
|
scenario: Scenario,
|
|
94
|
-
options?: { N?: number; k?: number }
|
|
100
|
+
options?: { N?: number; k?: number; flushMs?: number }
|
|
95
101
|
): Promise<MeasureResult>;
|
|
96
102
|
|
|
97
103
|
/**
|
|
@@ -123,3 +129,67 @@ export function runGate(config: GateConfig): Promise<GateResult>;
|
|
|
123
129
|
|
|
124
130
|
/** @internal */
|
|
125
131
|
export function _controlKeepAlive(): number;
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// suiteGate (v1.1) + toNDJSON (v1.2)
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/** forEach-style SPP record source (e.g. a lite-scope memory sink). */
|
|
138
|
+
export interface SppRecordSource {
|
|
139
|
+
forEach(cb: (packed: number, t: number, a: number, b: number) => void): void;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface SuiteBudget {
|
|
143
|
+
name: string;
|
|
144
|
+
/** Exact packed header (streamId << 16 | opcode). */
|
|
145
|
+
packed?: number;
|
|
146
|
+
/** Stream id; combine with op for an exact match. */
|
|
147
|
+
stream?: number;
|
|
148
|
+
/** Opcode; alone it matches the op on any stream. */
|
|
149
|
+
op?: number;
|
|
150
|
+
/** Record slot to read. Default 'a'. */
|
|
151
|
+
slot?: 't' | 'a' | 'b';
|
|
152
|
+
/** Reduction over matching records. Default 'max'. */
|
|
153
|
+
reduce?: 'count' | 'sum' | 'max' | 'mean' | 'last';
|
|
154
|
+
/** Inclusive budget: verdict() flags value > max. */
|
|
155
|
+
max: number;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface SuiteBudgetResult {
|
|
159
|
+
name: string;
|
|
160
|
+
value: number;
|
|
161
|
+
count: number;
|
|
162
|
+
max: number;
|
|
163
|
+
pass: boolean;
|
|
164
|
+
reasons: string[];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface SuiteGateResult {
|
|
168
|
+
name: string;
|
|
169
|
+
pass: boolean;
|
|
170
|
+
reasons: string[];
|
|
171
|
+
budgets: SuiteBudgetResult[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Evaluate SPP stream records against numeric budgets. Pure reduction with
|
|
176
|
+
* per-budget delegation to verdict(); no measurement, no process exit
|
|
177
|
+
* codes, no record emission. Wide-record CONT payloads are not budget
|
|
178
|
+
* targets in v1.1 (base-record t/a/b slots only).
|
|
179
|
+
*/
|
|
180
|
+
export function suiteGate(config: {
|
|
181
|
+
source: Float64Array | SppRecordSource;
|
|
182
|
+
budgets: SuiteBudget[];
|
|
183
|
+
name?: string;
|
|
184
|
+
}): SuiteGateResult;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Serialize suiteGate() and/or measure() results as NDJSON for CI
|
|
188
|
+
* artifacts: one JSON object per line, budget lines before their
|
|
189
|
+
* suite-gate summary, trailing newline. `meta` fields merge into every
|
|
190
|
+
* line; core fields win on collision.
|
|
191
|
+
*/
|
|
192
|
+
export function toNDJSON(
|
|
193
|
+
x: SuiteGateResult | MeasureResult | Array<SuiteGateResult | MeasureResult>,
|
|
194
|
+
meta?: Record<string, unknown>
|
|
195
|
+
): string;
|
package/PerfGate.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* MIT License
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
export const VERSION = '1.
|
|
20
|
+
export const VERSION = '1.2.0';
|
|
21
21
|
|
|
22
22
|
import {PerformanceObserver, constants} from 'node:perf_hooks';
|
|
23
23
|
import {setTimeout as sleep} from 'node:timers/promises';
|
|
@@ -48,33 +48,53 @@ function makeGcCounter() {
|
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
function gc2() {
|
|
51
|
+
async function gc2() {
|
|
52
52
|
if (typeof globalThis.gc === 'function') {
|
|
53
53
|
globalThis.gc();
|
|
54
|
+
// Yield one task tick so any FinalizationRegistry cleanup callbacks
|
|
55
|
+
// registered from the first gc() get a chance to run before the
|
|
56
|
+
// second pass. Without this, WeakRef/FinalizationRegistry-driven
|
|
57
|
+
// teardown (used across the ecosystem in lite-cleanup / lite-observe
|
|
58
|
+
// / lite-floating) can leave the heap in an intermediate state
|
|
59
|
+
// between the two collections, inflating the retained delta.
|
|
60
|
+
await new Promise(function (r) { setImmediate(r); });
|
|
54
61
|
globalThis.gc();
|
|
55
62
|
}
|
|
56
63
|
}
|
|
57
64
|
|
|
65
|
+
// Default post-hot sleep before reading the GC observer buffer. The Node
|
|
66
|
+
// perf_hooks 'gc' entries are delivered asynchronously, so we need to yield
|
|
67
|
+
// long enough for the observer callback to fire. 100ms is a safe floor on
|
|
68
|
+
// most systems; noisy CI runners (shared GitHub Actions, Docker under
|
|
69
|
+
// contention) can stall the event loop for tens of milliseconds and drop
|
|
70
|
+
// entries with a shorter wait. Override per-measurement via the `flushMs`
|
|
71
|
+
// option, or globally via the PERF_GATE_FLUSH_MS environment variable.
|
|
72
|
+
var DEFAULT_FLUSH_MS = 100;
|
|
73
|
+
if (typeof process !== 'undefined' && process.env && process.env.PERF_GATE_FLUSH_MS) {
|
|
74
|
+
var envMs = parseInt(process.env.PERF_GATE_FLUSH_MS, 10);
|
|
75
|
+
if (envMs > 0 && envMs < 60000) DEFAULT_FLUSH_MS = envMs;
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
// ---------------------------------------------------------------------------
|
|
59
79
|
// Core measurement
|
|
60
80
|
// ---------------------------------------------------------------------------
|
|
61
81
|
|
|
62
|
-
async function meterOnce(scenario, iters) {
|
|
82
|
+
async function meterOnce(scenario, iters, flushMs) {
|
|
63
83
|
const state = scenario.setup();
|
|
64
84
|
scenario.hot(state, Math.min(iters, 20000));
|
|
65
|
-
gc2();
|
|
85
|
+
await gc2();
|
|
66
86
|
|
|
67
87
|
const statsBefore = scenario.statsOf ? scenario.statsOf(state) : null;
|
|
68
88
|
const heapBefore = process.memoryUsage().heapUsed;
|
|
69
89
|
|
|
70
90
|
const gcc = makeGcCounter();
|
|
71
91
|
scenario.hot(state, iters);
|
|
72
|
-
await sleep(
|
|
92
|
+
await sleep(flushMs);
|
|
73
93
|
const minor = gcc.c.minor;
|
|
74
94
|
const major = gcc.c.major;
|
|
75
95
|
gcc.close();
|
|
76
96
|
|
|
77
|
-
gc2();
|
|
97
|
+
await gc2();
|
|
78
98
|
const heapAfter = process.memoryUsage().heapUsed;
|
|
79
99
|
const statsAfter = scenario.statsOf ? scenario.statsOf(state) : null;
|
|
80
100
|
if (scenario.teardown) scenario.teardown(state);
|
|
@@ -114,14 +134,19 @@ async function meterOnce(scenario, iters) {
|
|
|
114
134
|
* allocation via scavenge scaling.
|
|
115
135
|
*
|
|
116
136
|
* @param {Scenario} scenario
|
|
117
|
-
* @param {{ N?: number, k?: number }} [options]
|
|
137
|
+
* @param {{ N?: number, k?: number, flushMs?: number }} [options]
|
|
138
|
+
* flushMs: how long to wait after the hot loop before reading the GC
|
|
139
|
+
* observer buffer. Default 100ms (or PERF_GATE_FLUSH_MS env var).
|
|
140
|
+
* Bump if you see zero scavenges on scenarios that should allocate --
|
|
141
|
+
* noisy CI runners may need 250-500ms.
|
|
118
142
|
* @returns {Promise<MeasureResult>}
|
|
119
143
|
*/
|
|
120
144
|
export async function measure(scenario, options) {
|
|
121
145
|
const N = (options && options.N) || 200000;
|
|
122
146
|
const k = (options && options.k) || 8;
|
|
123
|
-
const
|
|
124
|
-
const
|
|
147
|
+
const flushMs = (options && options.flushMs) || DEFAULT_FLUSH_MS;
|
|
148
|
+
const lo = await meterOnce(scenario, N, flushMs);
|
|
149
|
+
const hi = await meterOnce(scenario, k * N, flushMs);
|
|
125
150
|
return {
|
|
126
151
|
name: scenario.name, N: N, k: k,
|
|
127
152
|
minorLo: lo.minor, minorHi: hi.minor,
|
|
@@ -264,7 +289,7 @@ export function formatResult(r) {
|
|
|
264
289
|
*/
|
|
265
290
|
export function zgcSuite(config) {
|
|
266
291
|
const scenarios = config.scenarios;
|
|
267
|
-
const opts = {N: config.N || 200000, k: config.k || 8};
|
|
292
|
+
const opts = {N: config.N || 200000, k: config.k || 8, flushMs: config.flushMs};
|
|
268
293
|
const thresholds = {
|
|
269
294
|
maxScavenges: config.maxScavenges !== undefined ? config.maxScavenges : 2,
|
|
270
295
|
maxRetainedKB: config.maxRetainedKB !== undefined ? config.maxRetainedKB : 64,
|
|
@@ -327,7 +352,7 @@ export function zgcSuite(config) {
|
|
|
327
352
|
*/
|
|
328
353
|
export async function runGate(config) {
|
|
329
354
|
const scenarios = config.scenarios;
|
|
330
|
-
const opts = {N: config.N || 200000, k: config.k || 8};
|
|
355
|
+
const opts = {N: config.N || 200000, k: config.k || 8, flushMs: config.flushMs};
|
|
331
356
|
const thresholds = {
|
|
332
357
|
maxScavenges: config.maxScavenges !== undefined ? config.maxScavenges : 2,
|
|
333
358
|
maxRetainedKB: config.maxRetainedKB !== undefined ? config.maxRetainedKB : 64,
|
|
@@ -403,3 +428,230 @@ export async function runGate(config) {
|
|
|
403
428
|
}
|
|
404
429
|
return {passed: passed, results: results};
|
|
405
430
|
}
|
|
431
|
+
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
// suiteGate -- SPP stream-fed budgets (v1.1)
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
// lite-perf-gate does NOT import @zakkster/lite-scope. Probes, gates, and
|
|
436
|
+
// consumers are coupled by the Scope Probe Protocol (SPP v1 -- PROTOCOL.md
|
|
437
|
+
// in lite-scope), never by packages. The two protocol facts used here:
|
|
438
|
+
// - a record is 4 numbers [packed, t, a, b], where
|
|
439
|
+
// packed = (streamId << 16 | opcode) >>> 0, both u16;
|
|
440
|
+
// - opcode 0x0F01 (CONT) is a wide-record continuation and is never a
|
|
441
|
+
// budget target. Budgets therefore see the base record's t/a/b slots;
|
|
442
|
+
// extended CONT payload slots are out of scope for v1.1 (a lite-scope
|
|
443
|
+
// side bridge can pre-reduce wide records if that ever gates).
|
|
444
|
+
// suiteGate never touches process exit codes: runner semantics (0 pass /
|
|
445
|
+
// 1 regression / 3 recapture in the VersionMatrix scripts, 0/1/2 in
|
|
446
|
+
// runGate) stay in the runner layer. All threshold comparison is delegated
|
|
447
|
+
// to verdict() -- one comparison authority, per-budget.
|
|
448
|
+
|
|
449
|
+
var SPP_OP_CONT = 0x0F01;
|
|
450
|
+
|
|
451
|
+
var SUITE_REDUCES = {count: 1, sum: 1, max: 1, mean: 1, last: 1};
|
|
452
|
+
var SUITE_SLOTS = {t: 1, a: 2, b: 3};
|
|
453
|
+
|
|
454
|
+
function suiteMatcher(b, i) {
|
|
455
|
+
if (b.packed !== undefined) {
|
|
456
|
+
if (!Number.isInteger(b.packed) || b.packed < 0 || b.packed > 0xFFFFFFFF) {
|
|
457
|
+
throw new RangeError('suiteGate: budget[' + i + '] packed must be a u32');
|
|
458
|
+
}
|
|
459
|
+
return {exact: b.packed >>> 0, op: -1};
|
|
460
|
+
}
|
|
461
|
+
if (b.op === undefined || !Number.isInteger(b.op) || b.op < 0 || b.op > 0xFFFF) {
|
|
462
|
+
throw new RangeError('suiteGate: budget[' + i + '] needs a u16 op (with optional stream), or packed');
|
|
463
|
+
}
|
|
464
|
+
if (b.stream !== undefined) {
|
|
465
|
+
if (!Number.isInteger(b.stream) || b.stream < 0 || b.stream > 0xFFFF) {
|
|
466
|
+
throw new RangeError('suiteGate: budget[' + i + '] stream must be a u16');
|
|
467
|
+
}
|
|
468
|
+
return {exact: ((b.stream << 16) | b.op) >>> 0, op: -1};
|
|
469
|
+
}
|
|
470
|
+
return {exact: -1, op: b.op}; // op-only: matches the op on any stream
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Evaluate SPP stream records against numeric budgets. Pure reduction plus
|
|
475
|
+
* per-budget delegation to verdict(); no measurement, no exit codes, no
|
|
476
|
+
* record emission (callers bridge verdicts to GATE_VERDICT meta records).
|
|
477
|
+
*
|
|
478
|
+
* @param {object} config
|
|
479
|
+
* @param {Float64Array | { forEach: (cb: (packed: number, t: number, a: number, b: number) => void) => void }} config.source
|
|
480
|
+
* A contiguous SPP slab (length divisible by 4) or any forEach-style
|
|
481
|
+
* record source (e.g. a lite-scope memory sink).
|
|
482
|
+
* @param {Array<{
|
|
483
|
+
* name: string,
|
|
484
|
+
* packed?: number, stream?: number, op?: number,
|
|
485
|
+
* slot?: 't' | 'a' | 'b',
|
|
486
|
+
* reduce?: 'count' | 'sum' | 'max' | 'mean' | 'last',
|
|
487
|
+
* max: number
|
|
488
|
+
* }>} config.budgets
|
|
489
|
+
* slot defaults to 'a', reduce to 'max'. Budgets that match zero records
|
|
490
|
+
* reduce to 0 (count 0 is reported so callers can tell silence from luck).
|
|
491
|
+
* @param {string} [config.name='suite-gate']
|
|
492
|
+
* @returns {{
|
|
493
|
+
* name: string, pass: boolean, reasons: string[],
|
|
494
|
+
* budgets: Array<{ name: string, value: number, count: number, max: number, pass: boolean, reasons: string[] }>
|
|
495
|
+
* }}
|
|
496
|
+
*/
|
|
497
|
+
export function suiteGate(config) {
|
|
498
|
+
if (!config || typeof config !== 'object') {
|
|
499
|
+
throw new TypeError('suiteGate: expects a config object');
|
|
500
|
+
}
|
|
501
|
+
const source = config.source;
|
|
502
|
+
const budgets = config.budgets;
|
|
503
|
+
if (!Array.isArray(budgets) || budgets.length === 0) {
|
|
504
|
+
throw new TypeError('suiteGate: budgets must be a non-empty array');
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const n = budgets.length;
|
|
508
|
+
const match = new Array(n);
|
|
509
|
+
const slot = new Array(n);
|
|
510
|
+
const reduce = new Array(n);
|
|
511
|
+
const seen = {};
|
|
512
|
+
for (let i = 0; i < n; i++) {
|
|
513
|
+
const b = budgets[i];
|
|
514
|
+
if (!b || typeof b.name !== 'string' || b.name.length === 0) {
|
|
515
|
+
throw new TypeError('suiteGate: budget[' + i + '] needs a non-empty name');
|
|
516
|
+
}
|
|
517
|
+
if (seen[b.name]) throw new RangeError('suiteGate: duplicate budget name "' + b.name + '"');
|
|
518
|
+
seen[b.name] = 1;
|
|
519
|
+
if (typeof b.max !== 'number' || !isFinite(b.max)) {
|
|
520
|
+
throw new RangeError('suiteGate: budget "' + b.name + '" needs a finite numeric max');
|
|
521
|
+
}
|
|
522
|
+
const sl = b.slot === undefined ? 'a' : b.slot;
|
|
523
|
+
if (SUITE_SLOTS[sl] === undefined) {
|
|
524
|
+
throw new RangeError('suiteGate: budget "' + b.name + '" slot must be t, a, or b');
|
|
525
|
+
}
|
|
526
|
+
const rd = b.reduce === undefined ? 'max' : b.reduce;
|
|
527
|
+
if (SUITE_REDUCES[rd] === undefined) {
|
|
528
|
+
throw new RangeError('suiteGate: budget "' + b.name + '" reduce must be count, sum, max, mean, or last');
|
|
529
|
+
}
|
|
530
|
+
match[i] = suiteMatcher(b, i);
|
|
531
|
+
slot[i] = SUITE_SLOTS[sl];
|
|
532
|
+
reduce[i] = rd;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const count = new Float64Array(n);
|
|
536
|
+
const sum = new Float64Array(n);
|
|
537
|
+
const maxv = new Float64Array(n);
|
|
538
|
+
const last = new Float64Array(n);
|
|
539
|
+
maxv.fill(-Infinity);
|
|
540
|
+
|
|
541
|
+
function visit(packed, t, a, b) {
|
|
542
|
+
const op = packed & 0xFFFF;
|
|
543
|
+
if (op === SPP_OP_CONT) return;
|
|
544
|
+
for (let i = 0; i < n; i++) {
|
|
545
|
+
const m = match[i];
|
|
546
|
+
if (m.exact >= 0 ? (packed >>> 0) !== m.exact : op !== m.op) continue;
|
|
547
|
+
const val = slot[i] === 1 ? t : slot[i] === 2 ? a : b;
|
|
548
|
+
count[i] += 1;
|
|
549
|
+
sum[i] += val;
|
|
550
|
+
if (val > maxv[i]) maxv[i] = val;
|
|
551
|
+
last[i] = val;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (source && typeof source.forEach === 'function' && !(source instanceof Float64Array)) {
|
|
556
|
+
source.forEach(visit);
|
|
557
|
+
} else if (source instanceof Float64Array) {
|
|
558
|
+
if (source.length % 4 !== 0) {
|
|
559
|
+
throw new RangeError('suiteGate: slab length must be divisible by 4');
|
|
560
|
+
}
|
|
561
|
+
for (let r = 0; r < source.length; r += 4) {
|
|
562
|
+
visit(source[r], source[r + 1], source[r + 2], source[r + 3]);
|
|
563
|
+
}
|
|
564
|
+
} else {
|
|
565
|
+
throw new TypeError('suiteGate: source must be a Float64Array slab or a forEach record source');
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const perBudget = [];
|
|
569
|
+
const reasons = [];
|
|
570
|
+
let pass = true;
|
|
571
|
+
for (let i = 0; i < n; i++) {
|
|
572
|
+
const b = budgets[i];
|
|
573
|
+
let value;
|
|
574
|
+
if (reduce[i] === 'count') value = count[i];
|
|
575
|
+
else if (reduce[i] === 'sum') value = sum[i];
|
|
576
|
+
else if (reduce[i] === 'max') value = count[i] > 0 ? maxv[i] : 0;
|
|
577
|
+
else if (reduce[i] === 'mean') value = count[i] > 0 ? sum[i] / count[i] : 0;
|
|
578
|
+
else value = count[i] > 0 ? last[i] : 0;
|
|
579
|
+
|
|
580
|
+
const counters = {};
|
|
581
|
+
counters[b.name] = value;
|
|
582
|
+
const ct = {};
|
|
583
|
+
ct[b.name] = b.max;
|
|
584
|
+
const v = verdict(
|
|
585
|
+
{name: b.name, minorHi: 0, majorHi: 0, retainedKB_hi: 0, counters_hi: counters},
|
|
586
|
+
{counters: ct}
|
|
587
|
+
);
|
|
588
|
+
if (!v.pass) pass = false;
|
|
589
|
+
for (let ri = 0; ri < v.reasons.length; ri++) reasons.push(v.reasons[ri]);
|
|
590
|
+
perBudget.push({
|
|
591
|
+
name: b.name, value: value, count: count[i], max: b.max,
|
|
592
|
+
pass: v.pass, reasons: v.reasons
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return {
|
|
597
|
+
name: typeof config.name === 'string' ? config.name : 'suite-gate',
|
|
598
|
+
pass: pass,
|
|
599
|
+
reasons: reasons,
|
|
600
|
+
budgets: perBudget
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ---------------------------------------------------------------------------
|
|
605
|
+
// toNDJSON -- verdict output for CI artifacts (v1.2)
|
|
606
|
+
// ---------------------------------------------------------------------------
|
|
607
|
+
|
|
608
|
+
function ndjsonSuiteGate(r, meta, lines) {
|
|
609
|
+
for (let i = 0; i < r.budgets.length; i++) {
|
|
610
|
+
const b = r.budgets[i];
|
|
611
|
+
lines.push(JSON.stringify(Object.assign({}, meta, {
|
|
612
|
+
type: 'budget', gate: r.name, name: b.name,
|
|
613
|
+
value: b.value, count: b.count, max: b.max, pass: b.pass
|
|
614
|
+
})));
|
|
615
|
+
}
|
|
616
|
+
lines.push(JSON.stringify(Object.assign({}, meta, {
|
|
617
|
+
type: 'suite-gate', name: r.name, pass: r.pass, reasons: r.reasons
|
|
618
|
+
})));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function ndjsonMeasure(r, meta, lines) {
|
|
622
|
+
lines.push(JSON.stringify(Object.assign({}, meta, {
|
|
623
|
+
type: 'measure', name: r.name, N: r.N, k: r.k,
|
|
624
|
+
minorLo: r.minorLo, minorHi: r.minorHi,
|
|
625
|
+
majorLo: r.majorLo, majorHi: r.majorHi,
|
|
626
|
+
retainedKB_lo: r.retainedKB_lo, retainedKB_hi: r.retainedKB_hi,
|
|
627
|
+
counters_lo: r.counters_lo, counters_hi: r.counters_hi
|
|
628
|
+
})));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Serialize gate output as NDJSON for CI artifacts: one JSON object per
|
|
633
|
+
* line, budget lines before their suite-gate summary line, trailing
|
|
634
|
+
* newline. Accepts a suiteGate() result, a measure() result, or an array
|
|
635
|
+
* mixing both. `meta` fields (run id, package, version, ...) are merged
|
|
636
|
+
* into every line; core fields win on collision.
|
|
637
|
+
*
|
|
638
|
+
* @param {object | object[]} x
|
|
639
|
+
* @param {object} [meta]
|
|
640
|
+
* @returns {string}
|
|
641
|
+
*/
|
|
642
|
+
export function toNDJSON(x, meta) {
|
|
643
|
+
const rows = Array.isArray(x) ? x : [x];
|
|
644
|
+
const m = meta && typeof meta === 'object' ? meta : {};
|
|
645
|
+
const lines = [];
|
|
646
|
+
for (let i = 0; i < rows.length; i++) {
|
|
647
|
+
const r = rows[i];
|
|
648
|
+
if (r && Array.isArray(r.budgets) && typeof r.pass === 'boolean') {
|
|
649
|
+
ndjsonSuiteGate(r, m, lines);
|
|
650
|
+
} else if (r && typeof r.minorHi === 'number' && typeof r.N === 'number') {
|
|
651
|
+
ndjsonMeasure(r, m, lines);
|
|
652
|
+
} else {
|
|
653
|
+
throw new TypeError('toNDJSON: row ' + i + ' is neither a suiteGate result nor a measure result');
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return lines.join('\n') + '\n';
|
|
657
|
+
}
|
package/README.md
CHANGED
|
@@ -24,6 +24,44 @@ npm install @zakkster/lite-perf-gate
|
|
|
24
24
|
|
|
25
25
|
Scavenge-counting is the only reliable detector of transient allocation. This library packages the methodology into a `node:test`-native harness that validates its own detector on every run.
|
|
26
26
|
|
|
27
|
+
|
|
28
|
+
## suiteGate -- SPP stream-fed budgets (v1.1)
|
|
29
|
+
|
|
30
|
+
Gate live probe streams, not just measured scenarios. `suiteGate()` reads
|
|
31
|
+
SPP records (a `Float64Array` slab or any `forEach(cb(packed, t, a, b))`
|
|
32
|
+
source -- a `@zakkster/lite-scope` memory sink qualifies), reduces each
|
|
33
|
+
budget's metric, and delegates every comparison to `verdict()`:
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
import { suiteGate, toNDJSON } from '@zakkster/lite-perf-gate';
|
|
37
|
+
|
|
38
|
+
const gate = suiteGate({
|
|
39
|
+
name: 'ci-gate',
|
|
40
|
+
source: sink.toSlab(), // or the sink itself (forEach source)
|
|
41
|
+
budgets: [
|
|
42
|
+
{ name: 'gc.pause.max', stream: 2, op: 0x0201, slot: 'a', reduce: 'max', max: 8 },
|
|
43
|
+
{ name: 'leak.orphans', op: 0x0801, reduce: 'count', max: 0 },
|
|
44
|
+
{ name: 'inp.worst', op: 0x0601, slot: 'a', reduce: 'max', max: 200 }
|
|
45
|
+
]
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
process.stdout.write(toNDJSON(gate, { pkg: 'my-lib', run: process.env.CI_RUN }));
|
|
49
|
+
if (!gate.pass) process.exitCode = 1; // exit codes stay in YOUR runner
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
No package coupling: lite-perf-gate speaks the Scope Probe Protocol
|
|
53
|
+
(SPP v1, `PROTOCOL.md` in lite-scope) and never imports lite-scope.
|
|
54
|
+
CONT continuation records are never budget targets (base-record slots
|
|
55
|
+
only in v1.1). `suiteGate` returns structured per-budget verdicts and
|
|
56
|
+
leaves exit codes to the runner layer.
|
|
57
|
+
|
|
58
|
+
## toNDJSON -- CI artifacts (v1.2)
|
|
59
|
+
|
|
60
|
+
One JSON object per line: `budget` lines, then the `suite-gate` summary;
|
|
61
|
+
`measure()` results serialize as `measure` lines. Optional `meta` fields
|
|
62
|
+
(package, run id, versions) merge into every line. Pipe to a file in your
|
|
63
|
+
gate script and attach it as a CI artifact.
|
|
64
|
+
|
|
27
65
|
## Quick start
|
|
28
66
|
|
|
29
67
|
```js
|
|
@@ -134,6 +172,18 @@ Two V8 gotchas the controls handle:
|
|
|
134
172
|
|
|
135
173
|
The default V8 semi-space is 16MB. At 200k iterations of 64-byte objects, you produce ~12MB -- possibly fitting without a single scavenge. `--max-semi-space-size=4` shrinks the young generation to 4MB, forcing scavenges on smaller cumulative allocation and sharpening sensitivity. The four-field positive control (`{x,y,z,w}`) provides margin even without this flag, but the flag is recommended for production gates.
|
|
136
174
|
|
|
175
|
+
## CI tuning
|
|
176
|
+
|
|
177
|
+
Node's `perf_hooks` delivers GC entries to the observer asynchronously. After the hot loop the harness waits before reading the entry count so the observer has time to flush. Default wait is **100ms**, which is safe on a dev machine but can be marginal on saturated CI runners.
|
|
178
|
+
|
|
179
|
+
If you see intermittent zero-scavenge reports on scenarios that clearly allocate:
|
|
180
|
+
|
|
181
|
+
- Bump globally via env var: `PERF_GATE_FLUSH_MS=300 node --test ...`
|
|
182
|
+
- Per-suite: `zgcSuite({ scenarios, flushMs: 300 })`
|
|
183
|
+
- Per-call: `measure(scenario, { flushMs: 500 })`
|
|
184
|
+
|
|
185
|
+
`gc2()` (the harness's forced-collection helper) does one `gc()`, yields a `setImmediate` tick to let `FinalizationRegistry` cleanups run, then does a second `gc()`. Without the yield, WeakRef/finalizer-driven teardown (used across the ecosystem in `lite-cleanup`, `lite-observe`, `lite-floating`) can leave the heap in an intermediate state between the two collections and inflate the retained-heap delta.
|
|
186
|
+
|
|
137
187
|
## License
|
|
138
188
|
|
|
139
189
|
MIT (c) Zahary Shinikchiev
|
package/llms.txt
CHANGED
|
@@ -21,8 +21,9 @@ zgcSuite(config) -- register node:test cases.
|
|
|
21
21
|
config.counters: Record<string, number> -- per-counter max delta.
|
|
22
22
|
config.mustFail: Scenario[] -- must trip the gate (self-test).
|
|
23
23
|
config.N: number (default 200000), config.k: number (default 8).
|
|
24
|
+
config.flushMs: number (default 100, or PERF_GATE_FLUSH_MS env var).
|
|
24
25
|
|
|
25
|
-
measure(scenario, {N, k}) -- raw MeasureResult.
|
|
26
|
+
measure(scenario, {N, k, flushMs}) -- raw MeasureResult.
|
|
26
27
|
verdict(result, thresholds) -- { pass: boolean, reasons: string[] }.
|
|
27
28
|
runGate(config) -- standalone report, same config as zgcSuite.
|
|
28
29
|
|
|
@@ -39,6 +40,19 @@ node --expose-gc --max-semi-space-size=4 --test your.test.mjs
|
|
|
39
40
|
|
|
40
41
|
--expose-gc is required. --max-semi-space-size=4 sharpens sensitivity.
|
|
41
42
|
|
|
43
|
+
## CI tuning
|
|
44
|
+
|
|
45
|
+
perf_hooks delivers GC entries asynchronously. Default 100ms wait after
|
|
46
|
+
hot loop is safe on dev machines but can be marginal on saturated CI
|
|
47
|
+
runners. If scenarios that should allocate report zero scavenges, bump
|
|
48
|
+
flushMs (via option) or PERF_GATE_FLUSH_MS env var to 250-500ms.
|
|
49
|
+
|
|
50
|
+
gc2() yields a setImmediate tick between its two gc() calls so any
|
|
51
|
+
FinalizationRegistry cleanups scheduled by the first run before the
|
|
52
|
+
second. Essential for ecosystems using WeakRef/FinalizationRegistry
|
|
53
|
+
(lite-cleanup / lite-observe / lite-floating) -- without it the two-pass
|
|
54
|
+
GC can leave the heap in an intermediate state and inflate retained deltas.
|
|
55
|
+
|
|
42
56
|
## Why scavenge counting
|
|
43
57
|
|
|
44
58
|
Retained-heap delta misses transient garbage (freed before snapshot).
|
|
@@ -49,3 +63,14 @@ is the only reliable detector of transient allocation in V8.
|
|
|
49
63
|
|
|
50
64
|
Single-file ESM. Imports only node:perf_hooks, node:timers/promises,
|
|
51
65
|
node:test, node:assert/strict.
|
|
66
|
+
|
|
67
|
+
v1.1 suiteGate(config): SPP stream-fed budgets. config.source = Float64Array
|
|
68
|
+
slab or { forEach(cb(packed,t,a,b)) } (lite-scope memory sink). budgets[] =
|
|
69
|
+
{ name, packed? | stream?+op | op, slot: t|a|b (default a), reduce:
|
|
70
|
+
count|sum|max|mean|last (default max), max }. Delegates every comparison to
|
|
71
|
+
verdict(); returns { name, pass, reasons, budgets: [{name, value, count,
|
|
72
|
+
max, pass, reasons}] }. Never sets exit codes; CONT records (op 0x0F01) are
|
|
73
|
+
never budget targets. No lite-scope import: coupled by SPP v1 protocol only.
|
|
74
|
+
v1.2 toNDJSON(x, meta?): NDJSON for CI artifacts; accepts suiteGate result,
|
|
75
|
+
measure result, or array; budget lines then suite-gate summary; meta merged
|
|
76
|
+
per line; trailing newline.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-perf-gate",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
5
5
|
"description": "Zero-GC and performance regression gate for node:test. Scavenge-counting, scaling verdict, detector self-validation. Proves your hot path allocates nothing -- or names what did.",
|
|
6
6
|
"type": "module",
|