Merge main

This commit is contained in:
Chris Tate
2026-10-18 12:24:21 -05:00
13 changed files with 7556 additions and 5429 deletions
+6
View File
@@ -48,6 +48,12 @@ Corpus programs are differential tests against Node: every program runs under No
Before opening a PR that adds or changes corpus, diagnostic, or frontend fixtures, run `pnpm test:ts7 --baselines-only`. This fast completeness check uses the same fixture discovery as the parity suite; it does not type-check or replace behavioral parity. Generate missing preflight/order baselines, review their diagnostics and module order, and run the affected `order-parity.test.ts` batches before pushing. Keep existing baseline entries unchanged unless the corresponding behavior intentionally changes. The full `pnpm test:ts7` command also checks completeness before starting its sweep.
A change that must not cost good code anything, such as a compatibility fix that replaces a refusal, proves it with the zero-cost gate. The gate compiles the benchmark workloads, the self-hosted compiler, and the TypeScript corpus with two built checkouts. It requires byte-identical IR, LLVM, and release executables, and compile time within tolerance. `--allow=<regex>` names the change's pattern signature: programs whose sources match may differ, and the PR lists them. Any other difference fails the gate, which names the first differing function. The gate also enforces the two-tier runtime separation: no executable may gain symbols from `DYNAMIC_TIER_PREFIXES` in `scripts/zero-cost-compare.mjs`, and fully typed benchmarks may link none; extend that list as dynamic-tier units land. `--quick` checks only the benchmarks; `--cachegrind` adds per-function instruction counts for executables that differ. Run it in a Sandbox with both checkouts prepared:
```bash
node scripts/zero-cost-gate.mjs --baseline=/vercel/sandbox/prev --candidate=. --allow='\barguments\b'
```
Test location follows scope:
- Co-locate white-box unit tests with implementation files under
@@ -2,3 +2,4 @@
- **Snapshots:** `t.assert.snapshot` and `t.assert.fileSnapshot` compile, with Node's default serializer, `JSON.stringify(value, null, 2)`, or an `options.serializers` array literal. A compiled test reads the snapshot file Node writes beside the entry file, `<entry file>.snapshot`, at the entry file's path when the program was compiled. Running the binary with Node's flag `--test-update-snapshots` as its first argument writes the snapshot files instead; as in Node, the flag is kept out of `process.argv` and appears in `process.execArgv`. Snapshot files are read in the form Node writes them, `` exports[`key`] = `value`; `` statements. The `snapshot.setDefaultSnapshotSerializers` and `snapshot.setResolveSnapshotPath` module functions throw `SC2020`.
- **TestContext values:** `t.filePath` is the entry file's path when the program was compiled. `t.attempt` is `0`, since a test runs once. `t.passed` is `false` while the body runs and the test's outcome in its `afterEach` and `t.after` hooks. `t.workerId` is `Number(process.env.NODE_TEST_WORKER_ID) || undefined`, as in Node. `t.tags` is `[]`, since the `tags` test option throws `SC2020`; the array is not frozen as Node's is.
- **Suite and hook contexts:** a `describe` body may take its `SuiteContext`, which has `name`, `fullName`, `filePath`, `attempt`, `passed` and `diagnostic`. Hooks may take their context, as in Node: the suite's for `before` and `after`, the test's for `beforeEach`, `afterEach`, `t.before` and `t.after`. The `SuiteContext` `signal` and `log` members, and hook `done` callbacks, throw `SC2020`.
- **Module-level hooks:** `before` hooks outside any suite run at their `before()` call, and the first top-level test waits for the async ones; `after` hooks outside any suite run after the last top-level test. Both get the root's context, named `<root>`. As in Node, a throwing root `before` fails each top-level test with its error, without running its body, and cancels the tests and suites inside each top-level suite. A throwing root `after` reports the entry file as a failed result, and the exit code is unchanged.
@@ -473,7 +473,7 @@ function lowerHook(
return {
kind: "libCall",
fn: "test.hook",
args: [numLit(which, loc), cb, numLit(flags, loc)],
args: [numLit(which, loc), cb, numLit(flags, loc), strLit(atStringOf(expr), loc)],
type: VOID,
loc,
};
@@ -1438,7 +1438,8 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
},
// The suite body takes no parameters or its SuiteContext (flag 2).
"test.suite": { argTypes: [STRING, F64, STRING, null, STRING, F64, F64], result: VOID },
"test.hook": { argTypes: [F64, null, F64], result: VOID },
// The trailing string is the hook call's "file:line:col".
"test.hook": { argTypes: [F64, null, F64, STRING], result: VOID },
"test.sub": {
argTypes: [TESTCTX_T, STRING, F64, STRING, null, F64, STRING, F64, F64, F64],
result: { kind: "promise", inner: VOID },
+141 -6
View File
@@ -80,6 +80,7 @@ enum {
typedef struct {
ScrClosure *cb;
int flags;
ScrStr *at; /* "file:line:col" of the hook call, owned; NULL for t.* hooks */
} ScrTestHook;
typedef struct {
@@ -123,6 +124,7 @@ struct ScrTestCtx {
ScrPromise *slot; /* owned; the parent waiting for its next subtest to finish */
struct ScrMockTimers *mock_timers; /* t.mock.timers, made on first use; freed at exit */
bool passed; /* t.passed: set when the body passes, as Node's pass() does */
bool listed; /* a suite listed in the failing section (a failing root before) */
};
static SCR_TL ScrTestCtx *scr_test_root = NULL;
@@ -136,6 +138,18 @@ static SCR_TL long scr_test_ct_tests = 0, scr_test_ct_suites = 0, scr_test_ct_pa
scr_test_ct_todo = 0;
static SCR_TL double scr_test_t0 = 0;
static SCR_TL bool scr_test_suite_hook_failed = false;
/* The promises of async module-level before hooks, which the runner waits
* for before the first top-level test. */
static SCR_TL ScrPromise **scr_test_root_pending = NULL;
static SCR_TL size_t scr_test_root_npending = 0, scr_test_root_pending_cap = 0;
/* A module-level before hook threw or rejected: the first failure is kept
* for the top-level tests and suites, later ones are dropped. */
static void scr_test_root_before_failed(ScrTestCtx *root) {
if (!scr_exc_pending()) return;
if (!root->before_err) root->before_err = scr_exc_take();
else scr_exc_clear();
}
/* Above 0 while subtests run concurrently: result lines are held, and the
* outermost concurrent parent prints its subtrees in declaration order,
* as Node's reporter does, once they all finish. */
@@ -164,7 +178,10 @@ ScrTestCtx *scr_testctx_retain(ScrTestCtx *t) {
}
static void scr_test_hooks_free(ScrTestHooks *h) {
for (size_t i = 0; i < h->n; i++) scr_closure_release(h->v[i].cb);
for (size_t i = 0; i < h->n; i++) {
scr_closure_release(h->v[i].cb);
scr_str_release(h->v[i].at);
}
free(h->v);
h->v = NULL;
h->n = h->cap = 0;
@@ -277,7 +294,9 @@ static void scr_test_indent(int depth) {
/* "(1.234567ms)" — durations print microsecond-rounded (Node's hrtime
* granularity), not with double noise. */
/* NaN prints nothing: a test cancelled before it started has no duration. */
static void scr_test_print_dur(double ms) {
if (isnan(ms)) return;
char num[32];
fputs(" (", stdout);
size_t n = scr_f64_to_str(round(ms * 1e6) / 1e6, num);
@@ -409,10 +428,14 @@ static void scr_test_mocks_restore(ScrTestHooks *mocks, bool forget) {
/* `ctx` is the context a hook that takes one receives: the suite or test
* for its own before/after hooks, the test for the beforeEach/afterEach
* chain. */
static void scr_test_run_hooks_of(ScrTestCtx *owner, int which, ScrTestCtx *ctx) {
for (size_t i = 0; i < owner->hooks[which].n && !scr_exc_pending(); i++) {
scr_test_call(owner->hooks[which].v[i].cb, owner->hooks[which].v[i].flags, ctx, NULL);
static ScrTestHook *scr_test_run_hooks_of(ScrTestCtx *owner, int which, ScrTestCtx *ctx) {
if (scr_exc_pending()) return NULL; /* an earlier hook in the chain threw */
for (size_t i = 0; i < owner->hooks[which].n; i++) {
ScrTestHook *h = &owner->hooks[which].v[i];
scr_test_call(h->cb, h->flags, ctx, NULL);
if (scr_exc_pending()) return h; /* the hook that threw */
}
return NULL;
}
/* Returns whether the chain has any `which` hooks. */
@@ -559,6 +582,36 @@ static void scr_test_finish_concurrent(ScrTestCtx *t) {
for (size_t i = 0; i < t->nchildren; i++) scr_test_report_tree(t->children[i]);
}
/* A failing root before hook (Node): each top-level test fails with its
* error without running its body or beforeEach hooks, though its
* afterEach hooks run; a top-level suite fails with it, and the tests and
* suites in it are cancelled before they start. */
static void scr_test_cancel_unrun(ScrTestCtx *c) {
for (size_t i = 0; i < c->nchildren; i++) scr_test_cancel_unrun(c->children[i]);
static const char msg[] = "test did not finish before its parent and was cancelled";
scr_throw_str(scr_str_new(msg, sizeof msg - 1));
c->err = scr_exc_take();
c->listed = true;
c->dur_ms = NAN;
c->state = SCR_TS_CANCELLED;
scr_test_report_result(c);
}
static void scr_test_fail_unrun(ScrTestCtx *c, ScrCaught *err) {
double start = scr_now_ms();
c->err = scr_caught_retain(err);
c->listed = true;
if (c->is_suite) {
for (size_t i = 0; i < c->nchildren; i++) scr_test_cancel_unrun(c->children[i]);
} else {
scr_test_run_each_hooks(c, SCR_HOOK_AFTER_EACH, false);
if (scr_exc_pending()) scr_exc_clear();
}
c->dur_ms = scr_now_ms() - start;
c->state = SCR_TS_FAIL;
scr_test_report_result(c);
}
static void scr_test_run_children(ScrTestCtx *t) {
/* `only` at this level: when a child is marked, or contains a marked
* test or suite, only those children run. The others are dropped entirely
@@ -572,6 +625,10 @@ static void scr_test_run_children(ScrTestCtx *t) {
/* Subtests t.test already ran INLINE at their call site, or started on
* their own fiber. */
if (c->state != SCR_TS_PENDING || c->done) continue;
if (t == scr_test_root && t->before_err) {
scr_test_fail_unrun(c, t->before_err);
continue;
}
if (t->concurrency > 1) scr_test_start(c);
else scr_test_run_node(c);
}
@@ -813,7 +870,7 @@ static void scr_test_print_failures(ScrTestCtx *t, bool *any) {
for (size_t i = 0; i < t->nchildren; i++) {
ScrTestCtx *c = t->children[i];
scr_test_print_failures(c, any);
if (!c->is_suite &&
if ((!c->is_suite || c->listed) &&
(c->state == SCR_TS_FAIL || c->state == SCR_TS_TODO_FAIL ||
c->state == SCR_TS_CANCELLED) &&
c->err) {
@@ -1404,7 +1461,41 @@ static void scr_test_runner_entry(ScrFiber *self, void *argpack) {
* the first test only after the file's synchronous body completes. */
scr_await_hop();
ScrTestCtx *root = scr_test_root;
/* The root's before hooks ran at their before() calls; the first test
* waits for the async ones. */
for (size_t i = 0; i < scr_test_root_npending; i++) {
scr_await_void(scr_test_root_pending[i]);
scr_test_root_before_failed(root);
scr_promise_release(scr_test_root_pending[i]);
}
free(scr_test_root_pending);
scr_test_root_pending = NULL;
scr_test_root_npending = scr_test_root_pending_cap = 0;
scr_test_run_children(root);
/* Its after hooks: a throw reports the root as "✖ <file>" with the hook's
* location, after the top-level results, without counting it or changing
* the exit code, as Node does. */
double after_start = scr_now_ms();
ScrTestHook *after_failed = scr_test_run_hooks_of(root, SCR_HOOK_AFTER, root);
if (scr_exc_pending()) {
root->err = scr_exc_take();
root->dur_ms = scr_now_ms() - after_start;
root->state = SCR_TS_FAIL;
if (after_failed && after_failed->at) {
root->at = scr_str_retain(after_failed->at);
/* The name is the file: the location without its ":line:col". */
size_t end = root->at->len;
for (int colons = 0; end > 0 && colons < 2; end--) {
if (root->at->data[end - 1] == ':') colons++;
}
scr_str_release(root->name);
root->name = scr_str_new(root->at->data, end);
}
fputs("\xE2\x9C\x96 ", stdout);
scr_test_print_str(root->name);
scr_test_print_dur(root->dur_ms);
fputc('\n', stdout);
}
scr_test_run_done = true;
scr_snap_write_all();
scr_test_count(root);
@@ -1418,6 +1509,10 @@ static void scr_test_runner_entry(ScrFiber *self, void *argpack) {
scr_test_info_line("duration_ms", round((scr_now_ms() - scr_test_t0) * 1e6) / 1e6);
bool any = false;
scr_test_print_failures(root, &any);
if (root->err) {
if (!any) fputs("\n\xE2\x9C\x96 failing tests:\n", stdout);
scr_test_print_failure(root);
}
/* The tree is done reporting: release it (test bodies may still hold
* their TestContext refs — the boxes stay alive through those). */
scr_test_reg_parent = NULL;
@@ -1534,7 +1629,41 @@ void scr_test_suite(ScrStr *name /*borrowed*/, double mode, ScrStr *msg /*borrow
scr_closure_release(cb);
}
void scr_test_hook(double which, ScrClosure *cb /*moves*/, double flags) {
/* A module-level before hook runs at its before() call, as Node runs the
* hooks of its already-started root at once: the synchronous part runs
* now, on the caller's stack, and the runner waits for an async hook's
* promise before the first top-level test. The first failure fails the
* top-level tests and suites (scr_test_fail_unrun). */
static void scr_test_root_before_now(ScrTestCtx *root, ScrClosure *cb, int flags) {
bool takes_ctx = (flags & SCR_TEST_F_CTX) != 0;
if (flags & SCR_TEST_F_ASYNC) {
ScrPromise *p =
takes_ctx
? ((ScrPromise * (*)(ScrClosure *, ScrTestCtx *)) cb->fn)(cb, scr_testctx_retain(root))
: ((ScrPromise * (*)(ScrClosure *)) cb->fn)(cb);
if (p && !scr_exc_pending()) {
if (scr_test_root_npending == scr_test_root_pending_cap) {
scr_test_root_pending_cap = scr_test_root_pending_cap ? scr_test_root_pending_cap * 2 : 2;
scr_test_root_pending = realloc(scr_test_root_pending,
scr_test_root_pending_cap * sizeof *scr_test_root_pending);
if (!scr_test_root_pending) scr_test_oom();
}
/* The runner observes it: a rejection is the hook's failure, not an
* unhandled rejection. */
scr_promise_mark_handled(p);
scr_test_root_pending[scr_test_root_npending++] = p;
return;
}
scr_promise_release(p);
} else if (takes_ctx) {
((void (*)(ScrClosure *, ScrTestCtx *))cb->fn)(cb, scr_testctx_retain(root));
} else {
((void (*)(ScrClosure *))cb->fn)(cb);
}
scr_test_root_before_failed(root);
}
void scr_test_hook(double which, ScrClosure *cb /*moves*/, double flags, ScrStr *at) {
scr_test_ensure_root();
ScrTestCtx *owner = scr_test_parent_get();
ScrTestHooks *h = &owner->hooks[(int)which];
@@ -1545,7 +1674,10 @@ void scr_test_hook(double which, ScrClosure *cb /*moves*/, double flags) {
}
h->v[h->n].cb = cb;
h->v[h->n].flags = (int)flags;
h->v[h->n].at = at && at->len > 0 ? scr_str_retain(at) : NULL;
h->n++;
if ((int)which == SCR_HOOK_BEFORE && owner == scr_test_root)
scr_test_root_before_now(owner, cb, (int)flags);
}
/* t.test(...): registers under `t` and — because subtests run inline on
@@ -1620,6 +1752,7 @@ void scr_test_ctx_hook(ScrTestCtx *t, double which, ScrClosure *cb /*moves*/, do
}
h->v[h->n].cb = cb;
h->v[h->n].flags = (int)flags;
h->v[h->n].at = NULL;
h->n++;
}
@@ -1631,6 +1764,7 @@ static void scr_test_mocks_add(ScrTestHooks *h, ScrClosure *restore /*moves*/) {
}
h->v[h->n].cb = restore;
h->v[h->n].flags = 0;
h->v[h->n].at = NULL;
h->n++;
}
@@ -2172,6 +2306,7 @@ void scr_test_ctx_diagnostic(ScrTestCtx *t, ScrStr *msg /*borrowed*/) {
}
ScrStr *scr_test_ctx_name(ScrTestCtx *t) {
if (!t->name && !t->parent) return scr_str_new("<root>", 6); /* Node's root test */
return t->name ? scr_str_retain(t->name) : scr_str_new("", 0);
}
+4 -2
View File
@@ -33,8 +33,10 @@ void scr_test_register(ScrStr *name, double mode, ScrStr *msg, ScrClosure *cb, d
void scr_test_suite(ScrStr *name, double mode, ScrStr *msg, ScrClosure *cb, ScrStr *at,
double flags, double concurrency);
/* before/after/beforeEach/afterEach on the CURRENT registration parent
* (`which`: 0 before, 1 after, 2 beforeEach, 3 afterEach). */
void scr_test_hook(double which, ScrClosure *cb, double flags);
* (`which`: 0 before, 1 after, 2 beforeEach, 3 afterEach); `at` is the
* hook call's "file:line:col". The root's before and after hooks run
* around the top-level tests. */
void scr_test_hook(double which, ScrClosure *cb, double flags, ScrStr *at);
/* t.test(...): registers under `t`, runs the subtest INLINE on the
* runner fiber, and returns the settled promise the `await` consumes (+1). */
ScrPromise *scr_test_sub(ScrTestCtx *t, ScrStr *name, double mode, ScrStr *msg, ScrClosure *cb, double flags, ScrStr *at,
+490
View File
@@ -0,0 +1,490 @@
/* Comparison logic for scripts/zero-cost-gate.mjs, kept free of process
* orchestration so tests/harness/zero-cost-gate.test.ts can pin it.
*
* Artifacts are compared as bytes first. Only when they differ are they split
* into named units (IR functions, LLVM functions, executable symbols) to name
* the first unit that differs. The self-hosted compiler's IR and LLVM exceed
* V8's maximum string length, so splitting works on Buffers and hashes each
* unit instead of decoding the artifact. */
import { createHash } from "node:crypto";
const hash = (bytes) => createHash("sha1").update(bytes).digest("hex");
/** Named units of an artifact: `order` lists unit keys in artifact order,
* `hashes` maps each key to a content hash, and `module` holds the
* module-level remainder (IR header, LLVM globals and declarations) as a list
* of comparable pieces with their labels. Repeated names get `#n` suffixes. */
function unitsBuilder() {
const order = [];
const hashes = new Map();
const seen = new Map();
return {
order,
hashes,
module: [],
add(name, bytes) {
const count = seen.get(name) ?? 0;
seen.set(name, count + 1);
const key = count === 0 ? name : `${name}#${count + 1}`;
order.push(key);
hashes.set(key, hash(bytes));
},
};
}
/* ── IR (compact or readable JSON) ─────────────────────────────────────── */
function skipString(bytes, index) {
for (index++; index < bytes.length; index++) {
if (bytes[index] === 0x5c) index++;
else if (bytes[index] === 0x22) return index + 1;
}
return bytes.length;
}
/** Byte ranges of the root object's `"functions"` array elements, or null. */
export function irFunctionSpans(bytes) {
const key = Buffer.from('"functions"');
let depth = 0;
for (let index = 0; index < bytes.length;) {
const byte = bytes[index];
if (byte === 0x22) {
const end = skipString(bytes, index);
if (depth === 1 && end - index === key.length && bytes.subarray(index, end).equals(key)) {
let next = end;
while (next < bytes.length && bytes[next] !== 0x3a) next++;
next++;
while (next < bytes.length && bytes[next] !== 0x5b && bytes[next] <= 0x20) next++;
if (bytes[next] === 0x5b) return elementSpans(bytes, next);
}
index = end;
continue;
}
if (byte === 0x7b || byte === 0x5b) depth++;
else if (byte === 0x7d || byte === 0x5d) depth--;
index++;
}
return null;
}
function elementSpans(bytes, open) {
const spans = [];
let depth = 0;
let start = -1;
for (let index = open + 1; index < bytes.length;) {
const byte = bytes[index];
if (byte === 0x22) {
if (start < 0) start = index;
index = skipString(bytes, index);
continue;
}
if (byte === 0x7b || byte === 0x5b) {
if (start < 0) start = index;
depth++;
} else if (byte === 0x7d || byte === 0x5d) {
if (depth === 0) {
if (start >= 0) spans.push([start, trimEnd(bytes, index)]);
return { open, close: index, spans };
}
depth--;
} else if (byte === 0x2c && depth === 0) {
spans.push([start, trimEnd(bytes, index)]);
start = -1;
} else if (start < 0 && byte > 0x20) start = index;
index++;
}
return null;
}
function trimEnd(bytes, end) {
while (end > 0 && bytes[end - 1] <= 0x20) end--;
return end;
}
/** The `name` of one serialized IR function. Functions serialize `name`
* first; anything else is parsed (a single function is small). */
function irFunctionName(bytes, [start, end]) {
const head = bytes.toString("utf8", start, Math.min(end, start + 4096));
const match = /^\{\s*"name"\s*:\s*("(?:[^"\\]|\\.)*")/.exec(head);
if (match) return JSON.parse(match[1]);
const fn = JSON.parse(bytes.toString("utf8", start, end));
return typeof fn?.name === "string" ? fn.name : "<unnamed>";
}
/** IR units: one per function, plus the module header split by top-level
* key (and, for arrays, by element) so header differences are named too. */
export function irUnits(bytes) {
const units = unitsBuilder();
const located = irFunctionSpans(bytes);
let header;
if (located === null) header = JSON.parse(bytes.toString("utf8"));
else {
for (const span of located.spans)
units.add(irFunctionName(bytes, span), bytes.subarray(span[0], span[1]));
header = JSON.parse(
bytes.toString("utf8", 0, located.open + 1) + bytes.toString("utf8", located.close),
);
}
for (const [key, value] of Object.entries(header)) {
if (key === "functions" && located !== null) continue;
if (Array.isArray(value))
value.forEach((item, index) => {
const name = item && typeof item === "object" && "name" in item ? `:${item.name}` : "";
units.module.push({ label: `${key}[${index}${name}]`, text: JSON.stringify(item) });
});
else units.module.push({ label: key, text: JSON.stringify(value) });
}
return units;
}
/* ── LLVM text ─────────────────────────────────────────────────────────── */
const DEFINE_NAME = /@("(?:[^"\\]|\\.)*"|[-\w$.]+)\s*\(/;
/** LLVM units: one per `define … { … }` body; every other line is
* module-level text compared line by line. */
export function llvmUnits(bytes) {
const units = unitsBuilder();
let fnStart = -1;
let fnName = "";
let lineNumber = 0;
for (let start = 0; start < bytes.length;) {
let end = bytes.indexOf(10, start);
if (end < 0) end = bytes.length;
lineNumber++;
if (fnStart >= 0) {
if (end - start === 1 && bytes[start] === 0x7d) {
units.add(fnName, bytes.subarray(fnStart, end));
fnStart = -1;
}
} else if (bytes[start] === 0x64 && bytes.toString("latin1", start, start + 7) === "define ") {
const line = bytes.toString("utf8", start, end);
fnName = DEFINE_NAME.exec(line)?.[1] ?? `<line ${lineNumber}>`;
fnStart = start;
if (line.endsWith("}")) {
units.add(fnName, bytes.subarray(fnStart, end));
fnStart = -1;
}
} else if (end > start) {
units.module.push({ label: `line ${lineNumber}`, text: bytes.toString("utf8", start, end) });
}
start = end + 1;
}
return units;
}
/* ── unit comparison ───────────────────────────────────────────────────── */
const clip = (text, width = 160) => (text.length > width ? `${text.slice(0, width)}…` : text);
/** Compare two unit sets. `first` names the first difference in baseline
* order: a changed or removed function, then an added one, then the first
* differing module-level piece. */
export function compareUnits(baseline, candidate) {
const changed = [];
const removed = [];
for (const key of baseline.order) {
const other = candidate.hashes.get(key);
if (other === undefined) removed.push(key);
else if (other !== baseline.hashes.get(key)) changed.push(key);
}
const added = candidate.order.filter((key) => !baseline.hashes.has(key));
let module = null;
const count = Math.max(baseline.module.length, candidate.module.length);
for (let index = 0; index < count && module === null; index++) {
const a = baseline.module[index];
const b = candidate.module[index];
if (a?.text !== b?.text)
module = {
label: (a ?? b).label,
baseline: a === undefined ? null : clip(a.text),
candidate: b === undefined ? null : clip(b.text),
};
}
const firstFunction = baseline.order.find(
(key) => !candidate.hashes.has(key) || candidate.hashes.get(key) !== baseline.hashes.get(key),
);
let first = null;
if (firstFunction !== undefined)
first = `${changed.includes(firstFunction) ? "changed" : "removed"} ${firstFunction}`;
else if (added.length > 0) first = `added ${added[0]}`;
else if (module !== null) first = `module ${module.label}`;
else if (baseline.order.join("\n") !== candidate.order.join("\n")) first = "function order";
return {
identical: first === null,
first,
changed,
added,
removed,
module,
functions: { baseline: baseline.order.length, candidate: candidate.order.length },
};
}
/** Byte-compare an artifact and, when it differs, name what changed. */
export function compareArtifact(kind, baseline, candidate) {
if (baseline.equals(candidate)) return { identical: true };
const split = kind === "ir" ? irUnits : llvmUnits;
const result = compareUnits(split(baseline), split(candidate));
// Equal units with unequal bytes means formatting outside the units.
return { ...result, identical: false, first: result.first ?? "bytes outside named units" };
}
/* ── executables ───────────────────────────────────────────────────────── */
/** The output of `nm` for `binary`, from its spawnSync result. Throws when
* nm could not start, was killed by a signal, or exited with a non-zero
* status, because the symbol checks would otherwise read an empty list. */
export function nmOutput(binary, result) {
if (result.error) throw new Error(`nm ${binary}: ${result.error.message}`);
if (result.signal) throw new Error(`nm ${binary}: killed by ${result.signal}`);
if (result.status !== 0) {
const detail = typeof result.stderr === "string" ? result.stderr.trim() : "";
throw new Error(
`nm ${binary}: exited with status ${result.status}${detail ? `: ${detail}` : ""}`,
);
}
return result.stdout;
}
/** Defined symbols from `nm -S` output (GNU/LLVM nm, address size type
* name) or `nm` output without sizes (Apple, address type name). */
export function parseNm(text) {
const symbols = [];
for (const line of text.split("\n")) {
const fields = line.trim().split(/\s+/);
if (fields.length === 4 && /^[0-9a-f]+$/i.test(fields[1]))
symbols.push({
address: BigInt(`0x${fields[0]}`),
size: BigInt(`0x${fields[1]}`),
type: fields[2],
name: fields[3],
});
else if (fields.length === 3 && /^[0-9a-f]+$/i.test(fields[0]))
symbols.push({
address: BigInt(`0x${fields[0]}`),
size: null,
type: fields[1],
name: fields[2],
});
}
return symbols;
}
/** The virtual address of file offset `offset` in an ELF64 little-endian
* executable, from its PT_LOAD program headers, or null. */
export function elfOffsetToAddress(bytes, offset) {
if (bytes.length < 64 || bytes.readUInt32BE(0) !== 0x7f454c46 || bytes[4] !== 2) return null;
const phoff = Number(bytes.readBigUInt64LE(0x20));
const phentsize = bytes.readUInt16LE(0x36);
const phnum = bytes.readUInt16LE(0x38);
for (let index = 0; index < phnum; index++) {
const at = phoff + index * phentsize;
if (bytes.readUInt32LE(at) !== 1) continue;
const fileOffset = bytes.readBigUInt64LE(at + 8);
const address = bytes.readBigUInt64LE(at + 16);
const fileSize = bytes.readBigUInt64LE(at + 32);
const target = BigInt(offset);
if (target >= fileOffset && target < fileOffset + fileSize)
return address + (target - fileOffset);
}
return null;
}
/** Name what differs between two executables: symbols added, removed or
* resized, then the symbol holding the first differing byte. */
export function compareExecutables(baseline, candidate, baselineNm, candidateNm) {
if (baseline.equals(candidate)) return { identical: true };
const before = new Map(parseNm(baselineNm).map((s) => [s.name, s]));
const after = new Map(parseNm(candidateNm).map((s) => [s.name, s]));
const removed = [...before.keys()].filter((name) => !after.has(name));
const added = [...after.keys()].filter((name) => !before.has(name));
const resized = [...before.values()]
.filter((s) => after.has(s.name) && s.size !== null && after.get(s.name).size !== s.size)
.map((s) => ({
name: s.name,
baseline: Number(s.size),
candidate: Number(after.get(s.name).size),
}));
let offset = 0;
const limit = Math.min(baseline.length, candidate.length);
while (offset < limit && baseline[offset] === candidate[offset]) offset++;
const address = elfOffsetToAddress(baseline, offset);
let holder = null;
if (address !== null)
for (const s of before.values())
if (s.size !== null && address >= s.address && address < s.address + s.size) holder = s.name;
const first =
resized[0] !== undefined
? `resized ${resized[0].name}`
: removed[0] !== undefined
? `removed ${removed[0]}`
: added[0] !== undefined
? `added ${added[0]}`
: holder !== null
? `bytes in ${holder}`
: `byte offset ${offset}`;
return { identical: false, first, offset, holder, added, removed, resized };
}
/* ── dynamic-tier symbols (two-tier design, invariant I3) ────────────────── */
/** Runtime symbol prefixes that belong to the dynamic tier: code a fully
* typed program has no reason to reach. A typed program's release binary
* must define none of them. Extend this list as dynamic-tier units land
* (S3/S4 add their scr_js_* families) and as legacy ScrDyn families move.
* The ScrDyn prefixes are the object-model entry points the two-tier audit
* (results/design-two-tier/DESIGN.md §3.2-3.3) lists as dynamic-only. */
export const DYNAMIC_TIER_PREFIXES = [
// Dynamic-tier runtime units (scr_js_value, scr_js_object, ...), from S3.
"scr_js_",
// ScrDyn property bags: reads, writes, lookup and key indexing.
"scr_dyn_obj_",
"scr_dyn_find_",
"scr_dyn_index_",
"scr_dyn_key_",
"sc_dyn_key_",
// Descriptors, own-property reflection, prototypes and proxies.
"scr_dyn_property_",
"scr_dyn_define_",
"scr_dyn_descriptor_",
"scr_dyn_own_",
"scr_dyn_get_own_",
"scr_dyn_prototype",
"scr_dyn_proxy_",
// Dynamic calls and the dynamic receiver read.
"scr_dyn_call",
"scr_dyn_apply",
"scr_dyn_invoke",
"scr_dyn_construct",
"scr_dyn_function_apply",
"scr_dyn_fn_get",
"scr_dyn_this_get",
// Dynamic operators and coercions.
"scr_dyn_add_",
"scr_dyn_arithmetic",
"scr_dyn_to_numeric",
"scr_dyn_number_coerce",
"scr_dyn_string_coerce",
];
/** Symbols under those prefixes that typed binaries reach today through a
* leak the two-tier design schedules for removal (S1). Remove an entry when
* its leak closes. */
export const DYNAMIC_TIER_EXEMPT = new Map([
// §3.3 L10: scr_dyn_release's dispose switch, linked whenever any dyn
// value can be released.
["scr_dyn_obj_drop_keys", "L10"],
]);
/** The dynamic-tier symbols among `names`, sorted. */
export function dynamicTierSymbols(names) {
return [...new Set(names)]
.filter(
(name) =>
DYNAMIC_TIER_PREFIXES.some((prefix) => name.startsWith(prefix)) &&
!DYNAMIC_TIER_EXEMPT.has(name),
)
.sort();
}
/* ── cachegrind ────────────────────────────────────────────────────────── */
/** Background threads whose instruction counts depend on scheduling: the
* allocator's prefault thread. */
export const CACHEGRIND_EXCLUDED = new Set(["scr_sa_pf_main", "scr_sa_prefault"]);
/** Per-function instruction counts (Ir) from a cachegrind output file,
* restricted to `keep` (the executable's own defined symbols). Dynamic
* linker and libc/pthread functions are outside `keep`; their counts vary
* run to run with lazy binding and thread wakeups. */
export function cachegrindCounts(text, keep) {
const counts = new Map();
let events = null;
let current = null;
for (const line of text.split("\n")) {
if (line.startsWith("events:")) events = line.slice(7).trim().split(/\s+/);
else if (line.startsWith("fn=")) {
const name = line.slice(3).trim();
current = keep.has(name) && !CACHEGRIND_EXCLUDED.has(name) ? name : null;
} else if (line.startsWith("fl=") || line.startsWith("fi=") || line.startsWith("fe=")) continue;
else if (current !== null && /^\d/.test(line)) {
const fields = line.trim().split(/\s+/);
const column = events === null ? 0 : Math.max(0, events.indexOf("Ir"));
const ir = Number(fields[column + 1] ?? 0);
counts.set(current, (counts.get(current) ?? 0) + ir);
}
}
return counts;
}
/** Per-function instruction-count deltas, largest absolute first. */
export function compareCounts(baseline, candidate) {
const names = new Set([...baseline.keys(), ...candidate.keys()]);
const deltas = [];
let totalBaseline = 0;
let totalCandidate = 0;
for (const name of names) {
const a = baseline.get(name) ?? 0;
const b = candidate.get(name) ?? 0;
totalBaseline += a;
totalCandidate += b;
if (a !== b) deltas.push({ name, baseline: a, candidate: b, delta: b - a });
}
deltas.sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta) || (x.name < y.name ? -1 : 1));
return {
identical: deltas.length === 0,
total: { baseline: totalBaseline, candidate: totalCandidate },
deltas,
};
}
/* ── verdicts ──────────────────────────────────────────────────────────── */
/** One program's verdict from its build results and artifact comparisons.
* `allowed` marks programs that match the change's pattern signature. */
export function programVerdict({ baseline, candidate, artifacts, allowed }) {
if (!baseline.ok || !candidate.ok) {
if (!baseline.ok && !candidate.ok && baseline.error === candidate.error)
return { verdict: "both-fail", fails: false };
return { verdict: "build-error", fails: true };
}
const differing = Object.entries(artifacts).filter(([, r]) => !r.identical);
if (differing.length === 0) return { verdict: "identical", fails: false };
return allowed ? { verdict: "allowed", fails: false } : { verdict: "differs", fails: true };
}
export function median(values) {
const sorted = [...values].sort((a, b) => a - b);
const middle = sorted.length >> 1;
return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
}
export function geomean(values) {
return values.length === 0
? 1
: Math.exp(values.reduce((sum, value) => sum + Math.log(value), 0) / values.length);
}
/** Compile-time ratio (candidate / baseline) for a set of programs. Each
* row holds paired samples: one baseline and one candidate build that ran
* side by side, so both saw the same machine load. The estimate is the
* geometric mean of each program's median pair ratio, with a 95% bootstrap
* interval over the pairs. Builds on a shared VM vary by 10-20% run to run,
* so the gate judges the interval, not the point estimate. */
export function timeRatio(rows, resamples = 2000) {
let state = 0x5eed;
const random = () => {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
return state / 4294967296;
};
const pick = (xs) =>
Array.from({ length: xs.length }, () => xs[Math.floor(random() * xs.length)]);
const estimate = (draw) => geomean(rows.map((r) => median(draw(r.ratios))));
const ratios = Array.from({ length: resamples }, () => estimate(pick)).sort((a, b) => a - b);
return {
ratio: estimate((xs) => xs),
low: ratios[Math.floor(resamples * 0.025)],
high: ratios[Math.floor(resamples * 0.975)],
};
}
+652
View File
@@ -0,0 +1,652 @@
/* Zero-cost gate: prove that a compiler change leaves good code untouched.
*
* Compiles a fixed good-code set with a baseline and a candidate checkout and
* compares IR, LLVM and release executables byte for byte, plus compile time
* and binary size, and optionally per-function cachegrind instruction counts.
* Both checkouts compile the same program files (from --programs, default the
* baseline), so program paths embedded in artifacts match; checkout paths and
* timestamps do not reach the artifacts. Builds bypass the native cache.
*
* The good-code set: the runtime benchmark workloads (and benchmarks/builds
* applications), the self-hosted compiler (IR and LLVM), and the TypeScript
* corpus programs. A program whose sources match --allow (the change's
* pattern signature) may differ; any other difference fails the gate. */
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { availableParallelism } from "node:os";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
import {
cachegrindCounts,
compareArtifact,
compareCounts,
compareExecutables,
dynamicTierSymbols,
median,
nmOutput,
parseNm,
programVerdict,
timeRatio,
} from "./zero-cost-compare.mjs";
const repoRoot = fileURLToPath(new URL("..", import.meta.url));
const { values } = parseArgs({
options: {
baseline: { type: "string" },
candidate: { type: "string", default: repoRoot },
programs: { type: "string", default: "baseline" },
set: { type: "string", default: "bench,self,corpus" },
quick: { type: "boolean", default: false },
corpus: { type: "string", default: "ts" },
shard: { type: "string" },
only: { type: "string" },
allow: { type: "string" },
"timing-runs": { type: "string" },
"self-timing-runs": { type: "string", default: "0" },
"max-time-growth": { type: "string", default: "2" },
"max-size-growth": { type: "string", default: "0" },
cachegrind: { type: "boolean", default: false },
jobs: { type: "string", default: String(Math.max(1, availableParallelism())) },
json: { type: "string" },
keep: { type: "boolean", default: false },
help: { type: "boolean", default: false },
},
});
if (values.help || values.baseline === undefined) {
console.log(`Usage: node scripts/zero-cost-gate.mjs --baseline=<checkout> [--candidate=<checkout>]
[--programs=baseline|candidate|<dir>] [--set=bench,self,corpus] [--quick]
[--corpus=ts|all] [--shard=i/n] [--only=<regex>] [--allow=<regex>]
[--timing-runs=N] [--self-timing-runs=N] [--max-time-growth=2]
[--max-size-growth=0]
[--cachegrind] [--jobs=N] [--json=<file>] [--keep]
Each checkout must be built (packages/cli/dist/bootstrap.js and this host's
runtime pack). Both compile the same program files, taken from --programs
(default: the baseline checkout).
--set picks the good-code set: bench (benchmarks/runtime workloads and the
benchmarks/builds applications), self (packages/compiler/src/native/cli.ts,
LLVM only), corpus (tests/corpus TypeScript programs; --corpus=all adds
JavaScript entries). --quick is --set=bench --timing-runs=0, for CI.
--shard=i/n keeps every n-th corpus program; --only filters program ids.
--allow=<regex> is the change's pattern signature: programs whose source
files match it may differ (reported as "allowed"). Any other difference in
IR, LLVM or the release executable fails the gate.
--timing-runs=N (default 5) times N paired --emit=llvm builds per bench
program, baseline and candidate side by side. The self-hosted compiler's
comparison build is its first pair; --self-timing-runs adds more. The gate
fails when the 95% bootstrap interval of the bench geomean ratio, or of the
self-hosted ratio, lies entirely above --max-time-growth percent; an interval
that straddles it is reported as inconclusive.
--max-size-growth bounds executable growth (percent) of allowed programs.
Dynamic-tier symbols (two-tier invariant I3): no executable may gain
symbols under DYNAMIC_TIER_PREFIXES (scripts/zero-cost-compare.mjs) unless
it matches --allow, and fully typed bench workloads (all but
DYNAMIC_WORKLOADS in this script) may define none.
--cachegrind (Linux, valgrind on PATH) runs differing executables under
cachegrind (bench workloads with their arguments, corpus programs without)
and compares instruction counts per function of the executable's own
symbols, excluding the allocator's prefault thread.
Exit status: 0 when the gate passes, 1 when it fails.`);
process.exit(values.help ? 0 : 2);
}
const trees = { baseline: resolve(values.baseline), candidate: resolve(values.candidate) };
for (const [label, root] of Object.entries(trees))
assert.ok(
existsSync(join(root, "packages/cli/dist/bootstrap.js")),
`${label} checkout ${root} has no built CLI (packages/cli/dist/bootstrap.js)`,
);
const programsRoot =
values.programs === "baseline" || values.programs === "candidate"
? trees[values.programs]
: resolve(values.programs);
const sets = new Set((values.quick ? "bench" : values.set).split(","));
for (const set of sets) assert.ok(["bench", "self", "corpus"].includes(set), `unknown set ${set}`);
const timingRuns = Number(values["timing-runs"] ?? (values.quick ? 0 : 5));
const selfTimingRuns = Number(values["self-timing-runs"]);
const maxTimeGrowth = Number(values["max-time-growth"]) / 100;
const maxSizeGrowth = Number(values["max-size-growth"]) / 100;
const jobs = Number(values.jobs);
assert.ok(Number.isInteger(timingRuns) && timingRuns >= 0, "--timing-runs must be >= 0");
assert.ok(Number.isInteger(jobs) && jobs >= 1, "--jobs must be >= 1");
assert.ok(["ts", "all"].includes(values.corpus), "--corpus must be ts or all");
const only = values.only ? new RegExp(values.only) : null;
const allow = values.allow ? new RegExp(values.allow, "m") : null;
if (values.cachegrind)
assert.equal(
spawnSync("valgrind", ["--version"]).status,
0,
"--cachegrind needs valgrind on PATH",
);
const work = mkdtempSync("/tmp/scriptc-zero-cost-");
/* ── the good-code set ─────────────────────────────────────────────────── */
/** Every file a program reads: the entry and its relative imports. */
function programInputs(entry) {
const files = new Set();
const pending = [entry];
while (pending.length > 0) {
const file = pending.pop();
if (files.has(file)) continue;
files.add(file);
const text = readFileSync(file, "utf8");
for (const match of text.matchAll(
/(?:from|import|require)\s*\(?\s*["'](\.{1,2}\/[^"']+)["']/g,
)) {
const base = resolve(dirname(file), match[1]);
const stem = base.replace(/\.(m|c)?js$/, "");
const found = [
base,
`${stem}.ts`,
`${stem}.mts`,
`${stem}.cts`,
`${base}.ts`,
join(base, "index.ts"),
].find((candidate) => existsSync(candidate) && statSync(candidate).isFile());
if (found !== undefined) pending.push(found);
}
}
return [...files];
}
function goodCodeSet() {
const programs = [];
if (sets.has("bench")) {
const suite = join(programsRoot, "benchmarks/runtime");
const manifest = JSON.parse(readFileSync(join(suite, "workloads.json"), "utf8"));
const seen = new Set();
for (const w of manifest.workloads) {
const entry = join(suite, w.entry);
if (seen.has(entry)) continue;
seen.add(entry);
programs.push({ id: `bench/${w.name}`, entry, kind: "exe", workload: w });
}
}
if (sets.has("self"))
programs.push({
id: "self/cli",
entry: join(programsRoot, "packages/compiler/src/native/cli.ts"),
kind: "source",
});
if (sets.has("corpus")) {
const corpus = join(programsRoot, "tests/corpus");
const exts = values.corpus === "all" ? ["ts", "js", "mjs", "cjs"] : ["ts"];
const entries = [];
for (const name of readdirSync(corpus).sort()) {
const path = join(corpus, name);
if (statSync(path).isDirectory()) {
const main = exts.map((ext) => join(path, `main.${ext}`)).find((p) => existsSync(p));
if (main !== undefined) entries.push(main);
} else if (exts.some((ext) => name.endsWith(`.${ext}`))) entries.push(path);
}
let selected = entries;
if (values.shard) {
const [index, count] = values.shard.split("/").map(Number);
assert.ok(index >= 1 && index <= count, "--shard must be i/n with 1 <= i <= n");
selected = entries.filter((_, k) => k % count === index - 1);
}
for (const entry of selected) {
const head = readFileSync(entry, "utf8").split("\n", 2);
const speed = head.some((line) => /^\/\/ @optimization:\s*speed\s*$/.test(line));
programs.push({
id: `corpus/${relative(corpus, entry)}`,
entry,
kind: "exe",
optimization: speed ? "speed" : "release",
});
}
}
return programs.filter((p) => only === null || only.test(p.id));
}
/* ── builds ────────────────────────────────────────────────────────────── */
function exec(command, args, options = {}) {
return new Promise((resolvePromise) => {
const started = process.hrtime.bigint();
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], ...options });
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8").on("data", (c) => (stdout += c));
child.stderr.setEncoding("utf8").on("data", (c) => (stderr += c));
child.on("close", (status, signal) =>
resolvePromise({
status,
signal,
stdout,
stderr,
ms: Number(process.hrtime.bigint() - started) / 1e6,
}),
);
});
}
function buildEnv() {
const env = { ...process.env, SCRIPTC_NO_CACHE: "1" };
delete env.SCRIPTC_TIMING;
delete env.SCRIPTC_CACHE_DIR;
return env;
}
function compile(tree, args) {
return exec(
process.execPath,
["--max-old-space-size=8192", join(tree, "packages/cli/dist/bootstrap.js"), "build", ...args],
{ cwd: programsRoot, env: buildEnv() },
);
}
/** Build one program with one checkout. Executable programs build once,
* keeping the LLVM and IR beside the release executable. The self-hosted
* compiler emits LLVM only: each of its frontend runs takes minutes, and its
* LLVM names the first differing function as well as its IR would. */
async function buildProgram(program, label) {
const dir = join(work, label, program.id.replace(/[^\w.-]+/g, "_"));
mkdirSync(dir, { recursive: true });
const tree = trees[label];
const failure = (result) => ({
ok: false,
error: (result.stderr || result.stdout || `exit ${result.status ?? result.signal}`)
.replaceAll(dir, "<out>")
.trim()
.slice(0, 4000),
});
if (program.kind === "source") {
const llvm = join(dir, "program.ll");
const result = await compile(tree, [program.entry, "--emit=llvm", "-o", llvm]);
if (result.status !== 0) return failure(result);
return { ok: true, dir, llvm, ms: result.ms };
}
const exe = join(dir, "program");
const result = await compile(tree, [
program.entry,
`--optimization=${program.optimization ?? "release"}`,
"--keep-llvm",
"--emit-ir",
"-o",
exe,
]);
if (result.status !== 0) return failure(result);
const files = readdirSync(dir);
const pick = (suffix) => {
const name = files.find((f) => f.endsWith(suffix));
return name === undefined ? null : join(dir, name);
};
return { ok: true, dir, exe, ir: pick(".ir.json"), llvm: pick(".ll"), ms: result.ms };
}
const nmCache = new Map();
function nm(binary) {
if (nmCache.has(binary)) return nmCache.get(binary);
const result = spawnSync("nm", process.platform === "darwin" ? [binary] : ["-S", binary], {
encoding: "utf8",
maxBuffer: 256 * 1024 * 1024,
});
const text = nmOutput(binary, result);
nmCache.set(binary, text);
return text;
}
/** Names of the functions an executable defines. */
const definedFunctions = (binary) =>
parseNm(nm(binary))
.filter((s) => /^[tTwW]$/.test(s.type))
.map((s) => s.name);
/* Bench workloads whose release binaries link dynamic-tier symbols on
* b14377864 (DYNAMIC_TIER_PREFIXES in zero-cost-compare.mjs): JSON.parse
* trees, worker messages, regex exec metadata (two-tier §3.3 L5), and a
* shared scr_dyn_call/scr_dyn_define_property core not yet traced to its
* typed root. Every other bench workload is fully typed and must link none.
* Remove a workload here when a leak fix makes its binary clean. */
const DYNAMIC_WORKLOADS = new Set([
"bench/json-records",
"bench/cli-config",
"bench/http-api",
"bench/validate-errors",
"bench/regex-logs",
"bench/atomics-contended",
"bench/intern-table",
"bench/typed-array-scan-workers",
"bench/log-summary",
"bench/inventory-report",
]);
/** The I3 symbol check. Every executable may not gain dynamic-tier symbols
* over the baseline (unless the program matches --allow); a fully typed
* bench workload may contain none at all. */
function dynamicTierCheck(program, baseline, candidate, artifacts, allowed) {
if (!baseline.exe || !artifacts.exe) return null;
const typed = program.id.startsWith("bench/") && !DYNAMIC_WORKLOADS.has(program.id);
if (artifacts.exe.identical && !typed) return null;
const after = dynamicTierSymbols(definedFunctions(candidate.exe));
const before = artifacts.exe.identical
? after
: dynamicTierSymbols(definedFunctions(baseline.exe));
const added = after.filter((name) => !before.includes(name));
const failures = [];
if (typed && after.length > 0)
failures.push(
`fully typed program links dynamic-tier symbols: ${after.slice(0, 5).join(", ")}`,
);
if (added.length > 0 && !allowed)
failures.push(`gained dynamic-tier symbols: ${added.slice(0, 5).join(", ")}`);
return { typed, count: after.length, added, failures };
}
function compareBuilds(baseline, candidate) {
const artifacts = {};
for (const kind of ["ir", "llvm"]) {
if (!(kind in baseline)) continue;
if (baseline[kind] === null || candidate[kind] === null) {
artifacts[kind] = { identical: false, first: `missing ${kind} artifact` };
continue;
}
artifacts[kind] = compareArtifact(
kind,
readFileSync(baseline[kind]),
readFileSync(candidate[kind]),
);
}
if (baseline.exe) {
const a = readFileSync(baseline.exe);
const b = readFileSync(candidate.exe);
artifacts.exe = a.equals(b)
? { identical: true }
: compareExecutables(a, b, nm(baseline.exe), nm(candidate.exe));
artifacts.exe.bytes = { baseline: a.length, candidate: b.length };
}
return artifacts;
}
/** Run tasks with at most `jobs` at a time; heavy tasks run alone. */
async function pool(tasks) {
const results = new Array(tasks.length);
let next = 0;
let running = 0;
let heavy = false;
await new Promise((done) => {
const pump = () => {
if (next === tasks.length && running === 0) return done();
while (next < tasks.length && running < jobs && !heavy) {
const task = tasks[next];
if (task.heavy && running > 0) break;
const index = next++;
running++;
if (task.heavy) heavy = true;
task.run().then((value) => {
results[index] = value;
running--;
if (task.heavy) heavy = false;
pump();
});
}
};
pump();
});
return results;
}
/* ── cachegrind ────────────────────────────────────────────────────────── */
async function instructionCounts(binary, args, out) {
const result = await exec("valgrind", [
"--tool=cachegrind",
"--cache-sim=no",
`--cachegrind-out-file=${out}`,
binary,
...args,
]);
// Corpus programs may exit non-zero on purpose (`// @exit: 1`).
if (result.signal !== null || !existsSync(out)) return null;
return cachegrindCounts(readFileSync(out, "utf8"), new Set(definedFunctions(binary)));
}
/* ── main ──────────────────────────────────────────────────────────────── */
function revision(root) {
const r = spawnSync("git", ["-C", root, "rev-parse", "--short", "HEAD"], { encoding: "utf8" });
if (r.status !== 0) return null;
const dirty = spawnSync("git", ["-C", root, "status", "--porcelain", "--untracked-files=no"], {
encoding: "utf8",
});
return r.stdout.trim() + (dirty.stdout.trim() ? "+dirty" : "");
}
const fmtMs = (ms) => `${(ms / 1000).toFixed(2)}s`;
const pct = (ratio) => `${ratio >= 1 ? "+" : ""}${((ratio - 1) * 100).toFixed(2)}%`;
const programs = goodCodeSet();
assert.ok(programs.length > 0, "the selected good-code set is empty");
const report = {
schema: 1,
date: new Date().toISOString(),
host: { platform: process.platform, arch: process.arch, node: process.version },
trees: Object.fromEntries(
Object.entries(trees).map(([label, root]) => [label, { root, revision: revision(root) }]),
),
programsRoot,
allow: values.allow ?? null,
programs: [],
timing: null,
failures: [],
};
console.log(
`zero-cost gate: ${programs.length} programs (${[...sets].join(", ")}), ` +
`baseline ${trees.baseline}, candidate ${trees.candidate}, programs from ${programsRoot}`,
);
let failed = false;
const started = Date.now();
await pool(
programs.map((program) => ({
heavy: program.kind === "source",
run: async () => {
const [baseline, candidate] = await Promise.all([
buildProgram(program, "baseline"),
buildProgram(program, "candidate"),
]);
const allowed =
allow !== null &&
programInputs(program.entry).some((f) => allow.test(readFileSync(f, "utf8")));
const artifacts = baseline.ok && candidate.ok ? compareBuilds(baseline, candidate) : {};
const { verdict, fails } = programVerdict({ baseline, candidate, artifacts, allowed });
const entry = {
id: program.id,
verdict,
allowed,
buildMs: { baseline: baseline.ms ?? null, candidate: candidate.ms ?? null },
artifacts,
};
if (!baseline.ok) entry.baselineError = baseline.error;
if (!candidate.ok) entry.candidateError = candidate.error;
const size = artifacts.exe?.bytes;
if (verdict === "allowed" && size && size.candidate > size.baseline * (1 + maxSizeGrowth)) {
entry.sizeFailure = true;
report.failures.push(
`${program.id}: executable grew ${size.baseline} -> ${size.candidate} bytes`,
);
}
if (fails) report.failures.push(`${program.id}: ${verdict}`);
const symbols =
baseline.ok && candidate.ok
? dynamicTierCheck(program, baseline, candidate, artifacts, allowed)
: null;
if (symbols !== null) entry.dynamicTier = symbols;
for (const failure of symbols?.failures ?? [])
report.failures.push(`${program.id}: ${failure}`);
if (fails || entry.sizeFailure || symbols?.failures.length) failed = true;
if (values.cachegrind && artifacts.exe && !artifacts.exe.identical) {
// Bench workloads run with their benchmark arguments; corpus
// programs take none.
const w = program.workload ?? { args: [] };
if (w.kind === "server" || w.args.includes("{input}"))
entry.cachegrind = { skipped: "needs a server client or generated input" };
else {
const counts = await Promise.all([
instructionCounts(baseline.exe, w.args, join(baseline.dir, "cg.out")),
instructionCounts(candidate.exe, w.args, join(candidate.dir, "cg.out")),
]);
entry.cachegrind = counts.includes(null)
? { skipped: "valgrind run failed" }
: (({ deltas, ...rest }) => ({ ...rest, deltas: deltas.slice(0, 20) }))(
compareCounts(counts[0], counts[1]),
);
}
}
printProgram(entry);
report.programs.push(entry);
nmCache.delete(baseline.exe);
nmCache.delete(candidate.exe);
if (!values.keep) {
if (baseline.dir) rmSync(baseline.dir, { recursive: true, force: true });
if (candidate.dir) rmSync(candidate.dir, { recursive: true, force: true });
}
},
})),
);
function printProgram(entry) {
const label = {
identical: "identical",
allowed: "allowed ",
differs: "DIFFERS ",
"build-error": "ERROR ",
"both-fail": "both-fail",
}[entry.verdict];
const notes = [];
for (const [kind, result] of Object.entries(entry.artifacts))
if (!result.identical) {
const counts = result.changed
? ` (${result.changed.length} changed, ${result.added.length} added, ${result.removed.length} removed)`
: "";
notes.push(`${kind}: ${result.first}${counts}`);
}
const size = entry.artifacts.exe?.bytes;
if (size && size.baseline !== size.candidate)
notes.push(
`size ${size.baseline} -> ${size.candidate} B (${pct(size.candidate / size.baseline)})`,
);
if (entry.cachegrind?.total) {
const { total, deltas } = entry.cachegrind;
notes.push(
`Ir ${total.baseline} -> ${total.candidate} (${pct(total.candidate / Math.max(1, total.baseline))})` +
(deltas[0]
? `, largest ${deltas[0].name} ${deltas[0].delta > 0 ? "+" : ""}${deltas[0].delta}`
: ""),
);
} else if (entry.cachegrind?.skipped)
notes.push(`cachegrind skipped: ${entry.cachegrind.skipped}`);
for (const failure of entry.dynamicTier?.failures ?? []) notes.push(`SYMBOLS: ${failure}`);
if (entry.baselineError) notes.push(`baseline: ${entry.baselineError.split("\n")[0]}`);
if (entry.candidateError) notes.push(`candidate: ${entry.candidateError.split("\n")[0]}`);
console.log(`${label} ${entry.id}${notes.length ? ` ${notes.join("; ")}` : ""}`);
}
/* Compile time: paired --emit=llvm builds, the baseline and the candidate
* side by side, one program at a time. Identical LLVM means everything after
* emission (LLVM optimization, linking) costs the same, so the timed part is
* the compiler's own work. The self-hosted compiler takes minutes per build;
* its main comparison build is its first pair. */
if (timingRuns > 0) {
const rows = [];
const pair = async (program) => {
const [baseline, candidate] = await Promise.all(
["baseline", "candidate"].map((label) =>
compile(trees[label], [
program.entry,
"--emit=llvm",
"-o",
join(work, `timing-${label}.ll`),
]),
),
);
return baseline.status === 0 && candidate.status === 0 ? [baseline.ms, candidate.ms] : null;
};
for (const program of programs) {
if (program.id.startsWith("corpus/")) continue;
const row = { id: program.id, baselineMs: [], candidateMs: [], ratios: [] };
const add = (sample) => {
if (sample === null) return;
row.baselineMs.push(Math.round(sample[0]));
row.candidateMs.push(Math.round(sample[1]));
row.ratios.push(sample[1] / sample[0]);
};
const runs = program.kind === "source" ? selfTimingRuns : timingRuns;
if (program.kind === "source") {
const built = report.programs.find((p) => p.id === program.id)?.buildMs;
if (built?.baseline && built?.candidate) add([built.baseline, built.candidate]);
}
for (let run = 0; run < runs; run++) add(await pair(program));
if (row.ratios.length === 0) continue;
rows.push(row);
console.log(
`time ${program.id} ${fmtMs(median(row.baselineMs))} -> ${fmtMs(median(row.candidateMs))} ` +
`(${pct(median(row.ratios))}, ${row.ratios.length} pairs)`,
);
}
report.timing = { runs: timingRuns, maxGrowth: maxTimeGrowth, rows, groups: {} };
const groups = {
bench: rows.filter((r) => r.id.startsWith("bench/")),
self: rows.filter((r) => r.id === "self/cli"),
};
for (const [name, group] of Object.entries(groups)) {
if (group.length === 0) continue;
const result = timeRatio(group);
report.timing.groups[name] = result;
const verdict =
result.low > 1 + maxTimeGrowth
? "SLOWER"
: result.high > 1 + maxTimeGrowth
? "inconclusive (rerun with more runs)"
: "within tolerance";
console.log(
`time ${name} ${group.length > 1 ? "geomean " : ""}${pct(result.ratio)} ` +
`[95% CI ${pct(result.low)}, ${pct(result.high)}]: ${verdict}`,
);
if (result.low > 1 + maxTimeGrowth) {
failed = true;
report.failures.push(
`${name} compile time grew ${pct(result.ratio)} (CI low ${pct(result.low)})`,
);
}
}
}
const counts = {};
for (const p of report.programs) counts[p.verdict] = (counts[p.verdict] ?? 0) + 1;
report.counts = counts;
report.verdict = failed ? "fail" : "pass";
report.elapsedMs = Date.now() - started;
if (values.json) {
mkdirSync(dirname(resolve(values.json)), { recursive: true });
writeFileSync(values.json, JSON.stringify(report, null, 2) + "\n");
}
if (!values.keep) rmSync(work, { recursive: true, force: true });
else console.log(`artifacts kept in ${work}`);
console.log(
`zero-cost gate: ${failed ? "FAIL" : "PASS"} — ${Object.entries(counts)
.map(([k, v]) => `${v} ${k}`)
.join(", ")} in ${fmtMs(report.elapsedMs)}`,
);
for (const failure of report.failures) console.log(` ${failure}`);
process.exit(failed ? 1 : 0);
+10
View File
@@ -0,0 +1,10 @@
// A throwing module-level after hook: the run reports the entry file as a
// failed result with the hook's location, after the top-level results,
// without counting it, and the exit code stays 0, as in Node.
import { after, test } from "node:test";
after(() => {
throw new Error("root after failed");
});
test("t", () => {});
+30
View File
@@ -0,0 +1,30 @@
// @exit: 1
// A throwing module-level before hook: each top-level test fails with its
// error without running its body or beforeEach hooks, and each top-level
// suite fails with it while the tests and suites in it are cancelled
// before they start. Later module-level before hooks still run at their
// before() calls, and the first failure is the one reported.
import { before, beforeEach, describe, it, test } from "node:test";
before(() => {
throw new Error("root before failed");
});
before(async () => {
await Promise.resolve();
throw new Error("a later failure");
});
beforeEach(() => {
throw new Error("beforeEach never runs");
});
describe("s", () => {
it("x", () => {});
describe("inner", () => {
it("y", () => {});
});
it("z", () => {});
});
test("t", () => {
throw new Error("the body never runs");
});
+44
View File
@@ -0,0 +1,44 @@
// Module-level before hooks run at their before() calls, with the root's
// context (named "<root>"), and the first top-level test waits for the
// async ones; module-level after hooks run after the last top-level test
// or suite, and module-level beforeEach hooks run before each test, with
// that test's context.
import { after, before, beforeEach, describe, it, test } from "node:test";
import * as assert from "node:assert";
const seen: string[] = [];
before((c) => {
seen.push(`before: ${c.name}`);
});
seen.push("after the before() call");
before(async () => {
seen.push("async before starts");
await new Promise<void>((resolve) => setTimeout(resolve, 5));
seen.push("async before ends");
});
beforeEach((c) => {
seen.push(`beforeEach: ${c.name}`);
});
after(() => {
// Throws, and so fails the run visibly, unless every hook ran in order.
assert.deepStrictEqual(seen, [
"before: <root>",
"after the before() call",
"async before starts",
"suite body",
"async before ends",
"beforeEach: x",
"beforeEach: t",
"report",
]);
});
describe("s", () => {
seen.push("suite body");
it("x", () => {});
});
test("t", () => {
seen.push("report");
});
File diff suppressed because it is too large Load Diff
+160
View File
@@ -0,0 +1,160 @@
import { spawnSync } from "node:child_process";
import { expect, test } from "vitest";
import {
cachegrindCounts,
compareArtifact,
compareCounts,
compareExecutables,
dynamicTierSymbols,
nmOutput,
programVerdict,
} from "../../scripts/zero-cost-compare.mjs";
const ir = (functions: object[], globals: object[]) =>
Buffer.from(JSON.stringify({ irVersion: 16, functions, globals, sourceFile: "/p/main.ts" }));
const llvm = (body: string, extra = "") =>
Buffer.from(
[
"; Generated by scriptc (LLVM backend) from /p/main.ts. Do not edit.",
'@.str = private constant [3 x i8] c"hi\\00"',
"define internal i64 @add(i64 %a, i64 %b) {",
` ${body}`,
"}",
'define void @"main.$1"() { ret void }',
extra,
"declare void @scr_print(ptr)",
"",
].join("\n"),
);
test("the zero-cost gate names the first differing IR and LLVM unit", () => {
const f = { name: "f", params: [], body: [{ kind: "return" }] };
const g = { name: "g", params: [], body: [] };
const base = ir([f, g, f], [{ name: "a", type: "i64" }]);
expect(compareArtifact("ir", base, Buffer.from(base))).toEqual({ identical: true });
const changed = compareArtifact(
"ir",
base,
ir([f, { ...g, params: [1] }, f], [{ name: "a", type: "i64" }]),
);
expect(changed).toMatchObject({
identical: false,
first: "changed g",
changed: ["g"],
added: [],
module: null,
});
const header = compareArtifact("ir", base, ir([f, g, f], [{ name: "a", type: "f64" }]));
expect(header).toMatchObject({ first: "module globals[0:a]", changed: [] });
// Repeated names are matched by occurrence, so an insertion shows as added.
expect(compareArtifact("ir", base, ir([f, g, f, f], [{ name: "a", type: "i64" }]))).toMatchObject(
{
first: "added f#3",
},
);
const ll = llvm("%r = add i64 %a, %b\n ret i64 %r");
expect(compareArtifact("llvm", ll, Buffer.from(ll))).toEqual({ identical: true });
expect(compareArtifact("llvm", ll, llvm("%r = sub i64 %a, %b\n ret i64 %r"))).toMatchObject({
first: "changed add",
changed: ["add"],
functions: { baseline: 2, candidate: 2 },
});
expect(
compareArtifact(
"llvm",
ll,
llvm("%r = add i64 %a, %b\n ret i64 %r", "define void @new() { ret void }"),
),
).toMatchObject({ first: "added new", added: ["new"] });
const global = compareArtifact("llvm", ll, Buffer.from(ll.toString().replace("hi", "ho")));
expect(global).toMatchObject({ first: "module line 2", changed: [] });
expect(global.module.candidate).toContain("ho");
});
test("the zero-cost gate compares executables and instruction counts per function", () => {
const nm =
"0000000000001000 0000000000000010 T main\n0000000000001010 0000000000000020 t scr_add\n";
const resized = compareExecutables(
Buffer.from("abcd"),
Buffer.from("abXd"),
nm,
nm.replace("0000000000000020 t", "0000000000000030 t"),
);
expect(resized).toMatchObject({ identical: false, first: "resized scr_add", offset: 2 });
const output = (main: number, prefault: number, loader: number) =>
[
"events: Ir",
"fl=???",
"fn=main",
`0 ${main}`,
"fn=scr_sa_prefault",
`0 ${prefault}`,
"fn=_dl_lookup_symbol_x",
`0 ${loader}`,
"fn=scr_add",
"0 7",
"0 3",
].join("\n");
const own = new Set(["main", "scr_add", "scr_sa_prefault"]);
const base = cachegrindCounts(output(100, 50, 9000), own);
expect([...base]).toEqual([
["main", 100],
["scr_add", 10],
]);
expect(compareCounts(base, cachegrindCounts(output(100, 75, 9300), own)).identical).toBe(true);
expect(compareCounts(base, cachegrindCounts(output(104, 50, 9000), own))).toMatchObject({
identical: false,
total: { baseline: 110, candidate: 114 },
deltas: [{ name: "main", delta: 4 }],
});
});
test("the zero-cost gate throws when nm does not list a binary's symbols", () => {
const ok = { status: 0, signal: null, stdout: "0000000000001000 T main\n", stderr: "" };
expect(nmOutput("prog", ok)).toBe(ok.stdout);
const missing = spawnSync("scriptc-missing-nm", ["prog"], { encoding: "utf8" });
expect(() => nmOutput("prog", missing)).toThrow(/^nm prog: .*ENOENT/);
expect(() =>
nmOutput("prog", { status: null, signal: "SIGKILL", stdout: "", stderr: "" }),
).toThrow("nm prog: killed by SIGKILL");
expect(() =>
nmOutput("prog", { status: 1, signal: null, stdout: "", stderr: "nm: prog: no symbols\n" }),
).toThrow("nm prog: exited with status 1: nm: prog: no symbols");
});
test("the zero-cost gate recognizes dynamic-tier runtime symbols", () => {
expect(
dynamicTierSymbols([
"main",
"scr_dyn_release",
"scr_dyn_obj_drop_keys",
"scr_dyn_this_push_dyn",
"scr_js_load_ic",
"scr_dyn_obj_read",
"scr_dyn_obj_read",
]),
).toEqual(["scr_dyn_obj_read", "scr_js_load_ic"]);
});
test("the zero-cost gate fails differing programs unless they match the signature", () => {
const ok = { ok: true };
const same = { ir: { identical: true }, exe: { identical: true } };
const differs = { ir: { identical: true }, exe: { identical: false } };
const verdict = (
artifacts: object,
allowed = false,
baseline: object = ok,
candidate: object = ok,
) => programVerdict({ baseline, candidate, artifacts, allowed });
expect(verdict(same)).toEqual({ verdict: "identical", fails: false });
expect(verdict(differs)).toEqual({ verdict: "differs", fails: true });
expect(verdict(differs, true)).toEqual({ verdict: "allowed", fails: false });
const failed = { ok: false, error: "SC1090" };
expect(verdict({}, false, failed, failed)).toEqual({ verdict: "both-fail", fails: false });
expect(verdict({}, true, ok, failed)).toEqual({ verdict: "build-error", fails: true });
});