Read a union's discriminant once in checked casts

A checked cast into a union whose discriminant has four or more string
literals reads the discriminant field once (scr_dyn_discriminant_case)
and then tests only the arms whose literal it holds and the arms without
a literal, in the union's arm order. Every other arm's literal test would
fail, so the arm chosen and the error for a value no arm matches are
unchanged. A typed capsule, an accessor or an inherited discriminant
still takes the tests arm by arm. Unions with fewer literals emit the
same code as before.
This commit is contained in:
Chris Tate
2026-10-23 22:31:08 -05:00
parent 260cf6e8c0
commit d68a705975
11 changed files with 519 additions and 15 deletions
+1
View File
@@ -16,6 +16,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea
| `functional-pipeline` | cpu | `filter`/`map`/`reduce`/`flatMap` chains, closures, small records |
| `intern-table`, `intern-table-map`, `intern-table-1`, `intern-table-map-1` | cpu | worker threads interning the same type-like objects under string keys and records under number keys, then hits and misses: one shared `InternTable` per kind of key, or a module-level `Map` per thread (the `-map` entries), with 8 threads or 1 (the `-1` entries); under Node.js every thread has its own tables |
| `json-records` | cpu | `JSON.stringify`, typed `JSON.parse`, Map aggregation |
| `json-unions` | cpu | `JSON.parse` into discriminated unions: an event log with eight kinds and nested expression trees with nine, evaluated after the cast |
| `log-lines` | cpu | 200k immediate `console.log`/`process.stdout.write` lines into the runner's pipe |
| `log-summary`, `inventory-report` | cpu | the multi-module applications from `benchmarks/builds`, on 300k-line inputs |
| `number-maps` | cpu | number-keyed Map/Set: a dense-id table, sparse ids in many small maps, get/has/set mixes |
@@ -0,0 +1,6 @@
{
"entry": "workloads/json-unions.ts",
"args": [
"6"
]
}
+155
View File
@@ -0,0 +1,155 @@
// JSON text parsed into discriminated unions: an event log with eight kinds
// of event and a nested expression tree with nine kinds of node, cast with
// `JSON.parse(text) as T`, then aggregated and evaluated. Argument: the
// number of rounds.
type Event =
| { kind: "click"; target: string; x: number; y: number }
| { key: string; kind: "key"; repeat: boolean }
| { kind: "scroll"; offset: number }
| { kind: "resize"; height: number; width: number }
| { kind: "focus"; target: string }
| { kind: "blur"; target: string }
| { kind: "load"; ms: number; url: string }
| { code: number; kind: "error"; message: string };
type Expr =
| { kind: "num"; value: number }
| { kind: "var"; name: string }
| { kind: "neg"; operand: Expr }
| { kind: "add"; left: Expr; right: Expr }
| { kind: "sub"; left: Expr; right: Expr }
| { kind: "mul"; left: Expr; right: Expr }
| { kind: "max"; items: Expr[] }
| { else: Expr; kind: "if"; test: Expr; then: Expr }
| { body: Expr; kind: "let"; name: string; value: Expr };
let seed = 12345;
function random(): number {
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
return seed / 4294967296;
}
const targets = ["menu", "search", "button", "list", "footer"];
function eventText(i: number): string {
const target = targets[i % targets.length]!;
switch (i % 8) {
case 0:
return `{"kind":"click","target":"${target}","x":${i % 640},"y":${i % 480}}`;
case 1:
return `{"key":"k${i % 26}","kind":"key","repeat":${i % 3 === 0}}`;
case 2:
return `{"kind":"scroll","offset":${(i * 7) % 2000}}`;
case 3:
return `{"height":${400 + (i % 300)},"kind":"resize","width":${600 + (i % 500)}}`;
case 4:
return `{"kind":"focus","target":"${target}"}`;
case 5:
return `{"kind":"blur","target":"${target}"}`;
case 6:
return `{"kind":"load","ms":${i % 900},"url":"/page/${i % 50}"}`;
default:
return `{"code":${400 + (i % 100)},"kind":"error","message":"failed ${i % 13}"}`;
}
}
const names = ["a", "b", "c", "d"];
function exprText(depth: number): string {
const pick = depth <= 0 ? Math.floor(random() * 2) : Math.floor(random() * 9);
switch (pick) {
case 0:
return `{"kind":"num","value":${Math.floor(random() * 100)}}`;
case 1:
return `{"kind":"var","name":"${names[Math.floor(random() * names.length)]!}"}`;
case 2:
return `{"kind":"neg","operand":${exprText(depth - 1)}}`;
case 3:
case 4:
case 5: {
const kind = pick === 3 ? "add" : pick === 4 ? "sub" : "mul";
return `{"kind":"${kind}","left":${exprText(depth - 1)},"right":${exprText(depth - 1)}}`;
}
case 6:
return `{"items":[${exprText(depth - 1)},${exprText(depth - 2)},${exprText(depth - 2)}],"kind":"max"}`;
case 7:
return `{"else":${exprText(depth - 2)},"kind":"if","test":${exprText(depth - 1)},"then":${exprText(depth - 2)}}`;
default:
return `{"body":${exprText(depth - 1)},"kind":"let","name":"${names[Math.floor(random() * names.length)]!}","value":${exprText(depth - 2)}}`;
}
}
function evaluate(e: Expr, env: Map<string, number>): number {
switch (e.kind) {
case "num":
return e.value;
case "var":
return env.get(e.name) ?? 1;
case "neg":
return -evaluate(e.operand, env);
case "add":
return (evaluate(e.left, env) + evaluate(e.right, env)) % 1000003;
case "sub":
return (evaluate(e.left, env) - evaluate(e.right, env)) % 1000003;
case "mul":
return (evaluate(e.left, env) * evaluate(e.right, env)) % 1000003;
case "max": {
let best = -Infinity;
for (const item of e.items) best = Math.max(best, evaluate(item, env));
return best;
}
case "if":
return evaluate(e.test, env) > 0 ? evaluate(e.then, env) : evaluate(e.else, env);
case "let": {
const inner = new Map(env);
inner.set(e.name, evaluate(e.value, env));
return evaluate(e.body, inner);
}
}
}
const rounds = Number(process.argv[2] ?? "6");
const eventsText = `[${Array.from({ length: 60000 }, (_, i) => eventText(i)).join(",")}]`;
const treesText = `[${Array.from({ length: 400 }, () => exprText(9)).join(",")}]`;
let clicks = 0;
let keys = 0;
let scrolled = 0;
let area = 0;
let loadMs = 0;
let errors = 0;
let focus = 0;
let total = 0;
for (let round = 0; round < rounds; round++) {
const events = JSON.parse(eventsText) as Event[];
for (const event of events) {
switch (event.kind) {
case "click":
clicks += event.x + event.y;
break;
case "key":
if (event.repeat) keys++;
break;
case "scroll":
scrolled += event.offset;
break;
case "resize":
area += (event.width * event.height) % 1000;
break;
case "focus":
case "blur":
focus += event.target.length;
break;
case "load":
loadMs += event.ms;
break;
case "error":
errors += event.code % 7;
break;
}
}
const trees = JSON.parse(treesText) as Expr[];
const env = new Map([
["a", 3],
["b", 5],
]);
for (const tree of trees) total = (total + evaluate(tree, env)) % 1000003;
}
console.log(clicks, keys, scrolled, area, loadMs, errors, focus, total);
+148 -12
View File
@@ -193,6 +193,11 @@ function builtinStreamKind(className: string): number | null {
return kinds[className] ?? null;
}
/** Unions whose discriminant has fewer string literals keep one literal test
* per arm (dispatchValues): one runtime lookup costs about what a few
* literal tests do. */
const DISCRIMINANT_DISPATCH_VALUES = 4;
export class LlDyn {
private readonly dynMatchers = new Map<string, string>();
private readonly dynBuilders = new Map<string, string>();
@@ -413,6 +418,86 @@ export class LlDyn {
return order;
}
/** The string literals of `def`'s discriminant when its arms are chosen
* by reading the discriminant once (unionArmsByDiscriminant), or null
* when the union keeps one literal test per arm: some literal is not a
* string, or there are fewer than DISCRIMINANT_DISPATCH_VALUES. */
private dispatchValues(def: IrUnionDef): string[] | null {
const values: string[] = [];
for (const guard of def.discriminant?.cases ?? []) {
if (guard.values.length === 0) return null;
for (const value of guard.values) {
if (typeof value !== "string") return null;
if (!values.includes(value)) values.push(value);
}
}
return values.length < DISCRIMINANT_DISPATCH_VALUES ? null : values;
}
/** Branches to `hits[tag]` for the first arm in `order` that `%d` matches
* (unionArmMatch), or to `none` when no arm does, for
* a union whose discriminant literals are `values` (dispatchValues). The
* discriminant is read once (scr_dyn_discriminant_case, against the
* constant `table`), and only the arms whose literal it holds and the arms
* without a literal are tested, in `order`: every other arm's literal test
* fails. A value the runtime cannot classify that way (a typed capsule, an
* accessor, an inherited discriminant) takes the tests arm by arm. */
private unionArmsByDiscriminant(
B: BlockBuilder,
def: IrUnionDef,
values: readonly string[],
order: readonly number[],
hits: readonly string[],
none: string,
preserveRefs: boolean,
table: string,
): void {
const discriminant = def.discriminant!;
const literalsOf = (tag: number): readonly (string | number | boolean)[] | null =>
discriminant.cases.find((candidate) => candidate.tag === tag)?.values ?? null;
const tryArms = (tags: readonly number[], literal: boolean): void => {
if (tags.length === 0) {
B.br(none);
return;
}
tags.forEach((tag, index) => {
const next = index === tags.length - 1 ? none : B.newLabel("du.c.n");
if (literal) this.unionArmMatch(B, def, tag, hits[tag]!, next, preserveRefs);
else this.unionArmStructure(B, def, tag, hits[tag]!, next, preserveRefs);
if (next !== none) B.startBlock(next);
});
};
const armsFor = (value: string | null): number[] =>
order.filter((tag) => {
const literals = literalsOf(tag);
return literals === null || (value !== null && literals.includes(value));
});
const host = this.host;
this.defs.push(
`${table} = private unnamed_addr constant [${values.length} x ptr] [${values.map((value) => `ptr ${host.internLiteral(value)}`).join(", ")}]`,
``,
);
host.declare(`declare i32 @scr_dyn_discriminant_case(ptr, ptr, ptr, ${host.sizeType})`);
const index = B.tmp();
B.line(
`${index} = call i32 @scr_dyn_discriminant_case(ptr %d, ptr ${host.internLiteral(discriminant.field)}, ptr ${table}, ${host.sizeType} ${values.length})`,
);
const tests = B.newLabel("du.tests");
const unmatched = B.newLabel("du.none");
const cases = values.map(() => B.newLabel("du.case"));
B.terminate(
`switch i32 ${index}, label %${tests} [ i32 -1, label %${unmatched} ${cases.map((label, k) => `i32 ${k}, label %${label}`).join(" ")} ]`,
);
values.forEach((value, k) => {
B.startBlock(cases[k]!);
tryArms(armsFor(value), false);
});
B.startBlock(unmatched);
tryArms(armsFor(null), false);
B.startBlock(tests);
tryArms(order, true);
}
/** Branch to a variant only after its literal discriminator and complete
* structure match. Use the same path for predicates and builders. */
private unionArmMatch(
@@ -450,6 +535,18 @@ export class LlDyn {
});
B.startBlock(structure);
}
this.unionArmStructure(B, def, tag, yes, no, preserveRefs);
}
/** The part of unionArmMatch past the arm's literal test. */
private unionArmStructure(
B: BlockBuilder,
def: IrUnionDef,
tag: number,
yes: string,
no: string,
preserveRefs: boolean,
): void {
const matches = B.tmp();
B.line(
`${matches} = call zeroext i1 @${this.dynMatchHelper(def.arms[tag]!, preserveRefs)}(ptr %d)`,
@@ -1275,11 +1372,26 @@ export class LlDyn {
);
// Arms in canonical order; any full match answers true.
const yes = B.newLabel("dm.y");
for (let tag = 0; tag < def.arms.length; tag++) {
const ln = B.newLabel("dm.n");
this.unionArmMatch(B, def, tag, yes, ln, preserveRefs);
B.startBlock(ln);
}
const values = this.dispatchValues(def);
if (values !== null) {
const no = B.newLabel("dm.n");
this.unionArmsByDiscriminant(
B,
def,
values,
def.arms.map((_, tag) => tag),
def.arms.map(() => yes),
no,
preserveRefs,
`@${name}.cases`,
);
B.startBlock(no);
} else
for (let tag = 0; tag < def.arms.length; tag++) {
const ln = B.newLabel("dm.n");
this.unionArmMatch(B, def, tag, yes, ln, preserveRefs);
B.startBlock(ln);
}
B.terminate(`ret i1 false`);
B.startBlock(yes);
B.terminate(`ret i1 true`);
@@ -2459,12 +2571,8 @@ export class LlDyn {
);
// Arms in unionArmOrder, first FULL match wins. The matched arm's
// builder can no longer fail.
for (const i of this.unionArmOrder(def)) {
const build = (i: number): void => {
const arm = def.arms[i]!;
const lHit = B.newLabel("dcu.h");
const lNext = B.newLabel("dcu.n");
this.unionArmMatch(B, def, i, lHit, lNext, preserveRefs);
B.startBlock(lHit);
if (arm.kind === "undefinedT" || arm.kind === "nullT") {
// A matched unit arm builds nothing: THE interned immortal
// instance (rc == SIZE_MAX — no retain owed).
@@ -2503,8 +2611,36 @@ export class LlDyn {
);
B.terminate(`ret ptr ${u}`);
}
B.startBlock(lNext);
}
};
const order = this.unionArmOrder(def);
const values = this.dispatchValues(def);
if (values !== null) {
const hits = def.arms.map(() => B.newLabel("dcu.h"));
const unmatched = B.newLabel("dcu.n");
this.unionArmsByDiscriminant(
B,
def,
values,
order,
hits,
unmatched,
preserveRefs,
`@${name}.cases`,
);
for (const i of order) {
B.startBlock(hits[i]!);
build(i);
}
B.startBlock(unmatched);
} else
for (const i of order) {
const lHit = B.newLabel("dcu.h");
const lNext = B.newLabel("dcu.n");
this.unionArmMatch(B, def, i, lHit, lNext, preserveRefs);
B.startBlock(lHit);
build(i);
B.startBlock(lNext);
}
if (preserveRefs) {
B.line(
`call void @scr_dyn_check_fail(ptr %path, ptr ${host.cstr("reference with its original layout")}, ptr %d)`,
@@ -9,9 +9,10 @@ import { inlineRcOption } from "../src/backend/llvm/shapes.js";
// Programs whose function bodies request module-level helpers and tables
// that the chunk journal (emit-journal.ts) carries: checked-dynamic
// conversions, inspection, publication walkers, live record views, stream
// adapters, regex instances, colliding literal digests, interned C strings,
// speculative loop sites, typed message listeners and readers.
// conversions (with their discriminant tables), inspection, publication
// walkers, live record views, stream adapters, regex instances, colliding
// literal digests, interned C strings, speculative loop sites, typed message
// listeners and readers.
const PROGRAMS = [
"1003-json-parse-unions.ts",
"114-live-record-delete.ts",
@@ -21,6 +22,7 @@ const PROGRAMS = [
"4900-console-instances.ts",
"5612-readable-from-iterables.cjs",
"5702-publish-candidate-buffers.ts",
"6305-json-discriminant-dispatch.ts",
"7321-speculative-loop-bail-sites.ts",
"7360-worker-typed-records.ts",
"node24-text-utilities-coercion.js",
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6305-json-discriminant-dispatch.ts"
],
"diags": []
}
+8
View File
@@ -677,6 +677,14 @@ void scr_dyn_copy_entry(ScrDyn *target, const ScrDyn *source, const ScrDynEntry
bool scr_dyn_field_eq_str(const ScrDyn *d, const ScrStr *key, const ScrStr *value);
bool scr_dyn_field_eq_num(const ScrDyn *d, const ScrStr *key, double value);
bool scr_dyn_field_eq_bool(const ScrDyn *d, const ScrStr *key, bool value);
/* The case a union's checked match takes from its string discriminant: the
* index in `values` of the string `d`'s own data property `key` holds, or
* -1 when every literal test above fails (`d` is not an object, or its own
* data property `key` holds no string among `values`), or -2 when only the
* tests themselves can tell (a typed capsule, an accessor, or a property
* `d` does not own). Borrowed args. */
int32_t scr_dyn_discriminant_case(const ScrDyn *d, const ScrStr *key, ScrStr *const *values,
size_t count);
/* Object.keys/values/entries over the checked-dynamic tree: JS own-key order (array-index
* keys ascending first), dyn-array results (+1); values/entries RETAIN
+18
View File
@@ -1422,6 +1422,24 @@ bool scr_dyn_field_eq_num(const ScrDyn *d, const ScrStr *key, double value) {
return matches;
}
int32_t scr_dyn_discriminant_case(const ScrDyn *d, const ScrStr *key, ScrStr *const *values,
size_t count) {
if (!d) return -1;
if (d->kind == SCR_DYN_TYPED_REF) return -2;
if (d->kind != SCR_DYN_OBJ) return -1;
for (size_t i = 0; i < d->v.obj.len; i++) {
const ScrDynEntry *entry = &d->v.obj.entries[i];
if (entry->key_len != key->len || memcmp(entry->key, key->data, key->len) != 0) continue;
if (entry->accessor) return -2;
const ScrDyn *member = entry->value;
if (!member || member->kind != SCR_DYN_STR) return -1;
for (size_t k = 0; k < count; k++)
if (scr_str_eq(member->v.str, values[k])) return (int32_t)k;
return -1;
}
return -2;
}
bool scr_dyn_field_eq_bool(const ScrDyn *d, const ScrStr *key, bool value) {
ScrDyn *member = scr_dyn_discriminant(d, key);
bool matches = member && member->kind == SCR_DYN_BOOL && member->v.b == value;
@@ -0,0 +1,123 @@
// JSON cast into a union with many string discriminant literals: the cast
// reads the discriminant once and tests only the arm whose literal it
// holds, plus the arms that are not records. An arm can list several
// literals, and values that are not objects reach the other arms in
// canonical order. Valid casts only (see 1002-json-parse-cast.ts); JSON keys
// and record fields alphabetical for stringify parity.
type Expr =
| { kind: "num"; value: number }
| { kind: "str"; text: string }
| { kind: "neg"; operand: Expr }
| { kind: "add" | "mul"; left: Expr; right: Expr }
| { args: Expr[]; kind: "call"; name: string }
| { kind: "method"; name: string; target: Expr }
| { kind: "list"; items: Expr[] };
function show(e: Expr): string {
switch (e.kind) {
case "num":
return String(e.value);
case "str":
return JSON.stringify(e.text);
case "neg":
return `-(${show(e.operand)})`;
case "add":
return `(${show(e.left)} + ${show(e.right)})`;
case "mul":
return `(${show(e.left)} * ${show(e.right)})`;
case "call":
return `${e.name}(${e.args.map(show).join(", ")})`;
case "method":
return `${show(e.target)}.${e.name}()`;
case "list":
return `[${e.items.map(show).join(", ")}]`;
}
}
function evaluate(e: Expr): number {
switch (e.kind) {
case "num":
return e.value;
case "str":
return e.text.length;
case "neg":
return -evaluate(e.operand);
case "add":
return evaluate(e.left) + evaluate(e.right);
case "mul":
return evaluate(e.left) * evaluate(e.right);
case "call":
return e.args.reduce((sum, arg) => sum + evaluate(arg), 0);
case "method":
return evaluate(e.target);
case "list":
return e.items.length;
}
}
const source =
'{"kind":"add","left":{"kind":"mul","left":{"kind":"num","value":3},' +
'"right":{"kind":"neg","operand":{"kind":"num","value":4}}},' +
'"right":{"args":[{"kind":"str","text":"abc"},{"kind":"num","value":2}],"kind":"call","name":"sum"}}';
const tree = JSON.parse(source) as Expr;
console.log(show(tree));
console.log(evaluate(tree));
console.log(JSON.stringify(tree) === source);
const method = JSON.parse(
'{"kind":"method","name":"size","target":{"items":[{"kind":"num","value":1}],"kind":"list"}}',
) as Expr;
console.log(show(method), evaluate(method));
// A deep list of every arm, built from text.
const parts: string[] = [];
for (let i = 0; i < 200; i++) {
parts.push(
i % 3 === 0
? `{"kind":"num","value":${i}}`
: i % 3 === 1
? `{"kind":"neg","operand":{"kind":"num","value":${i}}}`
: `{"kind":"mul","left":{"kind":"num","value":${i}},"right":{"kind":"str","text":"xy"}}`,
);
}
const list = JSON.parse(`{"items":[${parts.join(",")}],"kind":"list"}`) as Expr;
if (list.kind === "list") {
let total = 0;
for (const item of list.items) total += evaluate(item);
console.log(list.items.length, total);
}
// Arms that are not records: a value that is not an object can only be one
// of them.
type Token =
| { kind: "ident"; name: string }
| { kind: "number"; value: number }
| { kind: "string"; value: string }
| { kind: "punct"; symbol: string }
| { kind: "comment"; line: number; text: string }
| string
| number;
function describe(token: Token): string {
if (typeof token === "string") return `raw ${token}`;
if (typeof token === "number") return `count ${token}`;
switch (token.kind) {
case "ident":
return `ident ${token.name}`;
case "number":
return `number ${token.value}`;
case "string":
return `string ${JSON.stringify(token.value)}`;
case "punct":
return `punct ${token.symbol}`;
case "comment":
return `comment ${token.line}: ${token.text}`;
}
}
const tokens = JSON.parse(
'[{"kind":"ident","name":"x"},{"kind":"punct","symbol":"="},{"kind":"number","value":42},' +
'{"kind":"string","value":"hi"},{"kind":"comment","line":3,"text":"note"},"spare",7]',
) as Token[];
for (const token of tokens) console.log(describe(token));
@@ -0,0 +1 @@
{ "plain": 5844, "san": 7609 }
+48
View File
@@ -396,6 +396,54 @@ console.log("unreachable", s.kind);
expect(r.stderr).toContain("Uncaught TypeError: expected object | object at $, got object");
});
test("a union read by its discriminant fails with every arm named, as arm-by-arm tests do", async () => {
// Five record arms with six literals, plus string and number arms: the
// check reads `kind` once and tests only the arm it names and the
// arms that are not records.
const r = await compileAndRun(
"union-discriminant-dispatch",
`type Expr =
| { kind: "num"; value: number }
| { kind: "str"; text: string }
| { kind: "neg"; operand: Expr }
| { kind: "add" | "mul"; left: Expr; right: Expr }
| { items: Expr[]; kind: "list" }
| string
| number;
const inputs = [
'{"kind":"num"}',
'{"kind":"nope","value":1}',
'{"kind":7,"value":1}',
'{"value":1}',
'{"kind":"neg","operand":{"kind":"add","left":{"kind":"num","value":1},"right":{"kind":"str","text":5}}}',
'true',
'{"items":["a",3,{"kind":"mul","left":"x","right":2}],"kind":"list"}',
];
for (const input of inputs) {
try {
console.log(JSON.stringify(JSON.parse(input) as Expr));
} catch (error) {
console.log((error as Error).message);
}
}
`,
);
const any = "number | object | object | object | object | object | string";
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe(
[
`expected ${any} at $, got object`,
`expected ${any} at $, got object`,
`expected ${any} at $, got object`,
`expected ${any} at $, got object`,
`expected ${any} at $, got object`,
`expected ${any} at $, got boolean`,
'{"items":["a",3,{"kind":"mul","left":"x","right":2}],"kind":"list"}',
"",
].join("\n"),
);
});
test("nested paths point at the offending element", async () => {
const r = await compileAndRun(
"nested-path",