mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-25 06:24:27 +08:00
The gate read an empty symbol list when nm could not start, was killed by a signal, or exited with a non-zero status, so the dynamic-tier symbol check and the executable comparison passed without any symbols. nmOutput now throws with the binary, the reason and nm's stderr.
491 lines
19 KiB
JavaScript
491 lines
19 KiB
JavaScript
/* 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)],
|
|
};
|
|
}
|