From 4919576d097984a23f6005cf52d9fb33bda2b6a3 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 04:16:06 -0500 Subject: [PATCH 01/19] Filter regex start positions by their leading character sets --- .../src/backend/llvm/regex-matchers.test.ts | 19 ++ .../src/backend/llvm/regex-matchers.ts | 163 +++++++++++++++++- .../corpus/6872-regex-start-filter.ts.json | 6 + tests/corpus/6872-regex-start-filter.ts | 79 +++++++++ .../6872-regex-start-filter.ts.json | 1 + 5 files changed, 262 insertions(+), 6 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6872-regex-start-filter.ts.json create mode 100644 tests/corpus/6872-regex-start-filter.ts create mode 100644 tests/harness/corpus-costs/6872-regex-start-filter.ts.json diff --git a/packages/compiler/src/backend/llvm/regex-matchers.test.ts b/packages/compiler/src/backend/llvm/regex-matchers.test.ts index 1acd9c465..d009de7c9 100644 --- a/packages/compiler/src/backend/llvm/regex-matchers.test.ts +++ b/packages/compiler/src/backend/llvm/regex-matchers.test.ts @@ -110,6 +110,25 @@ describe("regex literal matcher code", () => { expect(body("a\\d{1,3}")).not.toContain("<16 x i8>"); }); + test("fixed leading characters filter 16 start positions at once", () => { + // Both alternatives' first characters are tested at every offset and + // combined, and the reads reach seven characters past the block. + const text = body("agggtaaa|tttaccct", "gi"); + expect(text.match(/load <16 x i8>/g)!.length).toBeGreaterThanOrEqual(16); + expect(text).toMatch(/and i16 /); + expect(text).toMatch(/or i16 /); + expect(text).toMatch(/add i32 %t\d+, 7\n/); + // A class sequence with a fixed count extends the filter. + expect(body("[cg]x{3}y")).toMatch(/and i16 /); + // An optional first character, a single first character and a + // variable-length start keep the first-character search. + expect(body("[ab]?cd")).not.toMatch(/and i16 /); + expect(body("[ab]*cd")).not.toMatch(/and i16 /); + expect(body("xy|zw")).toMatch(/and i16 /); + expect(body("[ab]|cd")).not.toMatch(/and i16 /); + expect(body("agggtaaa|tttaccct", "gi", false)).not.toContain("<16 x i8>"); + }); + test("loops no continuation can share push no backtrack entries", () => { // \d+ then a dot; [^\]]+ then ]; the run at the end of the pattern. expect(body("\\d+\\.\\d+")).not.toContain("@scr_regex_stack_grow"); diff --git a/packages/compiler/src/backend/llvm/regex-matchers.ts b/packages/compiler/src/backend/llvm/regex-matchers.ts index 001f081e5..77f8a06eb 100644 --- a/packages/compiler/src/backend/llvm/regex-matchers.ts +++ b/packages/compiler/src/backend/llvm/regex-matchers.ts @@ -589,6 +589,90 @@ function firstOf(node: RxNode): FirstInfo { } } +/** Positions the start filter tests and alternatives it keeps. */ +const PREFIX_LENGTH = 8; +const PREFIX_ALTERNATIVES = 8; + +/** The character sets a match's first characters must belong to: every + * match of the node begins with the characters of some entry of `alts`, + * one set per character. `complete` means every match is exactly as long as + * its entry (so a following node continues the sequences). Null when the + * node gives no usable condition (an alternative that can start with + * anything, or too many alternatives). Assertions add no characters: the + * result stays a necessary condition. */ +interface Prefix { + alts: CharSet[][]; + complete: boolean; +} + +/** Each head followed by each tail, cut to PREFIX_LENGTH sets; null past + * PREFIX_ALTERNATIVES entries. */ +function crossPrefixes(heads: CharSet[][], tails: CharSet[][]): CharSet[][] | null { + if (heads.length * tails.length > PREFIX_ALTERNATIVES) return null; + const out: CharSet[][] = []; + for (const head of heads) + for (const tail of tails) out.push([...head, ...tail].slice(0, PREFIX_LENGTH)); + return out; +} + +/** Whether no entry was cut at PREFIX_LENGTH. */ +function uncut(alts: CharSet[][]): boolean { + return alts.every((alt) => alt.length < PREFIX_LENGTH); +} + +function prefixOf(node: RxNode): Prefix | null { + switch (node.kind) { + case "set": + return { alts: [[node.set]], complete: true }; + case "assert": + return { alts: [[]], complete: true }; + case "cap": + return prefixOf(node.body); + case "alt": { + const alts: CharSet[][] = []; + let complete = true; + for (const alt of node.alts) { + const p = prefixOf(alt); + if (p === null) return null; + alts.push(...p.alts); + complete &&= p.complete; + } + return alts.length <= PREFIX_ALTERNATIVES ? { alts, complete } : null; + } + case "seq": { + let alts: CharSet[][] = [[]]; + for (const item of node.items) { + const p = prefixOf(item); + if (p === null) return { alts, complete: false }; + const joined = crossPrefixes(alts, p.alts); + if (joined === null) return { alts, complete: false }; + alts = joined; + if (!p.complete || !uncut(alts)) return { alts, complete: false }; + } + return { alts, complete: true }; + } + case "rep": { + if (node.max === 0) return { alts: [[]], complete: true }; + if (node.min === 0) return { alts: [[]], complete: false }; + const body = prefixOf(node.body); + if (body === null) return null; + // The first `min` iterations are required; they extend the sequences + // while the body has a fixed length. + let alts = body.alts; + let covered = 1; + while (covered < node.min && body.complete && uncut(alts)) { + const joined = crossPrefixes(alts, body.alts); + if (joined === null) break; + alts = joined; + covered++; + } + const complete = + body.complete && covered === node.min && node.min === node.max && uncut(alts); + return { alts, complete }; + } + } +} + /** Whether a node can match without consuming (assertions included). */ function canMatchEmpty(node: RxNode): boolean { switch (node.kind) { @@ -721,16 +805,74 @@ class RxGen { return wide; } + /** The vector plans of a start filter (prefixOf): per alternative, the + * plans of its first sets up to the first set without one. Null unless + * every alternative keeps at least two positions, where the filter is + * more selective than the first character alone. */ + prefixPlans(prefix: Prefix | null): { ranges: number[][]; negate: boolean }[][] | null { + if (prefix === null || !this.vector) return null; + const plans: { ranges: number[][]; negate: boolean }[][] = []; + for (const alt of prefix.alts) { + const row: { ranges: number[][]; negate: boolean }[] = []; + for (const set of alt) { + const plan = this.vectorPlan(set); + if (plan === null) break; + row.push(plan); + } + if (row.length < 2) return null; + plans.push(row); + } + return plans.length === 0 ? null : plans; + } + + /** Start positions s[index..index+16) where some alternative of the + * filter matches its first sets: an i16 mask. */ + testPrefix(plans: { ranges: number[][]; negate: boolean }[][], index: string): string { + let any = ""; + for (const row of plans) { + let all = ""; + for (let k = 0; k < row.length; k++) { + let at = index; + if (k > 0) { + at = this.tmp(); + this.line(`${at} = add i32 ${index}, ${k}`); + } + const bits = this.testVector(row[k]!, at); + if (all === "") all = bits; + else { + const both = this.tmp(); + this.line(`${both} = and i16 ${all}, ${bits}`); + all = both; + } + } + if (any === "") any = all; + else { + const either = this.tmp(); + this.line(`${either} = or i16 ${any}, ${all}`); + any = either; + } + } + return any; + } + /** The first-character search: from %at, store the next position whose - * character is in `set` and branch to %attempt, or branch to %nomatch. */ - emitSeek(set: CharSet): void { + * character is in `set` and branch to %attempt, or branch to %nomatch. + * With a start filter (`prefix`), the vector loop tests the filter's + * first sets of each alternative at 16 start positions at once and stops + * only where one of them matches all of its sets; a match can start + * nowhere else, so the attempts that remain are the ones that can + * succeed, in the same order. */ + emitSeek(set: CharSet, prefix: Prefix | null = null): void { this.block("seek"); const a = this.tmp(); this.line(`${a} = load i32, ptr %at`); + const filter = this.prefixPlans(prefix); const plan = this.vectorPlan(set); + // The filter reads s[i + reach + 15] at its farthest. + const reach = filter === null ? 0 : Math.max(...filter.map((row) => row.length)) - 1; let from = "seek", start = a; - if (plan !== null) { + if (plan !== null || filter !== null) { const head = this.label("vsh"), load = this.label("vsl"), next = this.label("vsn"), @@ -743,10 +885,16 @@ class RxGen { fits = this.tmp(); this.line(`${i} = phi i32 [ ${a}, %seek ], [ ${i16}, %${next} ]`); this.line(`${i16} = add i32 ${i}, 16`); - this.line(`${fits} = icmp sle i32 ${i16}, %len`); + if (reach === 0) { + this.line(`${fits} = icmp sle i32 ${i16}, %len`); + } else { + const last = this.tmp(); + this.line(`${last} = add i32 ${i16}, ${reach}`); + this.line(`${fits} = icmp sle i32 ${last}, %len`); + } this.line(`br i1 ${fits}, label %${load}, label %${tail}`); this.block(load); - const mask = this.testVector(plan, i), + const mask = filter !== null ? this.testPrefix(filter, i) : this.testVector(plan!, i), any = this.tmp(); this.line(`${any} = icmp ne i16 ${mask}, 0`); this.line(`br i1 ${any}, label %${hit}, label %${next}`); @@ -1769,7 +1917,10 @@ export function compileRegexMatcher( fn.push(` store i32 %off.n, ptr %at`); fn.push(` br label %attempt`); } else { - gen.emitSeek(first.set); + // A start filter over the first few characters (a single first byte + // keeps memchr). + const prefix = prefixOf(root); + gen.emitSeek(first.set, gen.prefixPlans(prefix) === null ? null : prefix); } } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6872-regex-start-filter.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6872-regex-start-filter.ts.json new file mode 100644 index 000000000..e2c97c71f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6872-regex-start-filter.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6872-regex-start-filter.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6872-regex-start-filter.ts b/tests/corpus/6872-regex-start-filter.ts new file mode 100644 index 000000000..872342e93 --- /dev/null +++ b/tests/corpus/6872-regex-start-filter.ts @@ -0,0 +1,79 @@ +// Searches whose patterns begin with fixed sequences of characters and +// classes (literal alternations, classes, fixed repetitions), over subjects +// of every length from 0 to 80 and with matches at every offset: match, +// matchAll, search, exec with captures, global and sticky lastIndex, +// replace, case-insensitive letters, multiline anchors and word boundaries. +// Results must not depend on where a match falls relative to 16-character +// blocks or the end of the subject. +const patterns: RegExp[] = [ + /agggtaaa|tttaccct/gi, + /[cgt]gggtaaa|tttaccc[acg]/gi, + /ab|abc|b/g, + /(ab)(c|d)x/g, + /x[0-9]{3}y/g, + /(?:no|yes)(!+)/gi, + /^ab/gm, + /\bcat\b/g, + /a{2}(b+)c/g, + /[A-Z][a-z]{2}-[0-9]/g, +]; + +function describe(re: RegExp, subject: string): string { + const parts: string[] = []; + const all = subject.match(re); + parts.push(`match=${all === null ? "null" : JSON.stringify(all)}`); + parts.push(`search=${subject.search(re)}`); + const rows: string[] = []; + for (const m of subject.matchAll(re)) rows.push(`${m.index}:${JSON.stringify(m.slice(0))}`); + parts.push(`all=[${rows.join(" ")}]`); + parts.push(`replace=${JSON.stringify(subject.replace(re, "<$&>"))}`); + return parts.join(" "); +} + +const filler = "qwertyuiopasdfghjklzxcvbnm"; +const needles = ["agggtaaa", "TTTACCCT", "tttacccg", "abcx", "abdx", "x123y", "NO!!", "yes!", "cat", "aabbbc", "Abc-1", "ab"]; +let checksum = 0; +let lines = 0; +for (let length = 0; length <= 80; length += 7) { + for (const needle of needles) { + for (const offset of [0, 1, 14, 15, 16, 17, 31, 32, 33]) { + if (offset + needle.length > length) continue; + let subject = ""; + while (subject.length < length) subject += filler; + subject = subject.slice(0, offset) + needle + subject.slice(offset + needle.length, length); + for (const re of patterns) { + const text = describe(re, subject); + for (let i = 0; i < text.length; i++) checksum = (checksum * 31 + text.charCodeAt(i)) >>> 0; + lines++; + if (length === 35 && (offset === 15 || offset === 16)) console.log(re.source, offset, needle, text); + } + } + } +} +console.log("lines", lines, "checksum", checksum); + +// Overlapping candidates: every start position looks like a match for the +// first characters, and only some complete. +const dense = "agggtaa".repeat(9) + "agggtaaa" + "tttaccc".repeat(5) + "tttaccct"; +console.log(describe(/agggtaaa|tttaccct/g, dense)); + +// exec with lastIndex, global and sticky. +const re = /x[0-9]{3}y/g; +const text = "zzx111yzzzzzzzzzzzzzzzzzzx222yx333y" + "z".repeat(20) + "x444y"; +let m: RegExpExecArray | null; +const found: string[] = []; +while ((m = re.exec(text)) !== null) found.push(`${m[0]}@${m.index}->${re.lastIndex}`); +console.log("exec", found.join(" ")); +const sticky = /x[0-9]{3}y/y; +sticky.lastIndex = 2; +console.log("sticky", sticky.test(text), sticky.lastIndex, sticky.test(text), sticky.lastIndex); + +// Short subjects and the tail. +for (const subject of ["", "a", "agggtaa", "agggtaaa", "zagggtaaa", "z".repeat(16) + "agggtaaa", "z".repeat(23) + "tttaccct"]) { + console.log(JSON.stringify(subject.slice(-12)), subject.length, describe(/agggtaaa|tttaccct/gi, subject)); +} + +// A long subject with matches spread through it. +let long = ""; +for (let i = 0; i < 2000; i++) long += i % 97 === 0 ? "aGgGtAaA" : i % 89 === 0 ? "tttACCCt" : filler[i % filler.length]; +console.log("long", (long.match(/agggtaaa|tttaccct/gi) ?? []).length, long.search(/tttaccct/i)); diff --git a/tests/harness/corpus-costs/6872-regex-start-filter.ts.json b/tests/harness/corpus-costs/6872-regex-start-filter.ts.json new file mode 100644 index 000000000..c9d5dd264 --- /dev/null +++ b/tests/harness/corpus-costs/6872-regex-start-filter.ts.json @@ -0,0 +1 @@ +{ "plain": 2430, "san": 8860 } From 1f357b3091288cf7aafa51d988580fa0b66c6420 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 07:01:51 -0500 Subject: [PATCH 02/19] Fence non-Buffer chunk parameters on byte-mode stream callbacks again --- .../src/frontend/lowering/lower-stream.ts | 48 +++++++++++++++---- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-stream.ts b/packages/compiler/src/frontend/lowering/lower-stream.ts index 14062051f..cb81348eb 100644 --- a/packages/compiler/src/frontend/lowering/lower-stream.ts +++ b/packages/compiler/src/frontend/lowering/lower-stream.ts @@ -289,6 +289,7 @@ function lowerStreamCallback( node: ts.MethodDeclaration | ts.FunctionExpression | ts.ArrowFunction, thisType: IrType, fullTuple: IrType[], + chunkValues = true, ): IrExpr { const loc = locOf(node); const { shapes, funcType } = lowerer.lambdaSignature(node); @@ -309,11 +310,13 @@ function lowerStreamCallback( // callable dyn whose glue dispatches to the runtime's *_done). if (funcType.params[i]!.kind === "dyn") continue; // A write or transform chunk of another type converts from the - // chunk's value when the callback runs (an object-mode side's value, - // or the Buffer), or throws a TypeError there; an encoding of another - // type (`string | null | undefined`) from the encoding argument's. + // chunk's value when the callback runs (an object-mode side's value), + // or throws a TypeError there — on a side known to be byte mode the + // chunk is a Buffer, so the declared type is the fence's; an encoding + // of another type (`string | null | undefined`) converts from the + // encoding argument's value. if ( - (i === 0 || i === 1) && + ((i === 0 && chunkValues) || i === 1) && (which === "write" || which === "transform") && funcType.params[i]!.kind !== "func" && canConvertToDyn( @@ -465,6 +468,20 @@ function streamObjectModes( return out; } +/** Whether an options literal sets the writable side's object mode with a + * `true` literal (objectMode, or writableObjectMode on a duplex stream). + * Reports nothing: streamObjectModes fences the other value forms. */ +function literalWritesValues(opts: ts.ObjectLiteralExpression, duplexish: boolean): boolean { + for (const prop of opts.properties?.nodes ?? []) { + if (!ts.isPropertyAssignment(prop)) continue; + const name = + ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name) ? prop.name.text : null; + if (name !== "objectMode" && !(duplexish && name === "writableObjectMode")) continue; + if (prop.initializer.kind !== ts.SyntaxKind.FalseKeyword) return true; + } + return false; +} + /** The full Node parameter tuples of the option callbacks, given the * concrete stream's `this` type. The trailing completion callbacks keep * whatever func type the checker contextually gave the user's parameter @@ -802,7 +819,10 @@ function parseStreamOptions( } return declared ?? funcOf([], VOID); }); - present.set(name, lowerStreamCallback(lowerer, name, fnNode, thisType, tuple)); + present.set( + name, + lowerStreamCallback(lowerer, name, fnNode, thisType, tuple, out.writableObject), + ); } finally { if (pinned) lowerer.stageSourceTypes.delete(pinned); } @@ -1151,6 +1171,7 @@ function streamMethodWrapper( thisType: IrType, loc: SrcLoc, blame: ts.Node, + chunkValues = true, ): IrExpr | null { const found = lowerer.findMethodOn(info, methodName); if (!found) { @@ -1235,10 +1256,11 @@ function streamMethodWrapper( continue; } // A write or transform chunk of another type converts from the - // chunk's value when the method runs (an object-mode side's value, - // or the Buffer), or throws a TypeError there. + // chunk's value when the method runs (an object-mode side's value), + // or throws a TypeError there; a byte-mode side's chunk is a Buffer. if ( i === 0 && + chunkValues && (which === "write" || which === "transform") && got.type.kind !== "func" && canConvertToDyn( @@ -1343,10 +1365,20 @@ export function lowerStreamSuperCall( ); } const shape = streamCtorShape(cls); + // Whether the writable side holds values: options read at run time may + // set objectMode, and a literal says so with objectMode (or a Duplex's + // writableObjectMode) true; otherwise _write and _transform receive + // Buffers. + const optsNode = argNodes[0] !== undefined ? followOptionsArg(lowerer, argNodes[0]) : null; + const chunkValues = + argNodes[0] !== undefined && + (optsNode === null || + !ts.isObjectLiteralExpression(optsNode) || + literalWritesValues(optsNode, cls !== "%Readable" && cls !== "%Writable")); const fallback = (name: string): IrExpr | null => { const methodName = UNDERSCORE_METHODS.get(name); return methodName - ? streamMethodWrapper(lowerer, info, name, methodName, thisType, loc, blame) + ? streamMethodWrapper(lowerer, info, name, methodName, thisType, loc, blame, chunkValues) : null; }; const thisRef = (): IrExpr => ({ kind: "varRef", localId: thisLocal.id, type: thisType, loc }); From f87a04fa06b0e2e9b547b435542220a59d2761ae Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 07:08:26 -0500 Subject: [PATCH 03/19] Build typed 'message' listener parameters from the message A 'message' listener with a typed parameter received each message as a checked-dynamic value: the port decoded the message into dyn objects, the listener's thunk matched the value against each union arm, read every field again to build the record, and the dyn objects were then released. For bench/channel-messages that was about a third of the delivery time. - A closure registered with on, once, addListener, prependListener or prependOnceListener for 'message' gets a typed message entry when its parameter is a number, boolean, string, a record of those without union fields, or a boxed union of those arms with null or undefined. The entry builds the parameter from the message's nodes and calls the closure. - The entry handles a message only when it has the parameter's exact layout: the union arm the checked conversion picks, and a record's declared keys in declaration order with no others. Any other message runs through the thunk on the decoded value, as before, so undeclared keys, other orders and mismatches behave as they did. - Ports, workers and parentPort deliver a message through the entries when no value in it is reached twice and it holds no ports or native handles. Listeners without an entry share one decoded value, made when the first of them runs. - The message readers are runtime entry points, so the runtime ABI is now 27. Corpus 7130 and 7131 check typed listeners on MessageChannel ports, a worker and parentPort against Node: exact layouts, extra keys, shared values, nested records, untyped listeners beside typed ones, once listeners and a transferred port. --- packages/cli/test/native-link-info.test.ts | 4 +- packages/compiler/src/backend/llvm/dyn.ts | 300 ++++++++++++++++++ .../compiler/src/backend/llvm/expr-dynamic.ts | 35 ++ packages/compiler/src/backend/runtime-abi.ts | 4 +- .../src/backend/runtime-pack-targets.test.ts | 2 +- .../compiler/src/backend/runtime-pack.test.ts | 2 +- .../test/native-codegen-integration.test.ts | 4 +- .../7130-typed-message-listeners.ts.json | 6 + .../corpus/7131-typed-worker-messages.ts.json | 6 + .../runtime-darwin-arm64/scripts/build.mjs | 2 +- .../runtime-pack-common/scripts/build.mjs | 2 +- packages/runtime/src/scr_console.c | 2 +- packages/runtime/src/scr_core.h | 2 +- packages/runtime/src/scr_dyn.h | 57 +++- packages/runtime/src/scr_events_emitter.c | 54 +++- packages/runtime/src/scr_events_emitter.h | 6 + packages/runtime/src/scr_json.c | 7 + packages/runtime/src/scr_message.c | 69 ++-- packages/runtime/src/scr_message.test.c | 47 +++ packages/runtime/src/scr_worker_events.c | 24 ++ tests/corpus/7130-typed-message-listeners.ts | 80 +++++ tests/corpus/7131-typed-worker-messages.ts | 54 ++++ .../7130-typed-message-listeners.ts.json | 1 + .../7131-typed-worker-messages.ts.json | 1 + .../self-hosting-native-toolchain.test.ts | 2 +- 25 files changed, 718 insertions(+), 55 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7130-typed-message-listeners.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7131-typed-worker-messages.ts.json create mode 100644 tests/corpus/7130-typed-message-listeners.ts create mode 100644 tests/corpus/7131-typed-worker-messages.ts create mode 100644 tests/harness/corpus-costs/7130-typed-message-listeners.ts.json create mode 100644 tests/harness/corpus-costs/7131-typed-worker-messages.ts.json diff --git a/packages/cli/test/native-link-info.test.ts b/packages/cli/test/native-link-info.test.ts index 630ef7173..a7a5e718a 100644 --- a/packages/cli/test/native-link-info.test.ts +++ b/packages/cli/test/native-link-info.test.ts @@ -72,7 +72,7 @@ describe.runIf(supported)("macOS arm64 native link info", () => { minimum_os: "14.0", }, program: { object, entry_symbol: "main" }, - runtime_abi: { version: 26, marker: "scr_runtime_abi_v26" }, + runtime_abi: { version: 27, marker: "scr_runtime_abi_v27" }, runtime_pack: { kind: "precompiled", package: "@scriptc/runtime-darwin-arm64", @@ -165,6 +165,6 @@ describe.runIf(supported)("macOS arm64 native link info", () => { () => null, (failure: { stderr?: string }) => failure, ); - expect(error?.stderr).toContain("scr_runtime_abi_v26"); + expect(error?.stderr).toContain("scr_runtime_abi_v27"); }); }); diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 6911ef98b..ac01a9209 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -108,6 +108,15 @@ const DYN_HANDLE_UNBOX_FN: Record = { dgramSocket: "scr_dgram_from_dyn", }; +/** ScrMessageKind (scr_message.h): the node kinds message readers test. */ +const MESSAGE_KIND = { + UNDEFINED: 0, + NULL: 1, + BOOL: 2, + NUMBER: 3, + STRING: 4, +} as const; + export const DYN_KIND = { NULL: 0, BOOL: 1, @@ -173,6 +182,8 @@ export class LlDyn { private readonly dynFuncThunks = new Map(); private readonly dynFuncBoxes = new Map(); private readonly dynFuncAdapters = new Map(); + private readonly messageListeners = new Map(); + private readonly messageReaders = new Map(); private readonly promiseDynAdapters = new Map(); private readonly mapDynOps = new Map(); private dynToStrFn: string | null = null; @@ -4610,6 +4621,295 @@ export class LlDyn { return name; } + /** `sc_dml_(ptr c, ptr message) -> i1`, the typed message entry of a + * closure registered as a 'message' listener (ScrDynMessageListener): + * builds the closure's parameter from the message's nodes and calls the + * closure. It answers false, without calling it, when the message does + * not have the parameter's exact layout, and the listener then runs + * through its thunk on the decoded message, as before. Null when the + * signature's parameter has no message reader (messageReadable). */ + messageListenerHelper(t: IrType & { kind: "func" }): string | null { + const key = typeKey(t); + const existing = this.messageListeners.get(key); + if (existing !== undefined) return existing; + const param = t.params[0]; + const supported = + t.params.length === 1 && + t.rest !== true && + param !== undefined && + (t.ret.kind === "void" || t.ret.kind === "f64" || t.ret.kind === "bool") && + this.messageReadable(param, new Set()); + if (!supported) { + this.messageListeners.set(key, null); + return null; + } + const name = `sc_dml_${this.messageListeners.size}`; + this.messageListeners.set(key, name); + const B = new BlockBuilder(); + B.entryAllocas.push(`%fail = alloca i1`); + B.line(`store i1 false, ptr %fail`); + const value = B.tmp(); + B.line( + `${value} = call ${this.valTy(param)} @${this.messageReaderHelper(param)}(ptr %m, ${this.host.sizeType} 0, ptr %fail)`, + ); + const failed = B.tmp(); + B.line(`${failed} = load i1, ptr %fail`); + const lMiss = B.newLabel("dml.miss"); + const lCall = B.newLabel("dml.call"); + B.condBr(failed, lMiss, lCall); + B.startBlock(lMiss); + B.terminate(`ret i1 false`); + B.startBlock(lCall); + // The closure consumes its parameter; a pending exception stays for + // the emitter, as after the thunk. + const fnp = B.tmp(); + const fn = B.tmp(); + B.line(`${fnp} = getelementptr inbounds %ScrClosure, ptr %c, i64 0, i32 1`); + B.line(`${fn} = load ptr, ptr ${fnp}`); + const args = `ptr %c, ${this.valTy(param)} ${value}`; + if (t.ret.kind === "void") B.line(`call void ${fn}(${args})`); + else B.line(`${B.tmp()} = call ${this.valTy(t.ret)} ${fn}(${args})`); + B.terminate(`ret i1 true`); + this.defs.push( + `define internal zeroext i1 @${name}(ptr %c, ptr %m) ${FN_ATTRS} { ; message entry for ${llvmCommentText(key)}`, + B.render(), + `}`, + ``, + ); + return name; + } + + /** Whether a message reader can build T: numbers, booleans and strings; + * records of them with only declared fields, none of union type; and + * boxed unions of those arms and null or undefined, with at most one + * number, boolean and string arm. */ + private messageReadable(t: IrType, seen: Set): boolean { + switch (t.kind) { + case "f64": + case "bool": + case "string": + return true; + case "record": { + const shape = this.host.recordsById.get(t.shapeId); + if (!shape || shape.tuple || shape.indexValue || shape.fields.length === 0) return false; + if (seen.has(t.shapeId) || this.isRecursiveShape(t)) return false; + seen.add(t.shapeId); + const readable = shape.fields.every( + (f) => + !f.name.startsWith("%") && + f.type.kind !== "union" && + llFieldType(f.type, this.host.inlineUnions.record(f.type)) !== INLINE_UNION_FIELD && + this.messageReadable(f.type, seen), + ); + seen.delete(t.shapeId); + return readable; + } + case "union": { + const def = this.host.unionsById.get(t.unionId); + if (!def || this.host.nullableUnions.has(t.unionId)) return false; + const scalars = new Set(); + for (const arm of def.arms) { + if (arm.kind === "nullT" || arm.kind === "undefinedT") continue; + if (arm.kind === "f64" || arm.kind === "bool" || arm.kind === "string") { + if (scalars.has(arm.kind)) return false; + scalars.add(arm.kind); + continue; + } + if (arm.kind !== "record" || !this.messageReadable(arm, seen)) return false; + } + return true; + } + default: + return false; + } + } + + /** `sc_dmr_(ptr message, size node, ptr fail) -> T`: T (+1) built + * from a message node, for a type messageReadable accepts. Sets *fail + * and returns a dummy when the node does not have T's layout: the arm + * the checked conversion would choose, a record's declared keys in + * declaration order and no others. */ + private messageReaderHelper(t: IrType): string { + const key = typeKey(t); + const existing = this.messageReaders.get(key); + if (existing) return existing; + const name = `sc_dmr_${this.messageReaders.size}`; + this.messageReaders.set(key, name); + const host = this.host; + const S = host.sizeType; + if (t.kind !== "record") host.declare(`declare i32 @scr_message_kind(ptr, ${S})`); + const retTy = this.valTy(t); + const dummy = retTy === "double" ? f64Lit(0) : retTy === "i1" ? "false" : "null"; + const B = new BlockBuilder(); + const lFail = B.newLabel("dmr.fail"); + // scr_message_fields tests a record's kind itself. + const kind = B.tmp(); + if (t.kind !== "record") B.line(`${kind} = call i32 @scr_message_kind(ptr %m, ${S} %n)`); + const requireKind = (want: number): void => { + const ok = B.tmp(); + B.line(`${ok} = icmp eq i32 ${kind}, ${want}`); + const lOk = B.newLabel("dmr.kind"); + B.condBr(ok, lOk, lFail); + B.startBlock(lOk); + }; + switch (t.kind) { + case "f64": + case "bool": { + host.declare(`declare double @scr_message_number(ptr, ${S})`); + requireKind(t.kind === "f64" ? MESSAGE_KIND.NUMBER : MESSAGE_KIND.BOOL); + const n = B.tmp(); + B.line(`${n} = call double @scr_message_number(ptr %m, ${S} %n)`); + if (t.kind === "f64") B.terminate(`ret double ${n}`); + else { + const b = B.tmp(); + B.line(`${b} = fcmp une double ${n}, ${f64Lit(0)}`); + B.terminate(`ret i1 ${b}`); + } + break; + } + case "string": { + host.declare(`declare ptr @scr_message_string(ptr, ${S})`); + requireKind(MESSAGE_KIND.STRING); + const str = B.tmp(); + B.line(`${str} = call ptr @scr_message_string(ptr %m, ${S} %n)`); + B.terminate(`ret ptr ${str}`); + break; + } + case "record": { + const shape = host.recordsById.get(t.shapeId)!; + const order = recordSourceFieldOrder(shape); + const fields = new Map(shape.fields.map((f, i) => [f.name, { field: f, index: i }])); + const base = host.nullableUnions.fieldBase(t.shapeId); + const struct = mangleRecordStruct(t.shapeId); + const names = this.recordNameTable(t.shapeId, shape); + host.declare(`declare zeroext i1 @scr_message_fields(ptr, ${S}, ptr, ptr, ${S}, ptr)`); + B.entryAllocas.push(`%kids = alloca [${order.length} x ${S}]`); + const matched = B.tmp(); + B.line( + `${matched} = call zeroext i1 @scr_message_fields(ptr %m, ${S} %n, ptr ${names.names}, ptr ${names.lens}, ${S} ${order.length}, ptr %kids)`, + ); + const lBuild = B.newLabel("dmr.build"); + B.condBr(matched, lBuild, lFail); + B.startBlock(lBuild); + B.line(`%r0 = call ptr @${mangleRecordNew(t.shapeId)}()`); + order.forEach((fieldName, i) => { + const { field, index } = fields.get(fieldName)!; + const kidp = B.tmp(); + const kid = B.tmp(); + B.line( + `${kidp} = getelementptr inbounds [${order.length} x ${S}], ptr %kids, i64 0, i64 ${i}`, + ); + B.line(`${kid} = load ${S}, ptr ${kidp}`); + const v = B.tmp(); + B.line( + `${v} = call ${this.valTy(field.type)} @${this.messageReaderHelper(field.type)}(ptr %m, ${S} ${kid}, ptr %fail)`, + ); + const failed = B.tmp(); + B.line(`${failed} = load i1, ptr %fail`); + const lDrop = B.newLabel("dmr.drop"); + const lStore = B.newLabel("dmr.store"); + B.condBr(failed, lDrop, lStore); + B.startBlock(lDrop); + B.line(`call void ${releaseSym(host, t)}(ptr %r0)`); + B.terminate(`ret ptr null`); + B.startBlock(lStore); + const p = B.tmp(); + B.line( + `${p} = getelementptr inbounds %${struct}, ptr %r0, i64 0, i32 ${index + base} ; .${llvmCommentText(fieldName)}`, + ); + const fieldTy = llFieldType(field.type, host.inlineUnions.record(field.type)); + if (fieldTy === "i8") { + const z = B.tmp(); + B.line(`${z} = zext i1 ${v} to i8`); + B.line(`store i8 ${z}, ptr ${p}`); + } else { + B.line(`store ${fieldTy} ${v}, ptr ${p}`); + } + }); + B.terminate(`ret ptr %r0`); + break; + } + case "union": { + const def = host.unionsById.get(t.unionId)!; + def.arms.forEach((arm, i) => { + const lNext = B.newLabel("dmr.next"); + if (arm.kind === "nullT" || arm.kind === "undefinedT") { + const hit = B.tmp(); + B.line( + `${hit} = icmp eq i32 ${kind}, ${arm.kind === "nullT" ? MESSAGE_KIND.NULL : MESSAGE_KIND.UNDEFINED}`, + ); + const lHit = B.newLabel("dmr.unit"); + B.condBr(hit, lHit, lNext); + B.startBlock(lHit); + B.terminate(`ret ptr ${host.unitInstanceRef(t.unionId, i)}`); + B.startBlock(lNext); + return; + } + if (arm.kind !== "record") { + const want = + arm.kind === "f64" + ? MESSAGE_KIND.NUMBER + : arm.kind === "bool" + ? MESSAGE_KIND.BOOL + : MESSAGE_KIND.STRING; + const hit = B.tmp(); + B.line(`${hit} = icmp eq i32 ${kind}, ${want}`); + const lHit = B.newLabel("dmr.arm"); + B.condBr(hit, lHit, lNext); + B.startBlock(lHit); + } + // A record arm that does not match leaves *fail clear for the + // next arm, like the checked conversion's next-arm match. + const v = B.tmp(); + B.line( + `${v} = call ${this.valTy(arm)} @${this.messageReaderHelper(arm)}(ptr %m, ${S} %n, ptr %fail)`, + ); + if (arm.kind === "record") { + const failed = B.tmp(); + B.line(`${failed} = load i1, ptr %fail`); + const lHit = B.newLabel("dmr.arm"); + const lMiss = B.newLabel("dmr.miss"); + B.condBr(failed, lMiss, lHit); + B.startBlock(lMiss); + B.line(`store i1 false, ptr %fail`); + B.br(lNext); + B.startBlock(lHit); + } + const u = B.tmp(); + if (arm.kind === "f64") { + host.declare(`declare ptr @scr_union_new_f64(i32, double)`); + B.line(`${u} = call ptr @scr_union_new_f64(i32 ${i}, double ${v})`); + } else if (arm.kind === "bool") { + host.declare(`declare ptr @scr_union_new_bool(i32, i1 zeroext)`); + B.line(`${u} = call ptr @scr_union_new_bool(i32 ${i}, i1 ${v})`); + } else { + const rc = vAdapters(host, arm); + host.declare(`declare ptr @scr_union_new_ref(i32, ptr, ptr, ptr, ptr)`); + B.line( + `${u} = call ptr @scr_union_new_ref(i32 ${i}, ptr ${v}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${traceArg(host, arm)})`, + ); + } + B.terminate(`ret ptr ${u}`); + B.startBlock(lNext); + }); + B.br(lFail); + break; + } + default: + throw new InternalCompilerError(`llvm emitter bug: message reader of ${key}`); + } + B.startBlock(lFail); + B.line(`store i1 true, ptr %fail`); + B.terminate(`ret ${retTy} ${dummy}`); + this.defs.push( + `define internal ${retTy === "i1" ? "zeroext i1" : retTy} @${name}(ptr %m, ${S} %n, ptr %fail) ${FN_ATTRS} { ; message reader for ${llvmCommentText(key)}`, + B.render(), + `}`, + ``, + ); + return name; + } + /** The adapter closure body for one TARGET signature: caps[0] is an * untraced obj-box owning the dyn function value. */ dynFuncAdapterHelper(t: IrType & { kind: "func" }): string { diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index 563a900f8..58bb90569 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -16,6 +16,7 @@ import { streamTypedRefEligible } from "../../ir/analysis.js"; import { DYN, type IrExpr, + type IrType, isDynTypedRefType, isRefCounted, isUnitType, @@ -221,6 +222,19 @@ export function emitDynamicExpr( host.own({ name: prepared, type: { kind: "dyn" } }); host.emitPendingCheck(); const args = e.args.map((a) => emitBorrowedInput(host, a)); + // A closure registered as a 'message' listener gets a typed message + // entry: ports deliver messages of its parameter's layout without + // decoding them to checked-dynamic values first. + const listener = messageListenerArg(e); + if (listener !== null) { + const entry = host.dyn.messageListenerHelper(listener); + if (entry !== null) { + host.declare(`declare void @scr_dyn_func_set_message(ptr, ptr, ptr)`); + B.line( + `call void @scr_dyn_func_set_message(ptr ${args[1]!.name}, ptr @${host.dyn.dynFuncThunkHelper(listener)}, ptr @${entry})`, + ); + } + } let argsPtr = "null"; if (args.length > 0) { const arr = B.slot(); @@ -1323,3 +1337,24 @@ export const INHERITED_OBJECT_MEMBERS: ReadonlySet = new Set([ "hasOwnProperty", "propertyIsEnumerable", ]); + +/** EventEmitter methods that register their second argument as a listener. */ +const LISTENER_METHODS: ReadonlySet = new Set([ + "on", + "once", + "addListener", + "prependListener", + "prependOnceListener", +]); + +/** The closure type of `recv.on("message", listener)` and the other + * listener registrations with a typed closure for the 'message' event, or + * null. */ +function messageListenerArg(e: ExprOf<"dynInvoke">): (IrType & { kind: "func" }) | null { + if (!LISTENER_METHODS.has(e.method) || e.args.length !== 2) return null; + const [event, listener] = e.args; + if (event?.kind !== "dynFrom" || event.value.kind !== "strLit" || event.value.value !== "message") + return null; + if (listener?.kind !== "dynFrom" || listener.value.type.kind !== "func") return null; + return listener.value.type; +} diff --git a/packages/compiler/src/backend/runtime-abi.ts b/packages/compiler/src/backend/runtime-abi.ts index 703e8fe48..3a41d9176 100644 --- a/packages/compiler/src/backend/runtime-abi.ts +++ b/packages/compiler/src/backend/runtime-abi.ts @@ -2,8 +2,8 @@ * of the link contract: a program object keeps this symbol undefined and the * matching runtime defines it, so incompatible manual links fail before the * program can start. */ -export const RUNTIME_ABI_VERSION = 26 as const; -export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v26" as const; +export const RUNTIME_ABI_VERSION = 27 as const; +export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v27" as const; /** Object consumption is useful today, but the complete scr_* surface may * still change before 1.0. The versioned marker prevents accidental mixing; diff --git a/packages/compiler/src/backend/runtime-pack-targets.test.ts b/packages/compiler/src/backend/runtime-pack-targets.test.ts index c884f1132..af61e5229 100644 --- a/packages/compiler/src/backend/runtime-pack-targets.test.ts +++ b/packages/compiler/src/backend/runtime-pack-targets.test.ts @@ -52,7 +52,7 @@ async function fixture(target: NativeTargetSpec, workers = false) { object_format: target.objectFormat, minimum_os: target.minimumOs, }, - runtime_abi: { version: 26, marker: "scr_runtime_abi_v26" }, + runtime_abi: { version: 27, marker: "scr_runtime_abi_v27" }, compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple }, macros: { executable: [], excluded: ["SCR_LIB"], sanitizer: "external-toolchain-required" }, flavors: { diff --git a/packages/compiler/src/backend/runtime-pack.test.ts b/packages/compiler/src/backend/runtime-pack.test.ts index 3a0b7870a..d09509911 100644 --- a/packages/compiler/src/backend/runtime-pack.test.ts +++ b/packages/compiler/src/backend/runtime-pack.test.ts @@ -127,7 +127,7 @@ async function fixture(target: NativeTargetSpec = MACOS_ARM64_TARGET) { object_format: target.objectFormat, minimum_os: target.minimumOs, }, - runtime_abi: { version: 26, marker: "scr_runtime_abi_v26" }, + runtime_abi: { version: 27, marker: "scr_runtime_abi_v27" }, compiler: { command: "clang", identity: "fixture clang", diff --git a/packages/compiler/test/native-codegen-integration.test.ts b/packages/compiler/test/native-codegen-integration.test.ts index af8d9b724..35a747d60 100644 --- a/packages/compiler/test/native-codegen-integration.test.ts +++ b/packages/compiler/test/native-codegen-integration.test.ts @@ -378,7 +378,7 @@ done: expect(await symbols(helperObject, ["-u"])).toEqual(await symbols(clangObject, ["-u"])); expect(await symbols(helperObject, ["-gU"])).toEqual(await symbols(clangObject, ["-gU"])); expect(await symbols(helperObject, ["-gU"])).toEqual(["0000000000000000 T _main"]); - expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v26"); + expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v27"); const clangExe = join(dir, "clang-program"); const linkDriver = join(dir, "clang-driver.c"); @@ -712,7 +712,7 @@ done: .split("\n"); expect(undefinedSymbols).toContain("_sf_scale"); expect(undefinedSymbols).toContain("_sf_callback_mix"); - expect(undefinedSymbols).toContain("_scr_runtime_abi_v26"); + expect(undefinedSymbols).toContain("_scr_runtime_abi_v27"); }); test("helper and clang object paths retain the same outbound FFI C ABI", async () => { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7130-typed-message-listeners.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7130-typed-message-listeners.ts.json new file mode 100644 index 000000000..f02244324 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7130-typed-message-listeners.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7130-typed-message-listeners.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7131-typed-worker-messages.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7131-typed-worker-messages.ts.json new file mode 100644 index 000000000..89a6c125a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7131-typed-worker-messages.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7131-typed-worker-messages.ts" + ], + "diags": [] +} diff --git a/packages/runtime-darwin-arm64/scripts/build.mjs b/packages/runtime-darwin-arm64/scripts/build.mjs index 012b86013..631750cdf 100644 --- a/packages/runtime-darwin-arm64/scripts/build.mjs +++ b/packages/runtime-darwin-arm64/scripts/build.mjs @@ -340,7 +340,7 @@ async function build() { package: packageManifest.name, version: packageManifest.version, target: RUNTIME_PACK_MATRIX.target, - runtime_abi: { version: 26, marker: "scr_runtime_abi_v26" }, + runtime_abi: { version: 27, marker: "scr_runtime_abi_v27" }, compiler: { command: compiler, identity: compilerVersion, diff --git a/packages/runtime-pack-common/scripts/build.mjs b/packages/runtime-pack-common/scripts/build.mjs index 5b8f3563f..0e81033ad 100644 --- a/packages/runtime-pack-common/scripts/build.mjs +++ b/packages/runtime-pack-common/scripts/build.mjs @@ -437,7 +437,7 @@ async function build() { package: packageManifest.name, version: packageManifest.version, target: matrix.target, - runtime_abi: { version: 26, marker: "scr_runtime_abi_v26" }, + runtime_abi: { version: 27, marker: "scr_runtime_abi_v27" }, compiler: { command: compiler, identity: compilerVersion, diff --git a/packages/runtime/src/scr_console.c b/packages/runtime/src/scr_console.c index 24cd8093b..a3ac43cc0 100644 --- a/packages/runtime/src/scr_console.c +++ b/packages/runtime/src/scr_console.c @@ -148,7 +148,7 @@ static void scr_enable_utf8_console_output(void) { } #endif -void scr_runtime_abi_v26(void) {} +void scr_runtime_abi_v27(void) {} void scr_init(void) { #ifdef SCR_WORKERS diff --git a/packages/runtime/src/scr_core.h b/packages/runtime/src/scr_core.h index 791c600c7..fea020d16 100644 --- a/packages/runtime/src/scr_core.h +++ b/packages/runtime/src/scr_core.h @@ -81,7 +81,7 @@ void scr_init(void); /* Program objects emitted by the bundled LLVM helper reference this symbol. * Its versioned spelling makes a mismatched manual runtime link fail before * the program can start. */ -void scr_runtime_abi_v26(void); +void scr_runtime_abi_v27(void); void scr_runtime_workers_v8(void); /* ── the trap funnel (scr_console.c; scr_library.c under -DSCR_LIB) ────── diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index c9d090370..2602e0341 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1,6 +1,7 @@ /* scriptc runtime — dynamic values: the checked-dynamic tree, native handles, * dyn promises, the ambient receiver and listener adapters (scr_json.c, - * scr_dyn_handle.c, scr_async_dyn.c). Included by scr_runtime.h, which says + * scr_dyn_handle.c, scr_async_dyn.c), and the message readers of typed + * 'message' listeners (scr_message.c). Included by scr_runtime.h, which says * where new declarations go. */ #ifndef SCR_DYN_H #define SCR_DYN_H @@ -254,6 +255,55 @@ typedef struct ScrDynTypedCast { * result as an owned (+1) dyn value. `args` entries are BORROWED. */ typedef ScrDyn *(*ScrDynThunk)(ScrClosure *clo, ScrDyn *const *args, size_t argc); +/* A compiled 'message' listener's typed entry: builds the closure's + * parameter from the message's nodes (the readers below) and calls + * the closure. Answers false, without calling it, when the message does + * not have the parameter's layout; the caller then decodes the message + * and calls the thunk. */ +struct ScrMessage; +typedef bool (*ScrDynMessageListener)(ScrClosure *clo, const struct ScrMessage *message); + +/* The kinds of a message's nodes (scr_message.c). */ +typedef enum { + SCR_MESSAGE_UNDEFINED, + SCR_MESSAGE_NULL, + SCR_MESSAGE_BOOL, + SCR_MESSAGE_NUMBER, + SCR_MESSAGE_STRING, + SCR_MESSAGE_BIGINT, + SCR_MESSAGE_ARRAY, + SCR_MESSAGE_OBJECT, + SCR_MESSAGE_STORAGE, + SCR_MESSAGE_BYTES, + SCR_MESSAGE_ARRAY_BUFFER, + SCR_MESSAGE_ERROR, + /* A deeply frozen plain-data graph shared by pointer (see publication). */ + SCR_MESSAGE_PUBLISHED, + /* A typed value published with @scriptc/threads (immortal), delivered as + * a fresh typed-ref capsule over the same pointer. */ + SCR_MESSAGE_TYPED_REF, + /* A transferred MessagePort: its channel end (+1) and side. */ + SCR_MESSAGE_PORT, + /* A native handle cloned through its unit's ScrDynHandleTransfer. */ + SCR_MESSAGE_HANDLE, +} ScrMessageKind; + +/* Readers for compiled 'message' listeners (ScrDynMessageListener), which + * build their typed parameter from the nodes. Node 0 is the root, and a + * node's values have higher numbers. scr_message_readable answers whether + * the message can reach these listeners: no node is reached twice, and it + * holds no ports or native handles, which the receiver adopts when it + * decodes the message. kind answers a ScrMessageKind; number reads a + * NUMBER or BOOL node, string a STRING node (+1). fields answers whether an + * OBJECT node has exactly the given keys, in order, and writes their value + * nodes. */ +bool scr_message_readable(const struct ScrMessage *message); +int32_t scr_message_kind(const struct ScrMessage *message, size_t node); +double scr_message_number(const struct ScrMessage *message, size_t node); +ScrStr *scr_message_string(const struct ScrMessage *message, size_t node); +bool scr_message_fields(const struct ScrMessage *message, size_t node, const char *const *names, + const size_t *lengths, size_t count, size_t *values); + /* Object member. Keys own shared immutable UTF-8 bytes (NUL-terminated for * convenience; key_len excludes the NUL) — duplicate keys were already * collapsed at parse time (later wins, like JS JSON.parse). */ @@ -391,7 +441,7 @@ struct ScrDyn { * and `name` are static compiler-emitted literals (never freed); name * may be NULL (anonymous — inspect prints [Function (anonymous)]). * The dyn→closure ownership edge is traced by the cycle collector. */ - struct { ScrClosure *clo; ScrDynThunk thunk; const char *sig; const char *name; uint32_t arity; ScrClassObj *class_obj; } fn; + struct { ScrClosure *clo; ScrDynThunk thunk; const char *sig; const char *name; uint32_t arity; ScrClassObj *class_obj; ScrDynMessageListener message; } fn; /* Native handles normally keep conservative ownership edges. Sets * backed by cycle-capable maps expose their edge to the collector. * Cache that allocation property: during collection the map may be @@ -1246,6 +1296,9 @@ ScrDyn *scr_dyn_define_prototype_props(ScrDyn *target, ScrDyn *descs, ScrDyn *pr * (callers retain first when they keep their own reference); `sig`/`name` * must be static literals (the box never frees them; name may be NULL). */ ScrDyn *scr_dyn_new_func(ScrClosure *clo, ScrDynThunk thunk, uint32_t arity, const char *sig, const char *name); +/* Gives a function box made with thunk its typed message entry (an + * EventEmitter listener registered from it keeps the entry). */ +void scr_dyn_func_set_message(ScrDyn *fn, ScrDynThunk thunk, ScrDynMessageListener listener); ScrDyn *scr_dyn_new_class(ScrClassObj *cls, const char *type_key); /* EventEmitter and the stream constructors as untyped constructor values, * and instanceof over untyped constructor functions diff --git a/packages/runtime/src/scr_events_emitter.c b/packages/runtime/src/scr_events_emitter.c index 901e2d114..5e03c0d6c 100644 --- a/packages/runtime/src/scr_events_emitter.c +++ b/packages/runtime/src/scr_events_emitter.c @@ -82,6 +82,8 @@ typedef struct ScrEeEntry { * the meta events. */ ScrClosure *flex_cb; ScrDynThunk flex_invoke; + /* The function box's typed message entry (scr_emitter_emit_message). */ + ScrDynMessageListener flex_message; const char *flex_sig; const char *flex_name; uint32_t flex_arity; @@ -512,6 +514,7 @@ static ScrEmitter *scr_ee_add(ScrEmitter *em, ScrStr *name, ScrClosure *cb /*mov if (dyn) { e->flex_cb = scr_closure_retain(dyn->v.fn.clo); e->flex_invoke = dyn->v.fn.thunk; + e->flex_message = dyn->v.fn.message; e->flex_sig = dyn->v.fn.sig; e->flex_name = dyn->v.fn.name; e->flex_arity = dyn->v.fn.arity; @@ -1013,15 +1016,16 @@ static ScrDyn *const scr_ee_no_args[1] = { NULL }; * a nested emit's snapshot still holds it (the shared fired flag — Node's * wrapper.fired). A listener throw stops the pass (the exception stays * pending for the caller). Returns whether the bucket had listeners. */ +typedef struct ScrEeMessage ScrEeMessage; static bool scr_ee_emit_core(ScrEmitter *em, ScrStr *name, va_list *ap_template, - ScrDyn *const *flex_args, size_t flex_argc); + ScrDyn *const *flex_args, size_t flex_argc, ScrEeMessage *message); /* emit('error', ...) first emits the same arguments to errorMonitor. */ static void scr_ee_emit_error_monitor(ScrEmitter *em, va_list *ap_template, ScrDyn *const *flex_args, size_t flex_argc) { ScrEeBucket *b = scr_ee_bucket_of(em->reg, SCR_EE_ERROR_MONITOR); if (!b || b->n == 0) return; - scr_ee_emit_core(em, SCR_EE_ERROR_MONITOR, ap_template, flex_args, flex_argc); + scr_ee_emit_core(em, SCR_EE_ERROR_MONITOR, ap_template, flex_args, flex_argc, NULL); } /* A listener this emit has no argument form for: an untyped emit whose @@ -1044,8 +1048,17 @@ SCR_COLD static void scr_ee_listener_mismatch(const ScrStr *name, bool typed_emi scr_throw_error(typed_emit ? SCR_ERR_ERROR : SCR_ERR_TYPE, message); } +/* A message emit (scr_emitter_emit_message): listeners with a typed message + * entry read the message; the others share one decoded argument, made when + * the first of them runs. */ +struct ScrEeMessage { + const struct ScrMessage *message; + ScrDyn *(*decode)(const struct ScrMessage *); + ScrDyn *decoded; +}; + static bool scr_ee_emit_core(ScrEmitter *em, ScrStr *name, va_list *ap_template, - ScrDyn *const *flex_args, size_t flex_argc) { + ScrDyn *const *flex_args, size_t flex_argc, ScrEeMessage *message) { if (name->len == 5 && memcmp(name->data, "error", 5) == 0) { scr_ee_emit_error_monitor(em, ap_template, flex_args, flex_argc); if (scr_exc_pending()) return true; @@ -1088,6 +1101,19 @@ static bool scr_ee_emit_core(ScrEmitter *em, ScrStr *name, va_list *ap_template, continue; } } + if (message && e->flex_message && e->flex_message(e->flex_cb, message->message)) { + scr_ee_entry_unref(e); + continue; + } + if (message && !message->decoded) { + message->decoded = message->decode(message->message); + if (!message->decoded) { + scr_ee_entry_unref(e); + continue; + } + flex_args = &message->decoded; + flex_argc = 1; + } if (flex_args && e->flex_invoke) { ScrDyn *answer = e->flex_invoke(e->flex_cb, flex_args, flex_argc); scr_dyn_release(answer); /* EventEmitter ignores callback results. */ @@ -1108,7 +1134,7 @@ static bool scr_ee_emit_core(ScrEmitter *em, ScrStr *name, va_list *ap_template, static bool scr_ee_emit_va(ScrEmitter *em, ScrStr *name, ...) { va_list ap; va_start(ap, name); - bool had = scr_ee_emit_core(em, name, &ap, NULL, 0); + bool had = scr_ee_emit_core(em, name, &ap, NULL, 0, NULL); va_end(ap); return had; } @@ -1119,7 +1145,7 @@ static bool scr_ee_emit_mixed_va(ScrEmitter *em, ScrStr *name, ScrDyn *const *ar int typed, ...) { va_list ap; va_start(ap, typed); - bool had = scr_ee_emit_core(em, name, typed ? &ap : NULL, args ? args : scr_ee_no_args, argc); + bool had = scr_ee_emit_core(em, name, typed ? &ap : NULL, args ? args : scr_ee_no_args, argc, NULL); va_end(ap); return had; } @@ -1131,7 +1157,7 @@ static bool scr_ee_emit_mixed_va(ScrEmitter *em, ScrStr *name, ScrDyn *const *ar bool scr_emitter_emit(ScrEmitter *em, ScrStr *name, ...) { va_list ap; va_start(ap, name); - bool had = scr_ee_emit_core(em, name, &ap, NULL, 0); + bool had = scr_ee_emit_core(em, name, &ap, NULL, 0, NULL); va_end(ap); return had; } @@ -1143,7 +1169,7 @@ bool scr_emitter_emit(ScrEmitter *em, ScrStr *name, ...) { bool scr_emitter_emit_mixed(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc, ...) { va_list ap; va_start(ap, argc); - bool had = scr_ee_emit_core(em, name, &ap, args ? args : scr_ee_no_args, argc); + bool had = scr_ee_emit_core(em, name, &ap, args ? args : scr_ee_no_args, argc, NULL); va_end(ap); return had; } @@ -1241,7 +1267,15 @@ static bool scr_ee_emit_error_dyn(ScrEmitter *em, ScrStr *name, ScrDyn *const *a /* An untyped emit of a name typed code never fixes a tuple for (computed * names, runtime-internal registries): the arguments as given. */ bool scr_emitter_emit_flex(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc) { - return scr_ee_emit_core(em, name, NULL, args ? args : scr_ee_no_args, argc); + return scr_ee_emit_core(em, name, NULL, args ? args : scr_ee_no_args, argc, NULL); +} + +bool scr_emitter_emit_message(ScrEmitter *em, ScrStr *name, const struct ScrMessage *message, + ScrDyn *(*decode)(const struct ScrMessage *)) { + ScrEeMessage state = {message, decode, NULL}; + bool had = scr_ee_emit_core(em, name, NULL, NULL, 0, &state); + scr_dyn_release(state.decoded); + return had; } /* emit(name, ...args) through an untyped value: the arguments as given. @@ -1255,7 +1289,7 @@ bool scr_emitter_emit_any(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, siz return scr_ee_emit_mixed_va(em, name, args, argc, s != NULL, s); } } - return scr_ee_emit_core(em, name, NULL, args ? args : scr_ee_no_args, argc); + return scr_ee_emit_core(em, name, NULL, args ? args : scr_ee_no_args, argc, NULL); } /* Handle event registries use checked callbacks for every tuple, including @@ -1268,7 +1302,7 @@ bool scr_emitter_emit_error_flex(ScrEmitter *em, ScrDyn *error) { return false; } ScrStr *name = scr_str_new("error", 5); - bool had = scr_ee_emit_core(em, name, NULL, &error, 1); + bool had = scr_ee_emit_core(em, name, NULL, &error, 1, NULL); scr_str_release(name); return had; } diff --git a/packages/runtime/src/scr_events_emitter.h b/packages/runtime/src/scr_events_emitter.h index b66de9e5a..6e1ef536a 100644 --- a/packages/runtime/src/scr_events_emitter.h +++ b/packages/runtime/src/scr_events_emitter.h @@ -154,6 +154,12 @@ ScrEmitter *scr_emitter_remove_all(ScrEmitter *em, ScrStr *name, bool all); * emit('error', err): no listener ⇒ THROWS err (borrowed; +1 taken). */ bool scr_emitter_emit(ScrEmitter *em, ScrStr *name, ...); bool scr_emitter_emit_flex(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc); +/* An untyped emit of one message: a listener registered from a function + * box with a typed message entry (scr_dyn_func_set_message) reads the + * message itself; the other listeners get decode(message), made once. */ +struct ScrMessage; +bool scr_emitter_emit_message(ScrEmitter *em, ScrStr *name, const struct ScrMessage *message, + ScrDyn *(*decode)(const struct ScrMessage *)); /* An untyped emit of an event with a fixed tuple: the arguments as given * for dynamic listeners, plus the tuple converted by the caller as * scr_emitter_emit's variadics for typed listeners. has_typed answers diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index f610ffdbb..355a34aee 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -4122,9 +4122,16 @@ ScrDyn *scr_dyn_new_func(ScrClosure *clo, ScrDynThunk thunk, uint32_t arity, con /* A signature adapter reports the length of the function it adapts. */ d->v.fn.arity = clo->identity != NULL ? clo->identity_length : arity; d->v.fn.class_obj = NULL; + d->v.fn.message = NULL; return d; } +void scr_dyn_func_set_message(ScrDyn *fn, ScrDynThunk thunk, ScrDynMessageListener listener) { + /* scr_dyn_new_func answers an adapted function's original box, whose + * closure has other parameter types. */ + if (fn && fn->kind == SCR_DYN_FUNC && fn->v.fn.thunk == thunk) fn->v.fn.message = listener; +} + /* A class is function-shaped for typeof and identity, but calling it without * new always throws. The closure owns the class object and its lexical boxes. */ static ScrDyn *scr_dyn_class_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) { diff --git a/packages/runtime/src/scr_message.c b/packages/runtime/src/scr_message.c index 5e1bce37f..508266103 100644 --- a/packages/runtime/src/scr_message.c +++ b/packages/runtime/src/scr_message.c @@ -3,30 +3,6 @@ #include #include -typedef enum { - SCR_MESSAGE_UNDEFINED, - SCR_MESSAGE_NULL, - SCR_MESSAGE_BOOL, - SCR_MESSAGE_NUMBER, - SCR_MESSAGE_STRING, - SCR_MESSAGE_BIGINT, - SCR_MESSAGE_ARRAY, - SCR_MESSAGE_OBJECT, - SCR_MESSAGE_STORAGE, - SCR_MESSAGE_BYTES, - SCR_MESSAGE_ARRAY_BUFFER, - SCR_MESSAGE_ERROR, - /* A deeply frozen plain-data graph shared by pointer (see publication). */ - SCR_MESSAGE_PUBLISHED, - /* A typed value published with @scriptc/threads (immortal), delivered as - * a fresh typed-ref capsule over the same pointer. */ - SCR_MESSAGE_TYPED_REF, - /* A transferred MessagePort: its channel end (+1) and side. */ - SCR_MESSAGE_PORT, - /* A native handle cloned through its unit's ScrDynHandleTransfer. */ - SCR_MESSAGE_HANDLE, -} ScrMessageKind; - const ScrMessagePortOps *scr_message_port_ops = NULL; typedef struct { @@ -95,6 +71,7 @@ struct ScrMessage { size_t count; bool storage; /* has STORAGE nodes */ bool releases; /* has STORAGE, PORT or HANDLE nodes, which own resources */ + bool readable; /* see scr_message_readable */ }; typedef struct { @@ -143,6 +120,8 @@ typedef struct { bool uncaught; /* the next value is an uncaught exception or its cause */ bool storage; bool releases; + bool shared; /* a node was reached twice */ + bool adopts; /* has PORT or HANDLE nodes */ ScrMessageNode inline_nodes[SCR_MESSAGE_INLINE]; ScrMessageVisit inline_visits[SCR_MESSAGE_INLINE]; ScrMessagePending inline_properties[SCR_MESSAGE_INLINE]; @@ -168,7 +147,7 @@ static void scr_message_encoder_init(ScrMessageEncoder *encoder, const ScrDyn *t encoder->stack_capacity = SCR_MESSAGE_INLINE; encoder->transfer = transfer; encoder->uncaught = uncaught; - encoder->storage = encoder->releases = false; + encoder->storage = encoder->releases = encoder->shared = encoder->adopts = false; } static bool scr_message_listed(const ScrMessageEncoder *encoder, const ScrDyn *value) { @@ -290,7 +269,10 @@ static void scr_message_push(ScrMessageEncoder *encoder, size_t node) { static size_t scr_message_storage(ScrMessageEncoder *encoder, const ScrBytes *bytes) { const ScrBytes *owner = bytes->backing ? bytes->backing : bytes; size_t found = scr_message_find(encoder, owner, SCR_MESSAGE_STORAGE + 1); - if (found != SIZE_MAX) return found; + if (found != SIZE_MAX) { + encoder->shared = true; + return found; + } size_t node = scr_message_node(encoder); scr_message_identify(encoder, owner, SCR_MESSAGE_STORAGE + 1, node); ScrMessageNode *storage = &encoder->nodes[node]; @@ -360,7 +342,10 @@ static size_t scr_message_value(ScrMessageEncoder *encoder, const ScrDyn *input) return SIZE_MAX; } size_t found = scr_message_find(encoder, identity, domain); - if (found != SIZE_MAX) return found; + if (found != SIZE_MAX) { + encoder->shared = true; + return found; + } size_t id = scr_message_node(encoder); scr_message_identify(encoder, identity, domain, id); encoder->visits[id].input = scr_dyn_retain((ScrDyn *)input); @@ -482,7 +467,7 @@ static size_t scr_message_value(ScrMessageEncoder *encoder, const ScrDyn *input) node->kind = SCR_MESSAGE_PORT; node->v.port.channel = channel; node->v.port.side = side; - encoder->releases = true; + encoder->releases = encoder->adopts = true; break; } bool buffer = scr_buffer_storage_is(value); @@ -498,7 +483,7 @@ static size_t scr_message_value(ScrMessageEncoder *encoder, const ScrDyn *input) node->kind = SCR_MESSAGE_HANDLE; node->v.handle.transfer = transfer; node->v.handle.payload = payload; - encoder->releases = true; + encoder->releases = encoder->adopts = true; break; } const ScrBytes *bytes = buffer ? value->v.handle.ptr : value->v.bytes; @@ -678,6 +663,7 @@ static ScrMessage *scr_message_try_publish(const ScrDyn *value) { message = scr_message_alloc(1, sizeof(*message) + sizeof(*message->nodes)); message->nodes = (ScrMessageNode *)(message + 1); message->count = 1; + message->readable = true; message->nodes[0].kind = SCR_MESSAGE_PUBLISHED; message->nodes[0].v.published = (ScrDyn *)value; if (getenv("SCRIPTC_PUBLISH_TRACE")) @@ -770,7 +756,8 @@ static ScrMessage *scr_message_pack(ScrMessageEncoder *encoder) { ScrMessageProperty *slots = (ScrMessageProperty *)(nodes + count); ScrMessageTyped *records = (ScrMessageTyped *)(slots + properties); char *text = (char *)(records + typed); - *message = (ScrMessage){nodes, count, encoder->storage, encoder->releases}; + *message = (ScrMessage){nodes, count, encoder->storage, encoder->releases, + !encoder->shared && !encoder->adopts}; memcpy(nodes, encoder->nodes, count * sizeof(*nodes)); if (typed) memcpy(records, encoder->typed, typed * sizeof(*records)); size_t first = 0; @@ -1014,6 +1001,28 @@ done:; return result; } +bool scr_message_readable(const ScrMessage *message) { return message->readable; } + +int32_t scr_message_kind(const ScrMessage *message, size_t node) { return message->nodes[node].kind; } + +double scr_message_number(const ScrMessage *message, size_t node) { return message->nodes[node].v.number; } + +ScrStr *scr_message_string(const ScrMessage *message, size_t node) { + return scr_str_new(message->nodes[node].v.text.data, message->nodes[node].v.text.length); +} + +bool scr_message_fields(const ScrMessage *message, size_t node, const char *const *names, + const size_t *lengths, size_t count, size_t *values) { + const ScrMessageNode *object = &message->nodes[node]; + if (object->kind != SCR_MESSAGE_OBJECT || object->count != count) return false; + for (size_t i = 0; i < count; i++) { + const ScrMessageProperty *property = &object->properties[i]; + if (property->length != lengths[i] || memcmp(property->key, names[i], lengths[i])) return false; + values[i] = property->value; + } + return true; +} + void scr_message_free(ScrMessage *message) { if (!message) return; if (message->releases) diff --git a/packages/runtime/src/scr_message.test.c b/packages/runtime/src/scr_message.test.c index bdabc4d12..b6adf34c5 100644 --- a/packages/runtime/src/scr_message.test.c +++ b/packages/runtime/src/scr_message.test.c @@ -250,6 +250,52 @@ static void check_large(void) { scr_message_free(message); } +/* The readers compiled 'message' listeners use: node kinds and values, a + * record's keys in order, and which messages they may read. */ +static void check_readers(void) { + ScrDyn *root = scr_dyn_new_obj(); + put_number(root, "id", 7); + ScrStr *text = scr_str_new("seven", 5); + scr_dyn_obj_set(root, "name", 4, scr_dyn_new_str(text)); + scr_str_release(text); + scr_dyn_obj_set(root, "done", 4, scr_dyn_new_bool(true)); + ScrMessage *message = scr_message_encode(root); + assert(message && scr_message_readable(message)); + assert(scr_message_kind(message, 0) == SCR_MESSAGE_OBJECT); + static const char *const names[] = {"id", "name", "done"}; + static const size_t lengths[] = {2, 4, 4}; + size_t values[3]; + assert(scr_message_fields(message, 0, names, lengths, 3, values)); + assert(scr_message_kind(message, values[0]) == SCR_MESSAGE_NUMBER); + assert(scr_message_number(message, values[0]) == 7); + assert(scr_message_kind(message, values[1]) == SCR_MESSAGE_STRING); + ScrStr *name = scr_message_string(message, values[1]); + assert(name->len == 5 && !memcmp(name->data, "seven", 5)); + scr_str_release(name); + assert(scr_message_kind(message, values[2]) == SCR_MESSAGE_BOOL); + assert(scr_message_number(message, values[2]) == 1); + /* Another order, a missing key or an extra key does not match. */ + static const char *const reordered[] = {"name", "id", "done"}; + static const size_t reordered_lengths[] = {4, 2, 4}; + assert(!scr_message_fields(message, 0, reordered, reordered_lengths, 3, values)); + assert(!scr_message_fields(message, 0, names, lengths, 2, values)); + assert(!scr_message_fields(message, values[0], names, lengths, 3, values)); + scr_message_free(message); + put_number(root, "extra", 1); + message = scr_message_encode(root); + assert(!scr_message_fields(message, 0, names, lengths, 3, values)); + scr_message_free(message); + /* A value reached twice makes the message unreadable. */ + ScrDyn *shared = scr_dyn_new_obj(); + scr_dyn_obj_set(root, "first", 5, scr_dyn_retain(shared)); + scr_dyn_obj_set(root, "second", 6, shared); + message = scr_message_encode(root); + assert(message && !scr_message_readable(message)); + scr_message_free(message); + scr_dyn_release(root); + scr_collect_cycles(); +} + /* A link-gated unit's handle crosses threads through the transfer hooks in * its ops: share on the sender, adopt on the receiver, drop with the * message. The test borrows the last capacity slot, which no unit uses, so @@ -376,6 +422,7 @@ int main(void) { check_rejection(); check_documents(); check_large(); + check_readers(); check_handle_transfer(); scr_context_cleanup(); puts("message graphs preserve identity, sparse values, byte aliases and getter order"); diff --git a/packages/runtime/src/scr_worker_events.c b/packages/runtime/src/scr_worker_events.c index 252230372..3d10183f4 100644 --- a/packages/runtime/src/scr_worker_events.c +++ b/packages/runtime/src/scr_worker_events.c @@ -665,6 +665,17 @@ static bool scr_channel_dispatch(void) { continue; } scr_worker_view_retain(view); + if (event->message && !view->broadcast && scr_message_readable(event->message)) { + /* Compiled listeners with a typed message entry read the message. */ + ScrStr *name = scr_str_new("message", 7); + scr_dyn_this_push(view, SCR_DYNH_MESSAGE_PORT); + scr_emitter_emit_message(view->events, name, event->message, scr_message_decode); + scr_dyn_this_pop(); + scr_str_release(name); + scr_mail_event_free(event); + scr_worker_view_release(view); + return true; + } ScrDyn *argument = event->message ? scr_message_decode(event->message) : NULL; if (argument && view->broadcast) { ScrDyn *self = scr_dyn_new_handle(view, SCR_DYNH_BROADCAST_CHANNEL); @@ -815,6 +826,19 @@ static void scr_worker_dispatch(void) { } } scr_worker_view_retain(view); + if (event->kind == SCR_WORKER_MESSAGE && event->message && scr_message_readable(event->message)) { + /* Compiled listeners with a typed message entry read the message. */ + if (!scr_exc_pending()) { + ScrStr *name = scr_str_new("message", 7); + scr_dyn_this_push(view, view->port ? SCR_DYNH_MESSAGE_PORT : SCR_DYNH_WORKER); + scr_emitter_emit_message(view->events, name, event->message, scr_message_decode); + scr_dyn_this_pop(); + scr_str_release(name); + } + scr_worker_view_release(view); + scr_mail_event_free(event); + return; + } const char *event_name = event->kind == SCR_WORKER_ONLINE ? "online" : event->kind == SCR_WORKER_EXIT ? "exit" : event->kind == SCR_WORKER_ERROR ? "error" : "message"; ScrDyn *argument = event->message ? scr_message_decode(event->message) : diff --git a/tests/corpus/7130-typed-message-listeners.ts b/tests/corpus/7130-typed-message-listeners.ts new file mode 100644 index 000000000..b98983824 --- /dev/null +++ b/tests/corpus/7130-typed-message-listeners.ts @@ -0,0 +1,80 @@ +import { MessageChannel } from "node:worker_threads"; + +// 'message' listeners with typed parameters on MessageChannel ports. A +// message with the parameter's exact layout, and one with other keys, +// shared values or other kinds, reach the listener with the same values as +// in Node. +type Item = { id: number; name: string; done: boolean }; +type Nested = { label: string; item: Item }; + +const { port1, port2 } = new MessageChannel(); +// Each port's lines print in delivery order once every message arrived. +const lines: string[] = []; +const nestedLines: string[] = []; +const otherLines: string[] = []; +let pending = 0; +function finish(): void { + if (--pending === 0) { + for (const line of [...lines, ...nestedLines, ...otherLines]) console.log(line); + port1.close(); + channel.port1.close(); + other.port1.close(); + } +} + +port1.on("message", (value: number | string | Item | null | undefined) => { + if (value === null || value === undefined) lines.push(`unit ${String(value)}`); + else if (typeof value === "number") lines.push(`number ${value.toFixed(2)}`); + else if (typeof value === "string") lines.push(`string ${value.length} ${value}`); + else lines.push(`item ${value.id} ${value.name} ${value.done} ${JSON.stringify(value)}`); + finish(); +}); + +const sent: unknown[] = [ + 1.5, + -0, + "", + "héllo wörld", + null, + undefined, + { id: 1, name: "first", done: false }, + { id: 2, name: "", done: true }, + // An extra key stays beside the record. + { id: 4, name: "extra", done: false, note: "kept" }, +]; +pending += sent.length; +for (const value of sent) port2.postMessage(value); + +// Nested records, a value shared by two fields, and a second listener +// that reads the message untyped. +const channel = new MessageChannel(); +channel.port1.on("message", (value: Nested) => { + nestedLines.push(`nested ${value.label} ${value.item.id} ${value.item.name} ${JSON.stringify(value)}`); + finish(); +}); +channel.port1.on("message", (value: unknown) => { + nestedLines.push(`untyped ${JSON.stringify(value)}`); + finish(); +}); +const shared = { id: 5, name: "shared", done: true }; +const nested: unknown[] = [ + { label: "plain", item: { id: 6, name: "six", done: false } }, + { label: "shared", item: shared, again: shared }, +]; +pending += 2 * nested.length; +for (const value of nested) channel.port2.postMessage(value); + +// once listeners, and a listener added while the port delivers. +const other = new MessageChannel(); +other.port1.once("message", (value: Item) => { + otherLines.push(`once ${value.id} ${value.name}`); + other.port1.on("message", (later: number) => { + otherLines.push(`later ${later}`); + finish(); + }); + finish(); +}); +pending += 3; +other.port2.postMessage({ id: 7, name: "seven", done: true }); +other.port2.postMessage(8); +other.port2.postMessage(9); diff --git a/tests/corpus/7131-typed-worker-messages.ts b/tests/corpus/7131-typed-worker-messages.ts new file mode 100644 index 000000000..86197b4de --- /dev/null +++ b/tests/corpus/7131-typed-worker-messages.ts @@ -0,0 +1,54 @@ +import { MessageChannel, MessagePort, Worker, isMainThread, parentPort } from "node:worker_threads"; + +// 'message' listeners with typed parameters on a worker and its +// parentPort: records and numbers in both directions, and a message that +// carries a transferred MessagePort. +type Job = { id: number; text: string; urgent: boolean }; +type Reply = { id: number; length: number; upper: string }; + +if (isMainThread) { + const worker = new Worker(new URL(import.meta.url)); + // The worker's replies, then the port's, each in delivery order. + const replies: string[] = []; + const portLines: string[] = []; + let expected = 5; + const done = (): void => { + if (--expected > 0) return; + for (const line of [...replies, ...portLines]) console.log(line); + void worker.terminate(); + }; + worker.on("message", (reply: Reply | number) => { + if (typeof reply === "number") replies.push(`count ${reply}`); + else replies.push(`reply ${reply.id} ${reply.length} ${reply.upper}`); + done(); + }); + const jobs: Job[] = [ + { id: 1, text: "alpha", urgent: false }, + { id: 2, text: "βeta", urgent: true }, + { id: 3, text: "", urgent: false }, + ]; + for (const job of jobs) worker.postMessage(job); + worker.postMessage(jobs.length); + // A port in the message: the worker answers through it. + const { port1, port2 } = new MessageChannel(); + port1.on("message", (text: string) => { + portLines.push(`port ${text}`); + port1.close(); + done(); + }); + worker.postMessage({ port: port2 }, [port2]); +} else { + let count = 0; + parentPort!.on("message", (message: Job | number | { port: MessagePort }) => { + if (typeof message === "number") { + parentPort!.postMessage(count + message); + } else if ("port" in message) { + message.port.postMessage("through the port"); + message.port.close(); + } else { + count++; + const text = message.urgent ? message.text.toUpperCase() : message.text; + parentPort!.postMessage({ id: message.id, length: message.text.length, upper: text }); + } + }); +} diff --git a/tests/harness/corpus-costs/7130-typed-message-listeners.ts.json b/tests/harness/corpus-costs/7130-typed-message-listeners.ts.json new file mode 100644 index 000000000..bf7b0d199 --- /dev/null +++ b/tests/harness/corpus-costs/7130-typed-message-listeners.ts.json @@ -0,0 +1 @@ +{ "plain": 2200, "san": 2200 } diff --git a/tests/harness/corpus-costs/7131-typed-worker-messages.ts.json b/tests/harness/corpus-costs/7131-typed-worker-messages.ts.json new file mode 100644 index 000000000..953e2af8f --- /dev/null +++ b/tests/harness/corpus-costs/7131-typed-worker-messages.ts.json @@ -0,0 +1 @@ +{ "plain": 1700, "san": 1600 } diff --git a/tests/harness/self-hosting-native-toolchain.test.ts b/tests/harness/self-hosting-native-toolchain.test.ts index 176caa516..c0176eb93 100644 --- a/tests/harness/self-hosting-native-toolchain.test.ts +++ b/tests/harness/self-hosting-native-toolchain.test.ts @@ -108,7 +108,7 @@ for (const backend of ["llvm"] as const) { object_format: target.objectFormat, minimum_os: target.minimumOs, }, - runtime_abi: { version: 26, marker: "scr_runtime_abi_v26" }, + runtime_abi: { version: 27, marker: "scr_runtime_abi_v27" }, compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple }, macros: { executable: [], excluded: [], sanitizer: "external-toolchain-required" }, flavors: { From 87162a491d4b0f4819bc6577f0045f02a3774912 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 07:20:14 -0500 Subject: [PATCH 04/19] Index dispatch receivers by name instead of testing each one ClassDynamicDispatch.process tested every property receiver for every property dispatch and for every method name a dynamic call names, in every pass: on the flat tsc-ts bundle, 9.2 million receiver tests per build. It now indexes each receiver list once by the method, generic method, accessor and field names along each receiver's base chain, and tests only the receivers listed under a name, plus the ones whose emitter, stream or EventTarget root answers names of its own, in list order. Names that the test decides on every receiver (compiler names, constructor, stack) and emit-override specializations still test every receiver. SCRIPTC_CHECK_DISPATCH_CANDIDATES=1 checks each selection against a test of every receiver. --- .../lowering/class-dynamic-dispatch.ts | 149 ++++++++++++++++-- 1 file changed, 132 insertions(+), 17 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index fefe85d0c..79edae433 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -1,4 +1,5 @@ import { primitiveConstructorOr } from "./lower-exprs.js"; +import { InternalCompilerError } from "../../errors.js"; import { dynUndefinedExpr, helperLoc, nodeThrowExpr, varRef } from "../../ir/build.js"; import { constructedValue } from "./constructor-returns.js"; import { eventTargetApiMembers, eventTargetInheritedMember } from "./lower-event-target.js"; @@ -467,6 +468,77 @@ function sortAdded(lowerer: Lowerer, set: Set, from: number): void { for (const name of added) set.add(name); } +/** The receivers of one property-receiver list (propertyReceivers) by the + * names that can make them answer a member, as positions in the list. A + * receiver whose root answers names of its own (an emitter, a stream, an + * EventTarget or a Date) is in `rooted` instead. */ +interface ReceiverIndex { + plans: readonly PropertyReceiver[]; + /** By a method or generic method its base chain declares. */ + methods: Map; + /** By a method, generic method or accessor its base chain declares (an + * accessor under its property name), or a field it has. */ + members: Map; + rooted: number[]; +} + +function receiverIndex(lowerer: Lowerer, plans: readonly PropertyReceiver[]): ReceiverIndex { + const methods = new Map(); + const members = new Map(); + const rooted: number[] = []; + const add = (table: Map, name: string, position: number): void => { + const positions = table.get(name); + if (positions === undefined) table.set(name, [position]); + else if (positions[positions.length - 1] !== position) positions.push(position); + }; + plans.forEach(({ info }, position) => { + if ( + emitterRooted(lowerer, info) || + info.builtinStream !== undefined || + eventTargetApiMembers(info) !== null || + dateRootOf(info) !== null + ) { + rooted.push(position); + return; + } + for (let c: ClassInfo | null = info; c; c = c.base) { + for (const name of c.methods.keys()) { + add(methods, name, position); + add( + members, + name.startsWith("get:") || name.startsWith("set:") ? name.slice(4) : name, + position, + ); + } + for (const name of c.genericMethods?.keys() ?? []) { + add(methods, name, position); + add(members, name, position); + } + } + for (const name of info.fields.keys()) add(members, name, position); + }); + for (const table of [methods, members]) + for (const positions of table.values()) positions.sort((a, b) => a - b); + return { plans, methods, members, rooted }; +} + +/** The ascending union of two ascending position lists. */ +function mergePositions(left: readonly number[], right: readonly number[]): number[] { + const out: number[] = []; + let i = 0; + let j = 0; + while (i < left.length || j < right.length) { + const next = + j >= right.length || (i < left.length && left[i]! <= right[j]!) ? left[i++]! : right[j++]!; + if (out[out.length - 1] !== next) out.push(next); + } + return out; +} + +/** SCRIPTC_CHECK_DISPATCH_CANDIDATES=1 checks every indexed receiver selection + * against a test of every receiver. */ +const CHECK_CANDIDATES = process.env["SCRIPTC_CHECK_DISPATCH_CANDIDATES"] === "1"; + export function classPropertiesHelper(lowerer: Lowerer, loc: SrcLoc, readOnly = false): IrFunction { const name = readOnly ? READ_PROPERTIES : "%dyn.class.properties"; lowerer.shareHelper(name); @@ -1342,10 +1414,31 @@ export class ClassDynamicDispatch { } refreshDescriptorGuards(lowerer); const byMethod = new Map(); + // A query asks every receiver about one name, and the program's dispatch + // sites ask about thousands: index the receivers by name once per list. + let index: ReceiverIndex | null = null; + const indexOf = (plans: readonly PropertyReceiver[]): ReceiverIndex => + index?.plans === plans ? index : (index = receiverIndex(lowerer, plans)); + /** The receivers of `plans` that can answer `name`, in list order: those + * `table` lists under it and the rooted ones. Emit-override + * specializations (`emit:`) join a class's methods while bodies + * lower, so they keep every receiver. */ + const receiversFor = ( + plans: readonly PropertyReceiver[], + name: string, + table: "methods" | "members", + ): readonly PropertyReceiver[] => { + if (name.startsWith("emit:")) return plans; + const found = indexOf(plans); + return mergePositions(found[table].get(name) ?? [], found.rooted).map( + (position) => plans[position]!, + ); + }; const candidates = (method: string): ClassInfo[] => { const found = byMethod.get(method); if (found) return found; - const matching = this.propertyReceivers(lowerer, true).flatMap(({ info }) => { + const plans = this.propertyReceivers(lowerer, true); + const test = ({ info }: PropertyReceiver): ClassInfo[] => { if (!info || info.fields.has(method) || info.builtinError) return []; if (emitterRooted(lowerer, info) && EMITTER_METHODS.has(method)) return [info]; if ( @@ -1364,7 +1457,13 @@ export class ClassDynamicDispatch { return findMethodOn(lowerer, info, method) || findGenericMethodOn(lowerer, info, method) ? [info] : []; - }); + }; + const matching = receiversFor(plans, method, "methods").flatMap(test); + if (CHECK_CANDIDATES) { + const every = plans.flatMap(test); + if (every.length !== matching.length || every.some((info, i) => info !== matching[i])) + throw new InternalCompilerError(`indexed candidates for ${method} differ`); + } byMethod.set(method, matching); return matching; }; @@ -2898,24 +2997,40 @@ export class ClassDynamicDispatch { }); changed = true; } - for (const plan of this.propertyReceivers(lowerer, true)) { + // Only the receivers that declare or hold the name, and the rooted + // ones, can pass the test, except for names it decides on every + // receiver. Fields added while bodies lower (the property bag and the + // attribute words) have compiler names, which take every receiver. + const plans = this.propertyReceivers(lowerer, true); + const anyReceiver = + !isClassOwnEnumerableFieldName(dispatch.name) || + dispatch.name === "constructor" || + dispatch.name === "stack"; + const skips = (plan: PropertyReceiver): boolean => + !isClassOwnEnumerableFieldName(dispatch.name) || + (!(plan.info.builtinStream !== undefined && streamProperty(dispatch.name)) && + !eventTargetInheritedMember(plan.info, dispatch.name) && + !dateInheritedMember(lowerer, plan.info, dispatch.name) && + !findMethodOn(lowerer, plan.info, dispatch.name) && + !findGenericMethodOn(lowerer, plan.info, dispatch.name) && + dispatch.name !== "constructor" && + !plan.info.fields.has(dispatch.name) && + !(dispatch.name === "stack" && lowerer.isSubclassOf(plan.info.def.name, "%Error")) && + !findMethodOn(lowerer, plan.info, `get:${dispatch.name}`) && + !findMethodOn(lowerer, plan.info, `set:${dispatch.name}`)); + const selected = anyReceiver ? plans : receiversFor(plans, dispatch.name, "members"); + if ( + CHECK_CANDIDATES && + plans.some( + (plan) => !dispatch.classes.has(plan.key) && !skips(plan) && !selected.includes(plan), + ) + ) + throw new InternalCompilerError(`indexed receivers for ${dispatch.name} differ`); + for (const plan of selected) { const { info } = plan; // Classes answered in an earlier pass skip the member lookups. if (dispatch.classes.has(plan.key)) continue; - if ( - !isClassOwnEnumerableFieldName(dispatch.name) || - (!(info.builtinStream !== undefined && streamProperty(dispatch.name)) && - !eventTargetInheritedMember(info, dispatch.name) && - !dateInheritedMember(lowerer, info, dispatch.name) && - !findMethodOn(lowerer, info, dispatch.name) && - !findGenericMethodOn(lowerer, info, dispatch.name) && - dispatch.name !== "constructor" && - !info.fields.has(dispatch.name) && - !(dispatch.name === "stack" && lowerer.isSubclassOf(info.def.name, "%Error")) && - !findMethodOn(lowerer, info, `get:${dispatch.name}`) && - !findMethodOn(lowerer, info, `set:${dispatch.name}`)) - ) - continue; + if (skips(plan)) continue; dispatch.classes.add(plan.key); const loc = dispatch.fn.loc; const { receiver, condition } = this.propertyReceiver(plan, loc); From 906f9911a148b4e1f54c0b4e195bafa92b629824 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 07:45:48 -0500 Subject: [PATCH 05/19] Keep a callback's captured scalars in registers in loops that call it A call through a binding that the constant-callback census resolves keeps a fallback through the closure's code pointer. In a loop that fallback is an unknown call that takes the closure, so the closure's code pointer, its captures and a captured `let` were reloaded, and the `let` stored back, on every iteration of `let s = 0; a.forEach((v) => { s = (s + v) | 0; })`. A for, while or do-while loop that calls such a binding now tests once, before the loop, that the binding's closure has the known code pointer. The copy where the test holds calls the function directly with no fallback, and the other copy is the ordinary loop. A closure's code pointer is set only when the closure is built and the census resolves only bindings the function never rebinds, so one test covers the loop. Only bindings declared before the loop qualify, and loops are versioned up to a size bound. Number and boolean box payloads get an alias category of their own, so a store to a captured `let` no longer aliases element loads and other memory in the loop. With the closure built in the caller, the test folds after inlining and the captured `let` stays in a register. --- .../src/backend/llvm/emit-function.ts | 2 + .../src/backend/llvm/emit-statements.ts | 117 ++++++++++++++ packages/compiler/src/backend/llvm/emitter.ts | 45 +++++- .../compiler/src/backend/llvm/expr-calls.ts | 27 ++-- .../test/callback-loop-versions.test.ts | 83 ++++++++++ .../7300-callback-captured-scalars.ts.json | 6 + .../corpus/7300-callback-captured-scalars.ts | 147 ++++++++++++++++++ .../7300-callback-captured-scalars.ts.json | 1 + 8 files changed, 406 insertions(+), 22 deletions(-) create mode 100644 packages/compiler/test/callback-loop-versions.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7300-callback-captured-scalars.ts.json create mode 100644 tests/corpus/7300-callback-captured-scalars.ts create mode 100644 tests/harness/corpus-costs/7300-callback-captured-scalars.ts.json diff --git a/packages/compiler/src/backend/llvm/emit-function.ts b/packages/compiler/src/backend/llvm/emit-function.ts index 09e6551c3..9e8f7d701 100644 --- a/packages/compiler/src/backend/llvm/emit-function.ts +++ b/packages/compiler/src/backend/llvm/emit-function.ts @@ -83,6 +83,8 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string { } emitter.currentConstantCallbacks = emitter.debug === null ? (emitter.constantCallbacks.get(fn.name) ?? new Map()) : new Map(); + emitter.knownCallbacks.clear(); + emitter.callbackVersionsEnabled = emitter.debug === null && !fn.async && !fn.generator; // Preserve concrete source bindings for debugger inspection. Suspended // functions keep the established array lifetime across continuations. emitter.streamingSplitsEnabled = emitter.debug === null && !fn.async && !fn.generator; diff --git a/packages/compiler/src/backend/llvm/emit-statements.ts b/packages/compiler/src/backend/llvm/emit-statements.ts index 7239db7d8..618464e88 100644 --- a/packages/compiler/src/backend/llvm/emit-statements.ts +++ b/packages/compiler/src/backend/llvm/emit-statements.ts @@ -55,6 +55,7 @@ import { emitBytesLoopVersionGuard } from "./bytes-loop-versions.js"; import { mangleLocal } from "../mangle.js"; import { LlvmUnsupportedError } from "./unsupported.js"; import type { LlValue } from "./expr-context.js"; +import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; import { widenInteger } from "./integer-values.js"; import { SCALAR_UNION_RESULT } from "./int32-abi.js"; import { emitLiteralSwitch } from "./switch-dispatch.js"; @@ -76,6 +77,9 @@ import { } from "./string-construction.js"; import { emitStringInputs } from "./string-lifetimes.js"; +/** A callback loop version copies the loop; keep the copies small. */ +const MAX_CALLBACK_VERSION_COST = 400; + export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { const B = emitter.B; emitter.frames.push([]); @@ -795,6 +799,11 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { break; } case "while": { + const callbacks = callbackVersionBindings(emitter, s); + if (callbacks) { + emitCallbackVersionedLoop(emitter, s, callbacks); + break; + } const workerBudget = emitter.workerLoopBudget(s); const le = B.newLabel("loop.e"); emitter.emitLoopCopies(s, true, le, (exit) => { @@ -827,6 +836,11 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { break; } case "doWhile": { + const callbacks = callbackVersionBindings(emitter, s); + if (callbacks) { + emitCallbackVersionedLoop(emitter, s, callbacks); + break; + } const workerBudget = emitter.workerLoopBudget(s); // Body first (runs at least once); continue jumps to the CONDITION. const le = B.newLabel("loop.e"); @@ -855,6 +869,11 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { break; } case "for": { + const callbacks = callbackVersionBindings(emitter, s); + if (callbacks) { + emitCallbackVersionedLoop(emitter, s, callbacks); + break; + } // The init's scope wraps the whole loop (break/continue must NOT // release it — scopeDepth captured after the push, C parity). const integerArrayLoop = @@ -1885,6 +1904,104 @@ export function emitSwitch(emitter: LlEmitter, s: IrStmt & { kind: "switch" }): B.startBlock(end); } +/** The loop copies a callback loop version needs: the bindings the loop + * calls that the constant-callback census resolves to one function, each + * with that function's direct entry. Only small loops of optimized + * synchronous functions, and only bindings declared before the loop that + * an enclosing version has not already proved, qualify: the guard reads + * each binding before the loop runs. */ +function callbackVersionBindings( + emitter: LlEmitter, + s: IrStmt & { kind: "for" | "while" | "doWhile" }, +): { ref: IrExpr & { kind: "varRef" }; direct: string }[] | null { + if ( + !emitter.callbackVersionsEnabled || + emitter.currentConstantCallbacks.size === 0 || + emitter.callbackVersioning.has(s) + ) + return null; + const found = new Map(); + // Bindings the loop declares or writes (a callback declared in its body). + const bound = new Set(); + let cost = 0; + const expr = (e: IrExpr): boolean => { + if (++cost > MAX_CALLBACK_VERSION_COST) return false; + if (e.kind === "assignExpr") bound.add(e.localId); + if ( + e.kind === "callValue" && + e.callee.kind === "varRef" && + !emitter.knownCallbacks.has(e.callee.localId) && + !found.has(e.callee.localId) + ) { + const local = emitter.currentLocals.get(e.callee.localId); + const target = + local && local.tdz !== true ? emitter.knownCallbackTarget(e.callee) : undefined; + if (target) found.set(e.callee.localId, { ref: e.callee, direct: target.direct }); + } + return everyExprChild(e, expr, stmt); + }; + const stmt = (x: IrStmt): boolean => { + if (++cost > MAX_CALLBACK_VERSION_COST) return false; + if (x.kind === "varDecl" || x.kind === "assign" || x.kind === "forOf") bound.add(x.localId); + else if (x.kind === "tryCatch" && x.catchLocalId !== null) bound.add(x.catchLocalId); + return everyStmtChild(x, expr, stmt); + }; + if (!stmt(s)) return null; + const bindings = [...found.values()].filter(({ ref }) => !bound.has(ref.localId)); + return bindings.length > 0 ? bindings : null; +} + +/** A callback loop version. The guard tests, once before the loop, that + * each binding's closure holds its known function: a closure's code + * pointer is written only when the closure is built, and the census + * resolves only bindings the function never rebinds. The copy where the + * guard holds calls those functions directly, with no indirect fallback + * that could rewrite the closure or what it captures, so LLVM can keep a + * captured scalar the callback updates in a register. The other copy is the + * ordinary loop. */ +function emitCallbackVersionedLoop( + emitter: LlEmitter, + s: IrStmt & { kind: "for" | "while" | "doWhile" }, + bindings: { ref: IrExpr & { kind: "varRef" }; direct: string }[], +): void { + const B = emitter.B; + let ok = "true"; + for (const { ref, direct } of bindings) { + // A borrowed read: the guard only compares the code pointer. + const closure = emitter.emitReadReceiver(ref).name; + const fnp = B.tmp(), + fn = B.tmp(), + same = B.tmp(); + B.line(`${fnp} = getelementptr inbounds %ScrClosure, ptr ${closure}, i64 0, i32 1`); + B.line(`${fn} = load ptr, ptr ${fnp}`); + B.line(`${same} = icmp eq ptr ${fn}, ${direct}`); + if (ok === "true") ok = same; + else { + const both = B.tmp(); + B.line(`${both} = and i1 ${ok}, ${same}`); + ok = both; + } + } + const fast = B.newLabel("callback.version.fast"), + slow = B.newLabel("callback.version.slow"), + done = B.newLabel("callback.version.done"); + B.condBr(ok, fast, slow); + emitter.callbackVersioning.add(s); + try { + B.startBlock(slow); + emitter.emitStmt(s); + B.br(done); + B.startBlock(fast); + for (const { ref } of bindings) emitter.knownCallbacks.add(ref.localId); + emitter.emitStmt(s); + B.br(done); + } finally { + for (const { ref } of bindings) emitter.knownCallbacks.delete(ref.localId); + emitter.callbackVersioning.delete(s); + } + B.startBlock(done); +} + /** Emit a loop's condition, body and update through `emitCopy`, twice * when speculative-integers.ts plans a fast integer copy. Both copies * share the loop's setup and exit; the ordinary copy comes first so the diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index ed1785912..9170db7d2 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -831,6 +831,12 @@ export class LlEmitter { readonly callLifetimes: CallLifetimes; readonly constantCallbacks: Map>; currentConstantCallbacks: ReadonlyMap = new Map(); + /** Callback bindings whose closures an enclosing callback loop version + * proved to hold their known function: calls take the direct entry with + * no fallback (emitCallbackVersionedLoop). */ + knownCallbacks = new Set(); + readonly callbackVersioning = new Set(); + callbackVersionsEnabled = false; readonly stackCallbacks: StackCallbacks; readonly receiverReaders: AmbientReceiverReaders; readonly stackCaptures: StackCaptures; @@ -3901,6 +3907,25 @@ export class LlEmitter { return b; } + /** The function a call through `callee` is known to reach (a literal + * closure, or a binding the constant-callback census resolves) and the + * entry it calls directly. Async and generator functions stay indirect. */ + knownCallbackTarget(callee: IrExpr): { known: IrFunction; direct: string } | undefined { + const knownName = + callee.kind === "closure" + ? callee.fnName + : callee.kind === "varRef" + ? this.currentConstantCallbacks.get(callee.localId) + : undefined; + const known = knownName ? this.fnByName.get(knownName) : undefined; + if (!known || known.async || known.generator) return undefined; + if (known.captures === undefined) { + this.fnValues.add(known.name); + return { known, direct: `@${mangleWrapper(known.name)}` }; + } + return { known, direct: `@${this.callTarget(known.name)}` }; + } + /** The payload slot of a capture box (ScrBox.slot, field 5). */ boxSlot(box: string): string { const slot = this.B.tmp(); @@ -3914,12 +3939,18 @@ export class LlEmitter { const r = B.tmp(); // Scalar payloads are read in place (scr_box_get_f64/bool are a // memcpy and a nonzero test of the same slot), so LLVM can keep a - // captured number in a register across a loop of calls. + // captured number in a register across a loop of calls. They have an + // alias category of their own: every other access to a box's payload + // is untagged or a runtime call. if (acc === "f64") { - B.line(`${r} = load double, ptr ${this.boxSlot(box)}`); + B.line( + `${r} = load double, ptr ${this.boxSlot(box)}${this.memoryAliasAttachment("box:scalar")}`, + ); } else if (acc === "bool") { const raw = B.tmp(); - B.line(`${raw} = load i64, ptr ${this.boxSlot(box)}`); + B.line( + `${raw} = load i64, ptr ${this.boxSlot(box)}${this.memoryAliasAttachment("box:scalar")}`, + ); B.line(`${r} = icmp ne i64 ${raw}, 0`); } else { this.declare(`declare ptr @scr_box_get_ref(ptr)`); @@ -3933,11 +3964,15 @@ export class LlEmitter { const B = this.B; const acc = boxAccess(t); if (acc === "f64") { - B.line(`store double ${value}, ptr ${this.boxSlot(box)}`); + B.line( + `store double ${value}, ptr ${this.boxSlot(box)}${this.memoryAliasAttachment("box:scalar")}`, + ); } else if (acc === "bool") { const raw = B.tmp(); B.line(`${raw} = zext i1 ${value} to i64`); - B.line(`store i64 ${raw}, ptr ${this.boxSlot(box)}`); + B.line( + `store i64 ${raw}, ptr ${this.boxSlot(box)}${this.memoryAliasAttachment("box:scalar")}`, + ); } else { this.declare(`declare void @scr_box_set_ref(ptr, ptr)`); B.line(`call void @scr_box_set_ref(ptr ${box}, ptr ${value})`); diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts index f0aa3b276..4bcf2a3d5 100644 --- a/packages/compiler/src/backend/llvm/expr-calls.ts +++ b/packages/compiler/src/backend/llvm/expr-calls.ts @@ -29,7 +29,6 @@ import { mangleClassStruct, mangleFnClosure, mangleFunction, - mangleWrapper, mangleVtStruct, mangleAsyncDirectSpawn, } from "../mangle.js"; @@ -839,21 +838,9 @@ export function emitCallExpr( host.declare("declare void @scr_dyn_this_push_dyn(ptr)"); B.line(`call void @scr_dyn_this_push_dyn(ptr ${receiver?.name ?? "null"})`); }; - const knownName = - e.callee.kind === "closure" - ? e.callee.fnName - : e.callee.kind === "varRef" - ? host.currentConstantCallbacks.get(e.callee.localId) - : undefined; - const candidate = knownName ? host.fnByName.get(knownName) : undefined; - const known = candidate && !candidate.async && !candidate.generator ? candidate : undefined; - let direct: string | undefined; - if (known) { - if (known.captures === undefined) { - host.fnValues.add(known.name); - direct = `@${mangleWrapper(known.name)}`; - } else direct = `@${host.callTarget(known.name)}`; - } + const target = host.knownCallbackTarget(e.callee); + const known = target?.known; + const direct = target?.direct; // A known closure body that borrows reference parameters is entered // directly with lent arguments, as a direct call to a borrowing body // is: an argument is read without a reference when neither a later @@ -939,7 +926,13 @@ export function emitCallExpr( let pending = host.indirectMayThrow; let pendingGuard: string | undefined; let t: string; - if (direct && e.callee.kind === "closure") { + // A literal closure, or a binding an enclosing callback loop version + // proved to hold its known function, enters it directly. + if ( + direct && + (e.callee.kind === "closure" || + (e.callee.kind === "varRef" && host.knownCallbacks.has(e.callee.localId))) + ) { t = invoke(lending ?? direct, !host.receiverReaders.ignores(known!)); if (quiet) pending = false; } else { diff --git a/packages/compiler/test/callback-loop-versions.test.ts b/packages/compiler/test/callback-loop-versions.test.ts new file mode 100644 index 000000000..c26abe1c4 --- /dev/null +++ b/packages/compiler/test/callback-loop-versions.test.ts @@ -0,0 +1,83 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile } from "../src/index.js"; + +async function emitLlvm(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-callback-versions-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ll"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "llvm" }); + if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n")); + return await readFile(outPath, "utf8"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +const body = (llvm: string, marker: string): string => { + const match = new RegExp(`^define internal [^\\n]*${marker}[^]*?^}`, "m").exec(llvm); + if (!match) throw new Error(`no function ${marker}`); + return match[0]; +}; + +test("loops that call a known callback test its closure once and call it directly", async () => { + const llvm = await emitLlvm(` + function each(values: number[], visit: (v: number) => void): void { + for (let i = 0; i < values.length; i++) visit(values[i]!); + } + let sum = 0; + each([1, 2, 3], (v) => { sum += v; }); + console.log(sum); + `); + const fn = body(llvm, "@sc_bf_each"); + const fast = /^callback\.version\.fast\d+:$[^]*?^callback\.version\.done\d+:$/m.exec(fn)![0]; + // The guard compares the closure's code pointer once, before the loop. + expect(fn.slice(0, fn.indexOf(fast))).toMatch(/icmp eq ptr %\S+, @/); + expect(fast).not.toContain("callback.indirect"); + expect(fast).not.toContain("scr_dyn_this_push_dyn"); + // The ordinary copy keeps the fallback through the closure's pointer. + expect(fn).toContain("callback.indirect"); +}); + +test("captured number payloads carry their own alias tag", async () => { + const llvm = await emitLlvm(` + function total(values: number[]): number { + let sum = 0; + values.forEach((v) => { sum += v; }); + return sum; + } + console.log(total([1, 2, 3])); + `); + // Every read and write through a box's payload slot (field 5) is tagged. + const slots = [ + ...llvm.matchAll(/(%\S+) = getelementptr inbounds %ScrBox, ptr %\S+, i32 0, i32 5/g), + ]; + expect(slots.length).toBeGreaterThan(0); + for (const [, slot] of slots) { + for (const access of llvm.matchAll( + new RegExp(`(load double, ptr|store double %\\S+, ptr) \\${slot}\\b[^\\n]*`, "g"), + )) + expect(access[0]).toMatch(/!tbaa !\d+$/); + } + expect(llvm).toMatch(/load double, ptr %\S+, !tbaa !\d+/); +}); + +test("a callback declared in the loop body is not read before the loop", async () => { + const llvm = await emitLlvm(` + function strides(): number { + let calls = 0; + for (let i = 0; i < 10; i++) { + const bump = (): void => { i += 2; }; + bump(); + calls++; + } + return calls; + } + console.log(strides()); + `); + expect(llvm).not.toContain("callback.version"); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7300-callback-captured-scalars.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7300-callback-captured-scalars.ts.json new file mode 100644 index 000000000..dc1158934 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7300-callback-captured-scalars.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7300-callback-captured-scalars.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7300-callback-captured-scalars.ts b/tests/corpus/7300-callback-captured-scalars.ts new file mode 100644 index 000000000..c8e8b28bd --- /dev/null +++ b/tests/corpus/7300-callback-captured-scalars.ts @@ -0,0 +1,147 @@ +// Callbacks that update captured `let` numbers and booleans from loops: typed +// array and array methods, an authored higher-order function called with one +// function at several calls and with two functions at one, closures of one +// function over different bindings, a callback that throws part way, nested +// callbacks, callbacks declared in the loop body, and reads after the loop. + +function each(values: number[], visit: (v: number, i: number) => void): void { + for (let i = 0; i < values.length; i++) visit(values[i]!, i); +} + +function until(limit: number, step: (i: number) => boolean): number { + let i = 0; + while (i < limit) { + if (!step(i)) break; + i++; + } + return i; +} + +function twice(values: Int16Array, f: (v: number) => void): void { + let i = 0; + do { + for (let j = 0; j < values.length; j++) f(values[j]!); + i++; + } while (i < 2); +} + +const ints = new Int32Array([5, -3, 8, 0, 12, -7, 4]); +let total = 0; +let negatives = 0; +let sawZero = false; +ints.forEach((v) => { + total = (total + v) | 0; + if (v < 0) negatives++; + if (v === 0) sawZero = true; +}); +console.log("forEach", total, negatives, sawZero); + +let max = -Infinity; +let maxAt = -1; +const floats = new Float64Array([0.5, 9.25, -2, 9.25, 3]); +floats.forEach((v, i) => { + if (v > max) { + max = v; + maxAt = i; + } +}); +console.log("max", max, maxAt); + +let calls = 0; +const doubled = ints.map((v) => { + calls++; + return v * 2 + calls; +}); +console.log("map", doubled.join(","), calls); + +let seen = 0; +const big = [3, 14, 15, 92, 65].filter((v) => { + seen += v; + return v > 10; +}); +console.log("filter", big.join(","), seen); + +let sum = 0; +let count = 0; +const summing = (v: number): void => { + sum += v; + count++; +}; +each([1, 2, 3], summing); +each([10, 20], summing); +console.log("each shared", sum, count); + +let a = 0; +let b = 0; +each([1, 2], (v) => { + a += v; +}); +each([1, 2], (v, i) => { + b += v * (i + 1); +}); +console.log("each two", a, b); + +const counters: (() => number)[] = []; +for (let k = 0; k < 3; k++) { + let local = k * 100; + each([1, 1, 1], () => { + local++; + }); + counters.push(() => local); +} +console.log("closures", counters.map((c) => c()).join(",")); + +let steps = 0; +console.log("until", until(10, (i) => (steps += i, steps < 12)), steps); + +let shorts = 0; +twice(new Int16Array([7, -8, 9]), (v) => { + shorts = shorts * 2 + v; +}); +console.log("twice", shorts); + +let partial = 0; +try { + floats.forEach((v) => { + if (v < 0) throw new RangeError(`at ${partial}`); + partial += v; + }); +} catch (error) { + console.log("throw", (error as Error).message, partial); +} + +let outer = 0; +let inner = 0; +[1, 2, 3].forEach((x) => { + outer += x; + ints.forEach((y) => { + if (y > x) inner++; + }); +}); +console.log("nested", outer, inner); + +let flag = false; +let flips = 0; +new Uint8Array([1, 0, 1, 1]).forEach((v) => { + if (v === 1) flag = !flag; + if (flag) flips++; +}); +console.log("bool", flag, flips); + +// Callbacks declared in the loop body: each iteration binds its own. +let strides = 0; +for (let i = 0; i < 10; i++) { + const bump = (): void => { + i += 2; + strides++; + }; + bump(); +} +let countdown = 0; +let k = 3; +while (k > 0) { + const tick = (n: number): number => (n > 0 ? tick(n - 1) + 1 : 0); + countdown += tick(k); + k--; +} +console.log("declared in loop", strides, countdown); diff --git a/tests/harness/corpus-costs/7300-callback-captured-scalars.ts.json b/tests/harness/corpus-costs/7300-callback-captured-scalars.ts.json new file mode 100644 index 000000000..18495cd4e --- /dev/null +++ b/tests/harness/corpus-costs/7300-callback-captured-scalars.ts.json @@ -0,0 +1 @@ +{ "plain": 6000, "san": 7900 } From f0c48e1b399cb7aa44ddfad988b678f9c6c3154d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 08:03:32 -0500 Subject: [PATCH 06/19] Bound compiled regex reads by the characters left in the subject The 16-character searches (the first-character search, the start filter and greedy class loops) and literal runs checked `i + n <= len` in 32-bit arithmetic. Subjects may be up to 2^31 - 2 characters, so near that length the sum wrapped negative, passed the check and read past the subject. Each check now compares the characters left (`len - i`, which cannot wrap) with the characters it reads, and advances the position only after it passes. --- .../src/backend/llvm/regex-matchers.ts | 28 +++--- .../test/regex-matcher-bounds.test.ts | 91 +++++++++++++++++++ 2 files changed, 106 insertions(+), 13 deletions(-) create mode 100644 packages/compiler/test/regex-matcher-bounds.test.ts diff --git a/packages/compiler/src/backend/llvm/regex-matchers.ts b/packages/compiler/src/backend/llvm/regex-matchers.ts index 77f8a06eb..d83d6bae5 100644 --- a/packages/compiler/src/backend/llvm/regex-matchers.ts +++ b/packages/compiler/src/backend/llvm/regex-matchers.ts @@ -882,16 +882,13 @@ class RxGen { this.block(head); const i = this.tmp(), i16 = this.tmp(), + rest = this.tmp(), fits = this.tmp(); this.line(`${i} = phi i32 [ ${a}, %seek ], [ ${i16}, %${next} ]`); - this.line(`${i16} = add i32 ${i}, 16`); - if (reach === 0) { - this.line(`${fits} = icmp sle i32 ${i16}, %len`); - } else { - const last = this.tmp(); - this.line(`${last} = add i32 ${i16}, ${reach}`); - this.line(`${fits} = icmp sle i32 ${last}, %len`); - } + // The characters left (0 <= i <= len, so this cannot wrap; i + 16 + // can for subjects near 2^31 characters). + this.line(`${rest} = sub i32 %len, ${i}`); + this.line(`${fits} = icmp sge i32 ${rest}, ${16 + reach}`); this.line(`br i1 ${fits}, label %${load}, label %${tail}`); this.block(load); const mask = filter !== null ? this.testPrefix(filter, i) : this.testVector(plan!, i), @@ -899,6 +896,7 @@ class RxGen { this.line(`${any} = icmp ne i16 ${mask}, 0`); this.line(`br i1 ${any}, label %${hit}, label %${next}`); this.block(next); + this.line(`${i16} = add i32 ${i}, 16`); this.line(`br label %${head}`); this.block(hit); const k = this.lowestBit(mask), @@ -1149,10 +1147,10 @@ class RxGen { check = this.label("chk"); this.block(entry); const p = this.loadPos(), - end = this.tmp(), + rest = this.tmp(), inb = this.tmp(); - this.line(`${end} = add i32 ${p}, ${sets.length}`); - this.line(`${inb} = icmp sle i32 ${end}, %len`); + this.line(`${rest} = sub i32 %len, ${p}`); + this.line(`${inb} = icmp sge i32 ${rest}, ${sets.length}`); this.line(`br i1 ${inb}, label %${check}, label %fail`); this.block(check); let acc = ""; @@ -1171,6 +1169,8 @@ class RxGen { acc = both; } } + const end = this.tmp(); + this.line(`${end} = add i32 ${p}, ${sets.length}`); // Storing before the branch is safe: every resume sets the position. this.storePos(end); this.line(`br i1 ${acc}, label %${succ}, label %fail`); @@ -1377,10 +1377,11 @@ class RxGen { this.block(vhead); const kv = this.tmp(), kv16 = this.tmp(), + left = this.tmp(), fits = this.tmp(); this.line(`${kv} = phi i32 [ 0, %${entry} ], [ ${kv16}, %${vnext} ]`); - this.line(`${kv16} = add i32 ${kv}, 16`); - this.line(`${fits} = icmp sle i32 ${kv16}, ${lim}`); + this.line(`${left} = sub i32 ${lim}, ${kv}`); + this.line(`${fits} = icmp sge i32 ${left}, 16`); this.line(`br i1 ${fits}, label %${vload}, label %${tail}`); this.block(vload); const vat = this.tmp(); @@ -1390,6 +1391,7 @@ class RxGen { this.line(`${all} = icmp eq i16 ${mask}, -1`); this.line(`br i1 ${all}, label %${vnext}, label %${vexit}`); this.block(vnext); + this.line(`${kv16} = add i32 ${kv}, 16`); this.line(`br label %${vhead}`); this.block(vexit); const missing = this.tmp(); diff --git a/packages/compiler/test/regex-matcher-bounds.test.ts b/packages/compiler/test/regex-matcher-bounds.test.ts new file mode 100644 index 000000000..3e0ec7f1b --- /dev/null +++ b/packages/compiler/test/regex-matcher-bounds.test.ts @@ -0,0 +1,91 @@ +/* Compiled regex matchers bound every read by the characters left in the + * subject, so subjects near the runtime's 2^31 - 2 character limit are + * read only inside the subject. Each matcher runs on a subject of exactly + * that length whose last byte sits just before an inaccessible page: a + * bounds check that wraps past INT32_MAX reads that page and faults. The + * subject is a lazily mapped zero region (no memory is committed for it) + * with the characters each case needs written near its end. */ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compileRegexMatcher, REGEX_MATCHER_DECLS } from "../src/backend/llvm/regex-matchers.js"; + +const LIMIT = 2 ** 31 - 2; + +/** [symbol, pattern, flags, start offset from the end, tail text]. */ +const CASES: [string, string, string, number, string][] = [ + // The first-character vector search, 10 characters before the end. + ["seek", "[b-d]", "", 10, "c"], + // The start filter (two 8-character alternatives), 20 before the end. + ["filter", "agggtaaa|tttaccct", "i", 20, "TTTACCCT"], + // A run of 8 literal characters beginning 2 before the end. + ["run", "xy234567", "", 30, "xy"], + // A greedy class over the whole subject, 16 characters per step. + ["greedy", "[^x]+", "", LIMIT, ""], +]; + +const DRIVER = ` +#include +#include +#include +#include +#include +void scr_regex_stack_grow(void *a, void *b, void *c, int32_t d) { (void)a; (void)b; (void)c; (void)d; _exit(3); } +void scr_regex_stack_free(void *p) { (void)p; } +${CASES.map(([sym]) => `int32_t ${sym}(const char *, int32_t, int32_t, const char **);`).join("\n")} +static void check(const char *name, int32_t (*m)(const char *, int32_t, int32_t, const char **), + int32_t start, const char *tail) { + const int32_t len = ${LIMIT}; + size_t page = (size_t)sysconf(_SC_PAGESIZE); + size_t body = ((size_t)len + page - 1) / page * page; + char *base = mmap(NULL, body + page, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0); + if (base == MAP_FAILED || mprotect(base + body, page, PROT_NONE) != 0) _exit(4); + char *s = base + (body - (size_t)len); + memcpy(s + len - strlen(tail), tail, strlen(tail)); + const char *out[16] = {0}; + int32_t found = m(s, len, start, out); + if (found) printf("%s %ld %ld\\n", name, (long)(out[0] - s), (long)(out[1] - s)); + else printf("%s none\\n", name); + fflush(stdout); + munmap(base, body + page); +} +int main(void) { +${CASES.map(([sym, , , back, tail]) => ` check("${sym}", ${sym}, ${LIMIT - back}, ${JSON.stringify(tail)});`).join("\n")} + return 0; +} +`; + +test.skipIf(process.platform === "win32")( + "compiled regex matchers stay inside subjects near 2^31 characters", + { timeout: 120_000 }, + () => { + const lines: string[] = []; + const decls = new Set(REGEX_MATCHER_DECLS); + for (const [sym, pattern, flags] of CASES) { + const code = compileRegexMatcher(sym, pattern, flags, "i64", true); + if (code === null) throw new Error(`/${pattern}/${flags} did not compile`); + lines.push(...code.lines.map((line) => line.replace(/^define internal /, "define "))); + for (const decl of code.decls) decls.add(decl); + } + const dir = mkdtempSync(join(tmpdir(), "scriptc-regex-bounds-")); + try { + const ir = join(dir, "matchers.ll"); + const driver = join(dir, "driver.c"); + const binary = join(dir, "bounds"); + writeFileSync(ir, [...decls, ...lines, "attributes #0 = { nounwind }", ""].join("\n")); + writeFileSync(driver, DRIVER); + execFileSync("clang", ["-O2", "-Wno-override-module", ir, driver, "-o", binary]); + const output = execFileSync(binary, { encoding: "utf8" }); + expect(output.trim().split("\n")).toEqual([ + `seek ${LIMIT - 1} ${LIMIT}`, + `filter ${LIMIT - 8} ${LIMIT}`, + "run none", + `greedy 0 ${LIMIT}`, + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, +); From 866c0bfea5a22bff19537bf33f8c375fc7d8823c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 08:05:45 -0500 Subject: [PATCH 07/19] Version typed-array loops whose index adds an unranged invariant term A row-major dot product, `const base = r * cols; for (let j = 0; j < cols; j++) s += w[base + j] * v[j]`, kept one index test per `w` read: the typed-array loop version guard covered an index `k * counter + terms + c` only when every loop-invariant term had a proven integer range, and `base` has none when `cols` is a parameter. The guard now also accepts a pure loop-invariant term without a proven range. It evaluates the term once and requires an exact integer (not -0) between -2^32 and 2^32, then checks the first and last index as for any affine index. In the fast copy the term has that range, so the index is integer arithmetic and the reads take no index test. Any other value runs the checked copy. Adds a Float32Array matrix-vector product with four accumulators per row to the typed-array-kernels workload. --- benchmarks/runtime/README.md | 2 +- .../runtime/workloads/typed-array-kernels.ts | 31 ++++++- .../backend/llvm/bytes-loop-versions.test.ts | 33 +++++++ .../src/backend/llvm/bytes-loop-versions.ts | 89 ++++++++++++++++--- ...-typed-array-invariant-index-terms.ts.json | 6 ++ .../6997-typed-array-invariant-index-terms.ts | 78 ++++++++++++++++ ...-typed-array-invariant-index-terms.ts.json | 1 + 7 files changed, 227 insertions(+), 13 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6997-typed-array-invariant-index-terms.ts.json create mode 100644 tests/corpus/6997-typed-array-invariant-index-terms.ts create mode 100644 tests/harness/corpus-costs/6997-typed-array-invariant-index-terms.ts.json diff --git a/benchmarks/runtime/README.md b/benchmarks/runtime/README.md index cbf47675a..1e01aa3a7 100644 --- a/benchmarks/runtime/README.md +++ b/benchmarks/runtime/README.md @@ -26,7 +26,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea | `region-builds` | cpu | a watch-mode build loop over `LifetimeRegion`: each build binds a program in a new region and releases the previous one, which a cache keeps referenced for two builds | | `template-render` | cpu | template literals, escaping, `map`/`join` | | `thread-slots`, `thread-slots-cache` | cpu | worker threads reading one published type graph from an `InternTable` and keeping per-thread state for each type they visit: a `ThreadSlots` family, or a direct-mapped cache by id in front of a `Map` by id (the `-cache` entry); under Node.js both are per thread | -| `typed-array-kernels` | cpu | element-wise loops that run to one typed array's length and index others at the same position: `Float64Array` axpy, `Float32Array` blend, `Uint8Array` mask, wrapping `Int32Array` add, `Uint8ClampedArray` brighten, RGBA to gray | +| `typed-array-kernels` | cpu | element-wise loops that run to one typed array's length and index others at the same position: `Float64Array` axpy, `Float32Array` blend, `Uint8Array` mask, wrapping `Int32Array` add, `Uint8ClampedArray` brighten, RGBA to gray, and a row-major `Float32Array` matrix-vector product | | `typed-array-scan`, `typed-array-scan-workers` | cpu | element-by-element scan loops over `Uint8Array`, `Uint16Array`, `Int32Array` and `Float64Array`; the second entry is a worker-enabled executable (it can start a worker), so each access also checks for shared storage | | `unicode-lexer` | cpu | a hand-written lexer over a 2.7M-unit bundled module whose comments and strings are in several languages: `charCodeAt` scans, line starts | | `validate-errors` | cpu | throw/catch on a fraction of inputs, small call chains | diff --git a/benchmarks/runtime/workloads/typed-array-kernels.ts b/benchmarks/runtime/workloads/typed-array-kernels.ts index ba68f7241..4fd2e7cd7 100644 --- a/benchmarks/runtime/workloads/typed-array-kernels.ts +++ b/benchmarks/runtime/workloads/typed-array-kernels.ts @@ -1,5 +1,6 @@ // Element-wise loops over several typed arrays: each loop runs to one -// array's length and reads or writes the others at the same index. +// array's length and reads or writes the others at the same index. Also a +// row-major matrix-vector product with four accumulators per row. function axpy(a: number, x: Float64Array, y: Float64Array, out: Float64Array): void { for (let i = 0; i < out.length; i++) out[i] = a * x[i]! + y[i]!; @@ -21,6 +22,23 @@ function brighten(src: Uint8ClampedArray, gain: number, out: Uint8ClampedArray): for (let i = 0; i < out.length; i++) out[i] = src[i]! * gain + 8; } +function matvec(w: Float32Array, x: Float32Array, out: Float32Array, cols: number): void { + for (let r = 0; r < out.length; r++) { + const base = r * cols; + let s0 = 0; + let s1 = 0; + let s2 = 0; + let s3 = 0; + for (let j = 0; j < cols; j += 4) { + s0 += w[base + j]! * x[j]!; + s1 += w[base + j + 1]! * x[j + 1]!; + s2 += w[base + j + 2]! * x[j + 2]!; + s3 += w[base + j + 3]! * x[j + 3]!; + } + out[r] = s0 + s1 + s2 + s3; + } +} + function gray(rgba: Uint8Array, out: Uint8Array): void { for (let i = 0; i < out.length; i++) { const j = i * 4; @@ -45,6 +63,10 @@ const io = new Int32Array(n); const pixels = new Uint8ClampedArray(n); const bright = new Uint8ClampedArray(n); const rgba = new Uint8Array(n * 4); +const side = 256; +const weights = new Float32Array(side * side); +const vector = new Float32Array(side); +const product = new Float32Array(side); const luma = new Uint8Array(n); let state = 2463534242; const next = (): number => { @@ -65,6 +87,8 @@ for (let i = 0; i < n; i++) { pixels[i] = next() & 255; } for (let i = 0; i < rgba.length; i++) rgba[i] = next() & 255; +for (let i = 0; i < weights.length; i++) weights[i] = (next() % 2001) / 1000 - 1; +for (let i = 0; i < side; i++) vector[i] = (next() % 1001) / 1000; const rounds = Math.round(1200 * scale); let f64Sum = 0; @@ -73,6 +97,7 @@ let byteSum = 0; let intSum = 0; let brightSum = 0; let lumaSum = 0; +let productSum = 0; for (let r = 0; r < rounds; r++) { // Change one input element per round so no round repeats the last. const at = (r * 7919) & (n - 1); @@ -82,12 +107,14 @@ for (let r = 0; r < rounds; r++) { ia[at] = r * 65537; pixels[at] = r & 255; rgba[at * 4] = r & 255; + vector[r & (side - 1)] = (r % 1001) / 1000; axpy(1.5, x, y, d); blend((r & 15) / 16, fa, fb, fo); mask(data, key, masked); addWrap(ia, ib, io); brighten(pixels, 0.75 + (r & 7) / 8, bright); gray(rgba, luma); + matvec(weights, vector, product, side); const probe = (r * 104729) & (n - 1); f64Sum += d[probe]!; f32Sum += fo[probe]!; @@ -95,6 +122,7 @@ for (let r = 0; r < rounds; r++) { intSum = (intSum + io[probe]!) | 0; brightSum += bright[probe]!; lumaSum += luma[probe]!; + productSum += product[probe & (side - 1)]!; } console.log(`float64 ${f64Sum.toFixed(3)}`); console.log(`float32 ${f32Sum.toFixed(3)}`); @@ -102,3 +130,4 @@ console.log(`uint8 ${byteSum}`); console.log(`int32 ${intSum}`); console.log(`clamped ${brightSum}`); console.log(`gray ${lumaSum}`); +console.log(`matvec ${productSum.toFixed(3)}`); diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts index 28194c7b3..ace8f294c 100644 --- a/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts +++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts @@ -134,6 +134,39 @@ test("a counted loop's affine accesses get one guard and exact operand ranges", expect(version.ranges.get(shifted)).toEqual({ min: 1, max: Number.MAX_SAFE_INTEGER }); }); +test("invariant terms without a range are tested by the guard", () => { + // for (let i = 0; i < n; i++) out[i] = src[k + i], with `k` a parameter + const shifted = bin("+", ref("k"), ref("i")); + const loop = counted([set(ref("out", f64s), ref("i"), get(ref("src", f64s), shifted))]); + const kernel = fn([loop]); + const version = match(kernel, loop)!; + expect([...version.guarded]).toEqual(["v1:k"]); + expect(version.affine.map((c) => c.terms.map((t) => t.key))).toEqual([[], ["v1:k"]]); + // In the fast copy the term has the range the guard tests. + expect(version.ranges.get(shifted.kind === "bin" ? shifted.left : shifted)).toEqual({ + min: -(2 ** 32), + max: 2 ** 32, + }); + const mod: IrModule = { + irVersion: 17, + sourceFile: loc.file, + entry: "__main", + functions: [ + { name: "__main", params: [], returnType: VOID, locals: [], body: [], loc }, + kernel, + ], + }; + const body = /^define internal [^\n]*@sc_bf_kernel[^]*?^}/m.exec(emitLlvmModule(mod))![0]; + const guard = body.slice(0, body.search(/^bytes\.version\.(fast|slow)\d+:$/m)); + // -2^32 <= k <= 2^32, and k is the double of its frozen integer. + expect(guard).toMatch(/fcmp oge double %\S+, 0xC1F0000000000000/); + expect(guard).toMatch(/fcmp ole double %\S+, 0x41F0000000000000/); + expect(guard).toMatch(/freeze i64/); + const fast = /^bytes\.version\.fast\d+:$[^]*?^bytes\.version\.done\d+:$/m.exec(body)![0]; + expect(fast).not.toContain("bytes.index"); + expect(body.slice(0, body.indexOf(fast))).toContain("bytes.index.invalid"); +}); + test("statically ranged indices compare one constant with the length", () => { const masked = bin("&", bin("^", ref("i"), ref("k")), num(255)); const loop = counted([set(ref("out", f64s), num(0), get(ref("table", i32s), masked))]); diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts index 8c7f668a0..c3cee3feb 100644 --- a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts +++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts @@ -7,6 +7,7 @@ import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; import { exactInteger, widenInteger } from "./integer-values.js"; import { emitCountedLoopLimit, emitCountedLoopSafety } from "./counted-loops.js"; import { emitBytesHeaderLength, emitBytesSharedStorage } from "./expr-bytes.js"; +import { f64Lit } from "./common.js"; type Node = IrExpr | IrStmt; type VarRef = IrExpr & { kind: "varRef" }; @@ -50,6 +51,9 @@ export interface BytesLoopVersion { ownedReceivers: number[]; fixed: FixedCheck[]; affine: AffineCheck[]; + /** Keys of affine terms the guard tests at run time: exact integers + * within GUARDED_TERM. */ + guarded: ReadonlySet; /** The counted loop whose iteration space the affine checks cover. */ counted: IntegerCountedForLoop | null; /** The guard also requires every counter value within [-bound, bound]. */ @@ -83,6 +87,9 @@ const MAX_FIXED_END = 2 ** 32; const MAX_CHECKS = 8; const MAX_OFFSET = 2 ** 31; const SAFE = Number.MAX_SAFE_INTEGER; +/** The range the guard requires of an invariant index term without a + * proven one: every index of a typed array of at most 2^32 elements. */ +const GUARDED_TERM: IntegerRange = { min: -(2 ** 32), max: 2 ** 32 }; const INVARIANT_OPS = new Set(["+", "-", "*", "&", "|", "^", "<<", ">>", ">>>"]); /** Loop versioning for typed-array bounds. A typed array's length and data @@ -95,7 +102,9 @@ const INVARIANT_OPS = new Set(["+", "-", "*", "&", "|", "^", "<<", ">>", ">>>"]) * `hi < length`, such as `table[x & 0xff]` or a constant-trip counter; * - an index `k * counter + invariant + c` of a counted loop needs its first * and last values in bounds, such as `out[base + i]` for `i < n` or - * `view.getUint32(i * 4)`. + * `view.getUint32(i * 4)`. An invariant without a proven integer range, + * such as `base` for `const base = r * cols`, must also be an exact + * integer between -2^32 and 2^32, which the guard tests. * * In worker programs, every access of a view that may be shared tests its * shared storage. A view's storage is also fixed at construction, so the @@ -198,9 +207,8 @@ export function matchBytesLoopVersion( } return invariant(e.localId) ? e : null; }; - // A pure integer operand evaluated again before the loop. - const invariantKey = (e: IrExpr): string | null => { - if (!facts.ranges.get(e)) return null; + // A pure loop-invariant number, evaluated again before the loop. + const pureKey = (e: IrExpr): string | null => { if (e.kind === "numLit") return Number.isSafeInteger(e.value) ? `n${e.value}` : null; if (e.kind === "varRef") return invariant(e.localId) ? `v${e.localId.length}:${e.localId}` : null; @@ -210,12 +218,27 @@ export function matchBytesLoopVersion( e.left.type.kind === "f64" && e.right.type.kind === "f64" ) { - const left = invariantKey(e.left), - right = invariantKey(e.right); + const left = pureKey(e.left), + right = pureKey(e.right); return left !== null && right !== null ? `(${e.op} ${left} ${right})` : null; } return null; }; + // Invariant terms without a proven range, such as `base` in + // `w[base + j]` with `const base = r * cols` for a parameter `cols`. The + // guard tests each one: an exact integer (not -0) within GUARDED_TERM. + const guardedRanges = new Map(); + const guardedNodes = new Map(); + // The key of a loop-invariant index term, with a proven range or guarded. + const invariantKey = (e: IrExpr): string | null => { + const key = pureKey(e); + if (key === null || facts.ranges.get(e)) return key; + if (e.kind === "numLit") return null; + guardedRanges.set(e, GUARDED_TERM); + guardedNodes.set(e, key); + return key; + }; + const termRange = (e: IrExpr): IntegerRange => facts.ranges.get(e) ?? guardedRanges.get(e)!; interface Affine { /** The counter's coefficient; 0 when the value is loop-invariant. */ counter: number; @@ -223,9 +246,10 @@ export function matchBytesLoopVersion( offset: number; } // Every operand of an affine index is `counter * k + Σ terms + offset`. - // Invariant terms need proven ranges. Operands that involve the counter - // need none: the guard bounds the counter so that each of them is a safe - // integer, which makes the index's double arithmetic exact. + // Invariant terms need proven ranges or the guard's test (guarded terms). + // Operands that involve the counter need none: the guard bounds the + // counter so that each of them is a safe integer, which makes the index's + // double arithmetic exact. const shapes = new Map(); const affine = (e: IrExpr): Affine | null => { if (shapes.has(e)) return shapes.get(e)!; @@ -309,7 +333,7 @@ export function matchBytesLoopVersion( }; const termMagnitude = (terms: Term[]): number => terms.reduce((sum, t) => { - const r = facts.ranges.get(t.expr)!; + const r = termRange(t.expr); return sum + Math.max(Math.abs(r.min), Math.abs(r.max)); }, 0); @@ -433,7 +457,7 @@ export function matchBytesLoopVersion( let min = Math.min(a, b) + shape.offset, max = Math.max(a, b) + shape.offset; for (const term of shape.terms) { - const r = facts.ranges.get(term.expr)!; + const r = termRange(term.expr); min += term.sign > 0 ? r.min : -r.max; max += term.sign > 0 ? r.max : -r.min; } @@ -447,6 +471,12 @@ export function matchBytesLoopVersion( ranges.set(operand, { min: Math.max(min, known.min), max: Math.min(max, known.max) }); } } + // Guarded terms of the kept checks, and the range the guard proves for + // every node of each in the fast copy. + const guarded = new Set(); + for (const group of affineKept) + for (const term of group.check.terms) if (guardedRanges.has(term.expr)) guarded.add(term.key); + for (const [node, key] of guardedNodes) if (guarded.has(key)) ranges.set(node, GUARDED_TERM); // Renumber the receivers the kept checks use. const order = [...used].sort((a, b) => a - b); const renumber = new Map(order.map((old, index) => [old, index])); @@ -470,6 +500,7 @@ export function matchBytesLoopVersion( minOffset: g.check.minOffset, maxEnd: g.check.maxEnd, })), + guarded, counted: affineKept.length > 0 ? counted : null, counterBound: needsBound ? bound : null, ranges, @@ -567,6 +598,14 @@ export function emitBytesLoopVersionGuard( let base = "0"; for (const term of check.terms) { const value = host.emitExpr(term.expr); + if (version.guarded.has(term.key)) { + const tested = emitGuardedTerm(host, value.name); + conditions.push(tested.ok); + const sum = B.tmp(); + B.line(`${sum} = ${term.sign > 0 ? "add" : "sub"} i64 ${base}, ${tested.integer}`); + base = sum; + continue; + } const integer = exactInteger(host, value, term.expr); if (!integer) { // Unreachable: every term has a proven range. Stay checked. @@ -614,6 +653,34 @@ export function emitBytesLoopVersionGuard( else B.condBr(valid, fast, slow); } +/** Tests that a guarded term is an exact integer within GUARDED_TERM (not + * -0; NaN fails the ordered comparisons) and gives its i64. The integer is + * frozen, so it is a fixed value even when the test fails. */ +function emitGuardedTerm(host: LlvmEmitterContext, value: string): { ok: string; integer: string } { + const B = host.B; + const low = B.tmp(), + high = B.tmp(), + inRange = B.tmp(), + raw = B.tmp(), + integer = B.tmp(), + back = B.tmp(), + bits = B.tmp(), + backBits = B.tmp(), + exact = B.tmp(), + ok = B.tmp(); + B.line(`${low} = fcmp oge double ${value}, ${f64Lit(GUARDED_TERM.min)}`); + B.line(`${high} = fcmp ole double ${value}, ${f64Lit(GUARDED_TERM.max)}`); + B.line(`${inRange} = and i1 ${low}, ${high}`); + B.line(`${raw} = fptosi double ${value} to i64`); + B.line(`${integer} = freeze i64 ${raw}`); + B.line(`${back} = sitofp i64 ${integer} to double`); + B.line(`${bits} = bitcast double ${value} to i64`); + B.line(`${backBits} = bitcast double ${back} to i64`); + B.line(`${exact} = icmp eq i64 ${bits}, ${backBits}`); + B.line(`${ok} = and i1 ${inRange}, ${exact}`); + return { ok, integer }; +} + function scaleCounter(host: LlvmEmitterContext, value: string, scale: number): string { if (scale === 1) return value; const scaled = host.B.tmp(); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6997-typed-array-invariant-index-terms.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6997-typed-array-invariant-index-terms.ts.json new file mode 100644 index 000000000..6d60d2635 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6997-typed-array-invariant-index-terms.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6997-typed-array-invariant-index-terms.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6997-typed-array-invariant-index-terms.ts b/tests/corpus/6997-typed-array-invariant-index-terms.ts new file mode 100644 index 000000000..53d9f10c0 --- /dev/null +++ b/tests/corpus/6997-typed-array-invariant-index-terms.ts @@ -0,0 +1,78 @@ +// Typed-array loops whose index adds a loop-invariant number with no known +// integer range, such as the row base of a matrix-vector product. Rows +// with integer, fractional, negative, -0 and NaN bases, windows at 2^32 and +// beyond, and matrices too short for their last rows, read and write as +// Node does. + +function matvec(w: Float32Array, v: Float32Array, out: Float32Array, cols: number): void { + for (let r = 0; r < out.length; r++) { + const base = r * cols; + let s0 = 0, s1 = 0, s2 = 0, s3 = 0; + for (let j = 0; j < cols; j += 4) { + s0 += w[base + j]! * v[j]!; + s1 += w[base + j + 1]! * v[j + 1]!; + s2 += w[base + j + 2]! * v[j + 2]!; + s3 += w[base + j + 3]! * v[j + 3]!; + } + out[r] = s0 + s1 + s2 + s3; + } +} + +function rowSums(m: Float64Array, rows: number, cols: number, first: number): number[] { + const sums: number[] = []; + for (let r = first; r < first + rows; r++) { + let s = 0; + for (let c = 0; c < cols; c++) s += m[r * cols + c]!; + sums.push(s); + } + return sums; +} + +function shiftedCopy(src: Int16Array, out: Int16Array, offset: number): void { + for (let i = 0; i < out.length; i++) out[i] = src[i - offset]! + 1; +} + +function window(src: Uint8Array, start: number, k: number): number { + let s = 0; + for (let i = 0; i < k; i++) s += src[start + i]! * (i + 1); + return s; +} + +const rows = 6, cols = 8; +const w = new Float32Array(rows * cols); +for (let i = 0; i < w.length; i++) w[i] = ((i * 37) % 23) / 4 - 2; +const v = new Float32Array(cols); +for (let i = 0; i < cols; i++) v[i] = (i % 5) - 1.5; +const out = new Float32Array(rows); +matvec(w, v, out, cols); +console.log("matvec", out.join(",")); +const tall = new Float32Array(rows + 2); +matvec(w, v, tall, cols); +console.log("matvec past rows", tall.join(",")); +const odd = new Float32Array(3); +matvec(w, v, odd, 6.5); +console.log("matvec fractional cols", odd.join(",")); +matvec(w, v, odd, NaN); +console.log("matvec NaN cols", odd.join(",")); + +const m = new Float64Array(30); +for (let i = 0; i < m.length; i++) m[i] = i * 1.5 - 7; +console.log("rows", rowSums(m, 5, 6, 0).join(",")); +console.log("rows from -1", rowSums(m, 3, 6, -1).join(",")); +console.log("rows fractional", rowSums(m, 2, 2.5, 1).join(",")); +console.log("rows past end", rowSums(m, 3, 6, 4).join(",")); +console.log("rows -0 base", rowSums(m, 1, 6, -0).join(",")); + +const src16 = new Int16Array([10, 20, 30, 40, 50, 60]); +const shifted = new Int16Array(6); +shiftedCopy(src16, shifted, 2); +console.log("shifted", shifted.join(",")); +shiftedCopy(src16, shifted, -3); +console.log("shifted back", shifted.join(",")); +shiftedCopy(src16, shifted, 0.5); +console.log("shifted half", shifted.join(",")); + +const bytes = new Uint8Array(40); +for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 29) & 255; +console.log("window", window(bytes, 3, 8), window(bytes, 36, 8), window(bytes, -2, 4)); +console.log("window far", window(bytes, 2 ** 32, 3), window(bytes, 2 ** 32 + 1, 3), window(bytes, 2 ** 40, 2), window(bytes, -(2 ** 32), 2)); diff --git a/tests/harness/corpus-costs/6997-typed-array-invariant-index-terms.ts.json b/tests/harness/corpus-costs/6997-typed-array-invariant-index-terms.ts.json new file mode 100644 index 000000000..ed05a3c5d --- /dev/null +++ b/tests/harness/corpus-costs/6997-typed-array-invariant-index-terms.ts.json @@ -0,0 +1 @@ +{ "plain": 3400, "san": 4800 } From e4a9940e5b4d45170992c3094cd7888dbf064fdb Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 08:07:12 -0500 Subject: [PATCH 08/19] Finish bailed speculative iterations outside the ordinary loop copy A speculative integer loop's failed check used to continue at the failed statement inside the ordinary copy. That gave the ordinary copy a second entry, so LLVM saw an irreducible cycle and did not hoist its invariant loads or keep its values in registers. A loop whose check fails on every entry, such as an inner loop over fractional fields, then ran every iteration in that unoptimized copy. A failed check now continues in a third copy that is entered only from the fast copy: it re-executes the failed statement, the rest of the body and the update, then jumps to the ordinary copy's header. The ordinary copy has a single entry and stays a natural loop. Each loop site also counts its entries in a row whose fast copy bailed. A fast copy that completes a speculated write and leaves the loop clears the count, and after 8 bailing entries in a row the site runs the ordinary copy only. The count is a hint, read and written with unordered atomics: both copies give the same results. --- .../src/backend/llvm/emit-statements.ts | 100 ++++++++++++++-- packages/compiler/src/backend/llvm/emitter.ts | 11 ++ .../test/speculative-loop-emission.test.ts | 85 ++++++++++++++ .../7321-speculative-loop-bail-sites.ts.json | 6 + .../7321-speculative-loop-bail-sites.ts | 111 ++++++++++++++++++ .../7321-speculative-loop-bail-sites.ts.json | 1 + 6 files changed, 304 insertions(+), 10 deletions(-) create mode 100644 packages/compiler/test/speculative-loop-emission.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7321-speculative-loop-bail-sites.ts.json create mode 100644 tests/corpus/7321-speculative-loop-bail-sites.ts create mode 100644 tests/harness/corpus-costs/7321-speculative-loop-bail-sites.ts.json diff --git a/packages/compiler/src/backend/llvm/emit-statements.ts b/packages/compiler/src/backend/llvm/emit-statements.ts index 7239db7d8..a22de3445 100644 --- a/packages/compiler/src/backend/llvm/emit-statements.ts +++ b/packages/compiler/src/backend/llvm/emit-statements.ts @@ -803,6 +803,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { const lp = workerBudget !== null ? B.newLabel("loop.p") : lc; B.br(lc); B.startBlock(lc); + enterLoopHeader(emitter, lc); B.condBr(emitter.emitCondition(s.cond), lb, exit); B.startBlock(lb); emitter.jumpTargets.push({ @@ -835,6 +836,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { const lc = B.newLabel("loop.c"); B.br(lb); B.startBlock(lb); + enterLoopHeader(emitter, lb); emitter.jumpTargets.push({ kind: "loop", brkLabel: exit, @@ -1135,6 +1137,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { s.update || capturedLets.length > 0 || workerBudget !== null ? B.newLabel("loop.u") : lc; B.br(lc); B.startBlock(lc); + enterLoopHeader(emitter, lc); if (integerLoop && integerSlot) { const receiver = emitter.emitStableReceiver(integerLoop.limitReceiver, []); const lenPtr = B.tmp(); @@ -1885,6 +1888,10 @@ export function emitSwitch(emitter: LlEmitter, s: IrStmt & { kind: "switch" }): B.startBlock(end); } +/** Entries in a row whose fast copy bailed before a speculative loop site + * stops trying it. */ +const SPECULATION_BAILS = 8; + /** Emit a loop's condition, body and update through `emitCopy`, twice * when speculative-integers.ts plans a fast integer copy. Both copies * share the loop's setup and exit; the ordinary copy comes first so the @@ -1939,23 +1946,66 @@ export function emitLoopCopies( ok = both; } } + // Each entry whose fast copy bails counts at the loop site, and a fast + // copy that completes a speculated write and leaves the loop clears the + // count. After SPECULATION_BAILS bailing entries in a row, the site runs + // the ordinary copy only. The count is a hint: both copies give the same + // results, and a race between workers loses at most an update. + const bails = `@scr.spec.bails.${emitter.speculativeLoopSites++}`; + emitter.declare(`${bails} = internal global i8 0`); + const count = B.tmp(), + trying = B.tmp(); + B.line(`${count} = load atomic i8, ptr ${bails} unordered, align 1`); + B.line(`${trying} = icmp ult i8 ${count}, ${SPECULATION_BAILS}`); + if (ok === "true") ok = trying; + else { + const both = B.tmp(); + B.line(`${both} = and i1 ${ok}, ${trying}`); + ok = both; + } const fast = B.newLabel("spec.fast"), slow = B.newLabel("spec.slow"); B.condBr(ok, fast, slow); const resume = new Map(); for (const s of plan.writes) resume.set(s, { label: B.newLabel("spec.resume"), placed: false }); + // A failed check finishes its iteration in a third copy, entered only + // there: it re-executes the statement and the rest of the body and the + // update, then enters the ordinary copy through its header. Jumping + // into the middle of the ordinary copy instead would give that loop a + // second entry, and LLVM does not optimize an irreducible loop. + const points = [...resume.values()]; + let header: string | null = null; B.startBlock(slow); + emitter.speculationHeader = (label) => { + header = label; + }; + try { + emitCopy(exit); + } finally { + emitter.speculationHeader = null; + } + if (header === null) + throw new InternalCompilerError("llvm emitter bug: speculative loop copy without a header"); + B.startBlock(B.newLabel("spec.tail")); emitter.speculationResume = resume; + emitter.speculationHeader = () => { + B.br(header!); + B.startBlock(B.newLabel("spec.unused")); + }; try { emitCopy(exit); } finally { emitter.speculationResume = null; + emitter.speculationHeader = null; } B.startBlock(fast); - if (![...resume.values()].every((r) => r.placed)) { + if (!points.every((r) => r.placed)) { B.br(slow); return; } + const ran = B.slot(); + B.entryAllocas.push(`${ran} = alloca i1 ; speculated write completed`); + B.line(`store i1 false, ptr ${ran}`); for (const id of plan.locals.keys()) { const shadow = B.slot(); B.entryAllocas.push( @@ -1992,22 +2042,43 @@ export function emitLoopCopies( writes: plan.writes, resume: new Map([...resume].map(([s, r]) => [s, r.label])), deferred, + bails, + ran, }; try { - if (deferred.size === 0) emitCopy(exit); - else { - const leave = B.newLabel("spec.leave"); - emitCopy(leave); - B.startBlock(leave); - emitter.writeBackDeferred(deferred); - B.br(exit); - } + const leave = B.newLabel("spec.leave"); + emitCopy(leave); + B.startBlock(leave); + emitter.writeBackDeferred(deferred); + // An entry that never ran a speculated write (a loop of no iterations) + // says nothing about the site. + const wrote = B.tmp(), + left = B.tmp(), + counted = B.tmp(), + both = B.tmp(), + clear = B.newLabel("spec.clear"); + B.line(`${wrote} = load i1, ptr ${ran}`); + B.line(`${left} = load atomic i8, ptr ${bails} unordered, align 1`); + B.line(`${counted} = icmp ne i8 ${left}, 0`); + B.line(`${both} = and i1 ${wrote}, ${counted}`); + B.condBr(both, clear, exit); + B.startBlock(clear); + B.line(`store atomic i8 0, ptr ${bails} unordered, align 1`); + B.br(exit); } finally { emitter.speculation = null; for (const id of plan.locals.keys()) emitter.integerLoopBindings.delete(id); } } +/** At the top of a loop copy's header: lets a speculative loop find its + * ordinary copy's header, and keeps its third copy out of the loop. */ +function enterLoopHeader(emitter: LlEmitter, header: string): void { + const dispatch = emitter.speculationHeader; + emitter.speculationHeader = null; + dispatch?.(header); +} + /** A proven integer loop (speculative-integers.ts) for this loop, or null. * A shadowed local's double is stale until the loop is left, which only * the loop's exit, a return and an exception do: the loop is planned only @@ -2134,7 +2205,8 @@ export function emitSpeculativeWrite( if (emitter.frames.pop()!.length > 0) throw new InternalCompilerError("llvm emitter bug: speculative write owns a temporary"); B.line(`store i64 ${result}, ptr ${binding.slot}`); - const deferred = emitter.speculation!.deferred; + const { deferred, bails: site, ran } = emitter.speculation!; + if (ran !== undefined) B.line(`store i1 true, ptr ${ran}`); if (!deferred.has(target.localId)) { const number = B.tmp(); B.line(`${number} = sitofp i64 ${result} to double`); @@ -2145,6 +2217,14 @@ export function emitSpeculativeWrite( B.br(done); B.startBlock(failed); emitter.writeBackDeferred(deferred); + if (site !== undefined) { + const count = B.tmp(), + next = B.tmp(); + emitter.declare("declare i8 @llvm.uadd.sat.i8(i8, i8)"); + B.line(`${count} = load atomic i8, ptr ${site} unordered, align 1`); + B.line(`${next} = call i8 @llvm.uadd.sat.i8(i8 ${count}, i8 1)`); + B.line(`store atomic i8 ${next}, ptr ${site} unordered, align 1`); + } B.br(resume!); B.startBlock(done); } diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index ed1785912..c1cf66af4 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1102,9 +1102,20 @@ export class LlEmitter { resume: Map; /** Globals whose double is written back only when the copy is left. */ deferred: Set; + /** The loop site's count of bailing entries in a row (a global), and + * whether this entry completed a speculated write (a slot). A proven + * integer loop has neither. */ + bails?: string; + ran?: string; } | null = null; + /** Speculative loop sites numbered so far in the module. */ + speculativeLoopSites = 0; /** The ordinary copy being emitted: resume labels still to place. */ speculationResume: Map | null = null; + /** Run once at the top of the next loop copy's header + * (`enterLoopHeader`): records the ordinary copy's header, or sends the + * third copy's header there. */ + speculationHeader: ((header: string) => void) | null = null; /** The function being emitted, for speculative globals. */ speculativeFunction = ""; /** Every function that writes each number global (computed on demand). */ diff --git a/packages/compiler/test/speculative-loop-emission.test.ts b/packages/compiler/test/speculative-loop-emission.test.ts new file mode 100644 index 000000000..78a7de295 --- /dev/null +++ b/packages/compiler/test/speculative-loop-emission.test.ts @@ -0,0 +1,85 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile } from "../src/index.js"; + +async function emitLlvm(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-speculative-loop-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ll"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "llvm" }); + expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true); + return await readFile(outPath, "utf8"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +const source = ` +class Point { + x: number; + w = 0; + constructor(x: number) { + this.x = x; + } +} +function scaled(points: Point[], scale: number): number { + let s = 0; + for (let i = 0; i < points.length; i++) { + const p = points[i]!; + p.w += 1; + const k = p.x * scale; + s += k * k; + } + return s; +} +console.log(scaled([new Point(1), new Point(2)], 2), scaled([new Point(1)], 0.5)); +`; + +function labels(fn: string): string[] { + return [...fn.matchAll(/^([\w.]+):/gm)].map((m) => m[1]!); +} + +test("a failed check finishes its iteration outside the ordinary copy", async () => { + const llvm = await emitLlvm(source); + const fn = /^define internal double @sc_bf_scaled\([^]*?^}/m.exec(llvm)?.[0] ?? ""; + const order = labels(fn); + const slow = order.findIndex((l) => l.startsWith("spec.slow")); + const tail = order.findIndex((l) => l.startsWith("spec.tail")); + const fast = order.findIndex((l) => l.startsWith("spec.fast")); + expect(slow).toBeGreaterThanOrEqual(0); + expect(tail).toBeGreaterThan(slow); + expect(fast).toBeGreaterThan(tail); + // Every resume point is in the third copy, between its start and the + // fast copy, so the ordinary copy has one entry. + const resumes = order.flatMap((l, i) => (l.startsWith("spec.resume") ? [i] : [])); + expect(resumes.length).toBeGreaterThan(0); + for (const i of resumes) { + expect(i).toBeGreaterThan(tail); + expect(i).toBeLessThan(fast); + } + // The third copy's header leaves for the ordinary copy's header. + const header = order[slow + 1]!; + const third = fn.slice(fn.indexOf(`\n${order[tail]}:`), fn.indexOf(`\n${order[fast]}:`)); + expect(third).toContain(`br label %${header}`); +}); + +test("a loop site stops entering its fast copy after repeated bails", async () => { + const llvm = await emitLlvm(source); + const site = /^(@scr\.spec\.bails\.\d+) = internal global i8 0$/m.exec(llvm)?.[1]; + expect(site).toBeDefined(); + const fn = /^define internal double @sc_bf_scaled\([^]*?^}/m.exec(llvm)?.[0] ?? ""; + const escaped = site!.replace(/\./g, "\\."); + // The entry test reads the count, a bail adds one, and a completed fast + // copy clears it. + expect(fn).toMatch( + new RegExp( + `(%\\S+) = load atomic i8, ptr ${escaped} unordered, align 1\\n\\s*%\\S+ = icmp ult i8 \\1, 8`, + ), + ); + expect(fn).toMatch(new RegExp(`call i8 @llvm\\.uadd\\.sat\\.i8\\(i8 %\\S+, i8 1\\)`)); + expect(fn).toContain(`store atomic i8 0, ptr ${site} unordered, align 1`); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7321-speculative-loop-bail-sites.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7321-speculative-loop-bail-sites.ts.json new file mode 100644 index 000000000..113eeedb7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7321-speculative-loop-bail-sites.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7321-speculative-loop-bail-sites.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7321-speculative-loop-bail-sites.ts b/tests/corpus/7321-speculative-loop-bail-sites.ts new file mode 100644 index 000000000..460595feb --- /dev/null +++ b/tests/corpus/7321-speculative-loop-bail-sites.ts @@ -0,0 +1,111 @@ +// Speculative integer loops whose checks fail: on the first statement of +// every entry (fractional field data), after an effect earlier in the same +// iteration, before a `continue`, `break` or `return`, and on entries +// that alternate between integer and fractional data. A site that keeps +// failing stops trying its integer copy; every result is Node's. + +class Point { + x: number; + y: number; + w = 0; + + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } +} + +function show(label: string, value: number): void { + console.log(label, Object.is(value, -0) ? "-0" : String(value)); +} + +// Each inner entry fails at its first statement when the fields hold +// fractions, and runs no iteration at all for the last point. +function pairs(points: Point[]): number { + let total = 0; + for (let i = 0; i < points.length; i++) { + const p = points[i]!; + let acc = p.w; + for (let j = i + 1; j < points.length; j++) { + const q = points[j]!; + const dx = p.x - q.x; + const dy = p.y - q.y; + const d2 = dx * dx + dy * dy; + acc += d2; + q.w += d2 * 0.5; + } + p.w = acc; + total += acc; + } + return total; +} + +const fractional = [ + new Point(0.5, 1), + new Point(1.25, -2), + new Point(3, 4.75), + new Point(-1.5, 0), +]; +const integral = [new Point(0, 1), new Point(2, -2), new Point(3, 4), new Point(-1, 0)]; +for (let round = 0; round < 12; round++) { + show(`pairs fractional ${round}`, pairs(fractional)); +} +// The same site, now with integer data after it stopped speculating. +show("pairs integral", pairs(integral)); +show("pairs integral again", pairs(integral)); + +// A field write comes before the failing statement: the iteration resumes +// after it and must not repeat it. +function afterEffect(points: Point[], scale: number): number { + let s = 0; + for (let i = 0; i < points.length; i++) { + const p = points[i]!; + p.w += 1; + const k = p.x * scale; + s += k * k; + } + return s; +} +const marks = [new Point(1, 0), new Point(2, 0), new Point(3, 0)]; +show("after effect 2", afterEffect(marks, 2)); +show("after effect 0.5", afterEffect(marks, 0.5)); +show("after effect 3", afterEffect(marks, 3)); +console.log(marks.map((p) => p.w).join(" ")); + +// The failing statement is followed by `continue`, `break` and `return`. +function control(n: number, step: number): number { + let acc = 0; + let i = 0; + while (i < n) { + const v = i * step; + i++; + if (v * v > 400) break; + if ((i & 1) === 0) continue; + acc += v * 3; + if (acc > 1000) return -acc; + } + return acc; +} +for (const step of [1, 0.5, 2, 0.75, 7, 1.5, 3, 0.25, 5, 1.75, 4]) { + show(`control ${step}`, control(40, step)); +} + +// do-while: the check fails part-way through, on some entries only. +function steps(start: number, factor: number): number { + let v = start; + let k = 0; + do { + v = v * factor - k; + k++; + } while (k < 6); + return v; +} +for (let r = 0; r < 20; r++) show(`steps ${r}`, steps(r % 3 === 0 ? r + 0.5 : r, 3)); + +// Overflow past 2^53 on later entries only. +function powers(base: number, n: number): number { + let p = 1; + for (let i = 0; i < n; i++) p = p * base; + return p; +} +for (const n of [3, 10, 40, 80, 5, 60, 2, 70, 1, 90, 0, 4]) show(`powers ${n}`, powers(3, n)); diff --git a/tests/harness/corpus-costs/7321-speculative-loop-bail-sites.ts.json b/tests/harness/corpus-costs/7321-speculative-loop-bail-sites.ts.json new file mode 100644 index 000000000..1e5609a66 --- /dev/null +++ b/tests/harness/corpus-costs/7321-speculative-loop-bail-sites.ts.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 2600 } From d406f633bdb2d609e8c9a85a107ff275fe46bf28 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 08:18:18 -0500 Subject: [PATCH 09/19] Keep the receiver index within what the native frontend lowers The receiver index read its methods or members table by a computed key and iterated generic method names through `?.keys() ?? []`. The native frontend refuses dynamic keyed reads of records and cannot convert the iterator-or-array union, so the complete frontend no longer lowered. The table is now selected with a conditional and the generic method names are iterated under a check. --- .../frontend/lowering/class-dynamic-dispatch.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index 79edae433..79deb2aeb 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -510,10 +510,11 @@ function receiverIndex(lowerer: Lowerer, plans: readonly PropertyReceiver[]): Re position, ); } - for (const name of c.genericMethods?.keys() ?? []) { - add(methods, name, position); - add(members, name, position); - } + if (c.genericMethods) + for (const name of c.genericMethods.keys()) { + add(methods, name, position); + add(members, name, position); + } } for (const name of info.fields.keys()) add(members, name, position); }); @@ -1430,9 +1431,8 @@ export class ClassDynamicDispatch { ): readonly PropertyReceiver[] => { if (name.startsWith("emit:")) return plans; const found = indexOf(plans); - return mergePositions(found[table].get(name) ?? [], found.rooted).map( - (position) => plans[position]!, - ); + const positions = (table === "methods" ? found.methods : found.members).get(name); + return mergePositions(positions ?? [], found.rooted).map((position) => plans[position]!); }; const candidates = (method: string): ClassInfo[] => { const found = byMethod.get(method); From 2f3e25b431fc13f0f8a08fdb0505b0c277dc734a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 08:35:56 -0500 Subject: [PATCH 10/19] Accept Buffer-compatible chunk annotations and keep super(undefined) in byte mode --- .../src/frontend/lowering/lower-stream.ts | 18 ++++++---- tests/corpus/7240-objectmode-streams.ts | 33 ++++++++++++++++- .../stream-byte-mode-chunk-fences.ts | 36 +++++++++++++++++++ .../stream-byte-mode-chunk-fences.ts.txt | 8 +++++ 4 files changed, 87 insertions(+), 8 deletions(-) create mode 100644 tests/diagnostics/stream-byte-mode-chunk-fences.ts create mode 100644 tests/harness/__snapshots__/stream-byte-mode-chunk-fences.ts.txt diff --git a/packages/compiler/src/frontend/lowering/lower-stream.ts b/packages/compiler/src/frontend/lowering/lower-stream.ts index cb81348eb..6540b3a99 100644 --- a/packages/compiler/src/frontend/lowering/lower-stream.ts +++ b/packages/compiler/src/frontend/lowering/lower-stream.ts @@ -312,11 +312,13 @@ function lowerStreamCallback( // A write or transform chunk of another type converts from the // chunk's value when the callback runs (an object-mode side's value), // or throws a TypeError there — on a side known to be byte mode the - // chunk is a Buffer, so the declared type is the fence's; an encoding - // of another type (`string | null | undefined`) converts from the - // encoding argument's value. + // chunk is a Buffer, so the declared type must be Node's or a + // supertype of it (`Buffer | string`); an encoding of another type + // (`string | null | undefined`) converts from the encoding argument's + // value. if ( - ((i === 0 && chunkValues) || i === 1) && + ((i === 0 && (chunkValues || widensInto(lowerer, fullTuple[0]!, funcType.params[0]!, loc))) || + i === 1) && (which === "write" || which === "transform") && funcType.params[i]!.kind !== "func" && canConvertToDyn( @@ -1369,11 +1371,13 @@ export function lowerStreamSuperCall( // set objectMode, and a literal says so with objectMode (or a Duplex's // writableObjectMode) true; otherwise _write and _transform receive // Buffers. + // super(undefined) and super(null) pass no options (followOptionsArg + // answers null), so their sides are byte mode too. const optsNode = argNodes[0] !== undefined ? followOptionsArg(lowerer, argNodes[0]) : null; const chunkValues = - argNodes[0] !== undefined && - (optsNode === null || - !ts.isObjectLiteralExpression(optsNode) || + optsNode !== null && + optsNode !== undefined && + (!ts.isObjectLiteralExpression(optsNode) || literalWritesValues(optsNode, cls !== "%Readable" && cls !== "%Writable")); const fallback = (name: string): IrExpr | null => { const methodName = UNDERSCORE_METHODS.get(name); diff --git a/tests/corpus/7240-objectmode-streams.ts b/tests/corpus/7240-objectmode-streams.ts index 9316ff480..47f799eee 100644 --- a/tests/corpus/7240-objectmode-streams.ts +++ b/tests/corpus/7240-objectmode-streams.ts @@ -2,7 +2,8 @@ // and Writable in pipeline(), typed 'data' listeners, a Transform with // readableObjectMode parsing bytes into records, an object-mode Duplex, // a PassThrough's object-counted high-water mark, and Readable.from over -// a Map, a Set of records and an array mixing strings and Buffers. +// a Map, a Set of records and an array mixing strings and Buffers, and +// byte-mode chunk parameters typed Buffer | string. import { Duplex, PassThrough, Readable, Transform, Writable } from "node:stream"; import { pipeline } from "node:stream/promises"; @@ -119,6 +120,34 @@ async function collections(): Promise { for await (const t of Readable.from(tags)) console.log("set", JSON.stringify(t)); } +// Byte-mode sides pass Buffers: a chunk parameter typed as a supertype +// of Buffer takes them, inline and as an underscore method, and +// super(undefined) leaves the stream in byte mode. +class Collector extends Writable { + constructor() { + super(undefined); + } + _write(chunk: Buffer | string, _encoding: BufferEncoding, cb: (err?: Error | null) => void): void { + console.log("collector", Buffer.isBuffer(chunk), typeof chunk === "string" ? chunk : chunk.toString()); + cb(); + } +} + +async function byteChunks(): Promise { + const inline = new Writable({ + write(chunk: Buffer | string, _encoding: BufferEncoding, cb: (err?: Error | null) => void): void { + console.log("inline", Buffer.isBuffer(chunk), typeof chunk === "string" ? chunk : chunk.toString()); + cb(); + }, + }); + inline.write("one"); + inline.end(Buffer.from("two")); + await new Promise((resolve) => inline.on("finish", () => resolve())); + const collector = new Collector(); + collector.end("three"); + await new Promise((resolve) => collector.on("finish", () => resolve())); +} + async function main(): Promise { await records(); await collections(); @@ -126,5 +155,7 @@ async function main(): Promise { sided(); duplex(); passthrough(); + await new Promise((resolve) => setTimeout(resolve, 5)); + await byteChunks(); } main(); diff --git a/tests/diagnostics/stream-byte-mode-chunk-fences.ts b/tests/diagnostics/stream-byte-mode-chunk-fences.ts new file mode 100644 index 000000000..6afd1d794 --- /dev/null +++ b/tests/diagnostics/stream-byte-mode-chunk-fences.ts @@ -0,0 +1,36 @@ +// A byte-mode stream's write chunks are Buffers: an underscore method +// whose chunk parameter has another type is fenced, and super(undefined) +// passes no options, so the stream is in byte mode. A parameter that +// admits the Buffer (Buffer | string) is not fenced, inline or as a +// method. +import { Writable } from "node:stream"; + +class Numbers extends Writable { + constructor() { + super(undefined); + } + _write(chunk: number, _encoding: BufferEncoding, cb: (err?: Error | null) => void): void { + console.log(chunk); + cb(); + } +} +new Numbers().end("x"); + +const accepted = new Writable({ + write(chunk: Buffer | string, _encoding: BufferEncoding, cb: (err?: Error | null) => void): void { + console.log(typeof chunk === "string" ? chunk : chunk.toString()); + cb(); + }, +}); +accepted.end("z"); + +class Wide extends Writable { + constructor() { + super(undefined); + } + _write(chunk: Buffer | string, _encoding: BufferEncoding, cb: (err?: Error | null) => void): void { + console.log(typeof chunk === "string" ? chunk : chunk.toString()); + cb(); + } +} +new Wide().end("w"); diff --git a/tests/harness/__snapshots__/stream-byte-mode-chunk-fences.ts.txt b/tests/harness/__snapshots__/stream-byte-mode-chunk-fences.ts.txt new file mode 100644 index 000000000..39a06dfd7 --- /dev/null +++ b/tests/harness/__snapshots__/stream-byte-mode-chunk-fences.ts.txt @@ -0,0 +1,8 @@ +stream-byte-mode-chunk-fences.ts:10:5 - error SC2020: 'a '_write' method whose parameter 0 is 'number' where Node passes 'Uint8Array'' is part of the standard library types but has no scriptc lowering yet + + 9 | constructor() { + 10 | super(undefined); + | ^~~~~~~~~~~~~~~~ + 11 | } + + hint: annotate the parameter with the Node signature's type or a supertype of it (chunks are Buffers here) \ No newline at end of file From 6e75e59f753e9dd25ba5b915220f036d2421bc10 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 08:52:18 -0500 Subject: [PATCH 11/19] Check each speculative site of a loop in the third-copy emission test Since dense-array loops bounded by an invariant limit are versioned, the test's loop has a speculative site in each dense copy. The test paired every resume point with the first site's labels; it now checks each site's resume points against that site's third and fast copies. --- .../test/speculative-loop-emission.test.ts | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/packages/compiler/test/speculative-loop-emission.test.ts b/packages/compiler/test/speculative-loop-emission.test.ts index 78a7de295..a691d856d 100644 --- a/packages/compiler/test/speculative-loop-emission.test.ts +++ b/packages/compiler/test/speculative-loop-emission.test.ts @@ -47,24 +47,33 @@ test("a failed check finishes its iteration outside the ordinary copy", async () const llvm = await emitLlvm(source); const fn = /^define internal double @sc_bf_scaled\([^]*?^}/m.exec(llvm)?.[0] ?? ""; const order = labels(fn); - const slow = order.findIndex((l) => l.startsWith("spec.slow")); - const tail = order.findIndex((l) => l.startsWith("spec.tail")); - const fast = order.findIndex((l) => l.startsWith("spec.fast")); - expect(slow).toBeGreaterThanOrEqual(0); - expect(tail).toBeGreaterThan(slow); - expect(fast).toBeGreaterThan(tail); - // Every resume point is in the third copy, between its start and the - // fast copy, so the ordinary copy has one entry. - const resumes = order.flatMap((l, i) => (l.startsWith("spec.resume") ? [i] : [])); - expect(resumes.length).toBeGreaterThan(0); - for (const i of resumes) { - expect(i).toBeGreaterThan(tail); - expect(i).toBeLessThan(fast); + // The loop may have several speculative sites (one in each copy of a + // dense-array loop). Each site emits its ordinary copy, its third copy + // and its fast copy in that order. + const slows = order.flatMap((l, i) => (l.startsWith("spec.slow") ? [i] : [])); + expect(slows.length).toBeGreaterThan(0); + let resumes = 0; + for (const slow of slows) { + const next = (prefix: string): number => + order.findIndex((l, i) => i > slow && l.startsWith(prefix)); + const tail = next("spec.tail"); + const fast = next("spec.fast"); + expect(tail).toBeGreaterThan(slow); + expect(fast).toBeGreaterThan(tail); + // Every resume point of the site is in its third copy, between that + // copy's start and the fast copy, so the ordinary copy has one entry. + for (let i = slow + 1; i < fast; i++) { + if (!order[i]!.startsWith("spec.resume")) continue; + expect(i).toBeGreaterThan(tail); + resumes++; + } + // The third copy's header leaves for the ordinary copy's header. + const header = order[slow + 1]!; + const third = fn.slice(fn.indexOf(`\n${order[tail]}:`), fn.indexOf(`\n${order[fast]}:`)); + expect(third).toContain(`br label %${header}`); } - // The third copy's header leaves for the ordinary copy's header. - const header = order[slow + 1]!; - const third = fn.slice(fn.indexOf(`\n${order[tail]}:`), fn.indexOf(`\n${order[fast]}:`)); - expect(third).toContain(`br label %${header}`); + expect(resumes).toBe(order.filter((l) => l.startsWith("spec.resume")).length); + expect(resumes).toBeGreaterThan(0); }); test("a loop site stops entering its fast copy after repeated bails", async () => { From 142e10a0c428065a37eac0228b3a2b83fef5694f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 08:55:08 -0500 Subject: [PATCH 12/19] Run counters that step beside a counted loop's counter as integers `for (let i = 0, p = 0; i < count; i++, p += 4)` kept both counters as doubles: the counted-loop match takes a single declaration and a single update. A counted loop now also accepts further counters declared in its head and stepped by a constant in its update, when the condition does not read them and the body does not write them. On body entry k such a counter holds `start + step * k`, so its range follows from the driver's: k is at most the driver's span over its step. When that range can pass 2^53, the versioned loop tests `|start| + |step| * span / |driver step| <= 2^52` at run time, and an unproven start is tested for an exact safe integer, as the driver's is. Nested update blocks (`i++, j += 2, k--`) are flattened. Byte windows and dense loops still take only single-counter loops. --- .../src/backend/llvm/counted-loops.ts | 45 ++++- .../backend/llvm/dense-array-loops.test.ts | 1 + .../src/backend/llvm/emit-statements.ts | 36 +++- .../compiler/src/ir/integer-loops.test.ts | 100 ++++++++++ packages/compiler/src/ir/integer-loops.ts | 188 +++++++++++++----- .../7141-comoving-loop-counters.ts.json | 6 + tests/corpus/7141-comoving-loop-counters.ts | 144 ++++++++++++++ .../7141-comoving-loop-counters.ts.json | 1 + 8 files changed, 467 insertions(+), 54 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7141-comoving-loop-counters.ts.json create mode 100644 tests/corpus/7141-comoving-loop-counters.ts create mode 100644 tests/harness/corpus-costs/7141-comoving-loop-counters.ts.json diff --git a/packages/compiler/src/backend/llvm/counted-loops.ts b/packages/compiler/src/backend/llvm/counted-loops.ts index 02943b852..ca2dd6b92 100644 --- a/packages/compiler/src/backend/llvm/counted-loops.ts +++ b/packages/compiler/src/backend/llvm/counted-loops.ts @@ -1,4 +1,9 @@ -import type { IntegerCountedForLoop, IntegerPairedForLoop } from "../../ir/integer-loops.js"; +import type { + IntegerCountedForLoop, + IntegerLoopFollower, + IntegerPairedForLoop, +} from "../../ir/integer-loops.js"; +import { FOLLOWER_GUARD_LIMIT } from "../../ir/integer-loops.js"; import type { IrExpr } from "../../ir/ir.js"; import type { LlvmEmitterContext } from "./expr-context.js"; import { f64Lit } from "./common.js"; @@ -61,9 +66,47 @@ export function emitCountedLoopSafety( ); } if (loop.startGuarded) safe = andSafe(host, safe, emitExactStart(host, loop.start)); + for (const follower of loop.followers) { + if (follower.startGuarded) safe = andSafe(host, safe, emitExactStart(host, follower.start)); + if (follower.tripGuarded) safe = andSafe(host, safe, emitFollowerReach(host, loop, follower)); + } return safe; } +/** `|start| + |step| * span / |driver step| <= 2^52` for a follower, where + * `span` is the distance from the driver's start to the limit. The body runs + * at most `span / |driver step| + 1` times, so every value the follower holds + * on a body entry is within the bound. NaN and infinities fail the ordered + * test. */ +function emitFollowerReach( + host: LlvmEmitterContext, + loop: IntegerCountedForLoop, + follower: IntegerLoopFollower, +): string { + const B = host.B; + const start = host.emitExpr(loop.start), + limit = host.emitExpr(loop.limit), + from = host.emitExpr(follower.start); + const span = B.tmp(), + reach = B.tmp(), + magnitude = B.tmp(), + total = B.tmp(), + ok = B.tmp(); + B.line( + loop.step > 0 + ? `${span} = fsub double ${limit.name}, ${start.name}` + : `${span} = fsub double ${start.name}, ${limit.name}`, + ); + B.line( + `${reach} = fmul double ${span}, ${f64Lit(Math.abs(follower.step) / Math.abs(loop.step))}`, + ); + host.declare(`declare double @llvm.fabs.f64(double)`); + B.line(`${magnitude} = call double @llvm.fabs.f64(double ${from.name})`); + B.line(`${total} = fadd double ${magnitude}, ${reach}`); + B.line(`${ok} = fcmp ole double ${total}, ${f64Lit(FOLLOWER_GUARD_LIMIT)}`); + return ok; +} + /** The run-time test of a guarded paired loop: each unproven start is an * exact safe integer. */ export function emitPairedLoopSafety(host: LlvmEmitterContext, loop: IntegerPairedForLoop): string { diff --git a/packages/compiler/src/backend/llvm/dense-array-loops.test.ts b/packages/compiler/src/backend/llvm/dense-array-loops.test.ts index 32c9cccab..f7a4bc892 100644 --- a/packages/compiler/src/backend/llvm/dense-array-loops.test.ts +++ b/packages/compiler/src/backend/llvm/dense-array-loops.test.ts @@ -120,6 +120,7 @@ const counted = ( limitGuarded: false, startGuarded: false, guardLimit: 0, + followers: [], ...fields, }); diff --git a/packages/compiler/src/backend/llvm/emit-statements.ts b/packages/compiler/src/backend/llvm/emit-statements.ts index 7239db7d8..c493f6c9c 100644 --- a/packages/compiler/src/backend/llvm/emit-statements.ts +++ b/packages/compiler/src/backend/llvm/emit-statements.ts @@ -879,8 +879,9 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { !integerLoop && !countedLoop && emitter.countedLoopsEnabled ? matchIntegerPairedForLoop(s, emitter.numericLocals, emitter.integerRanges) : null; + // Byte windows and dense loops reason about one counter. const window = - countedLoop && emitter.byteWindowsEnabled + countedLoop && countedLoop.followers.length === 0 && emitter.byteWindowsEnabled ? matchByteWindow( s, countedLoop, @@ -898,7 +899,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { length: integerArrayLoop.limitReceiver as IrExpr & { kind: "varRef" }, range: { min: 0, max: 4294967294 }, } - : countedLoop && !countedLoop.guarded + : countedLoop && !countedLoop.guarded && countedLoop.followers.length === 0 ? countedDenseShape(s, countedLoop) : null; const dense = @@ -1030,6 +1031,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { let integerSlot: string | null = null; let countedLimit: string | null = null; const pairedSlots: string[] = []; + const followerSlots: string[] = []; if (integerLoop) { integerSlot = B.slot(); B.entryAllocas.push( @@ -1073,6 +1075,23 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { ? emitter.denseLoop.range : countedLoop.range, }); + for (const follower of countedLoop.followers) { + const slot = B.slot(); + B.entryAllocas.push( + `${slot} = alloca i64 ; integer induction ${emitter.currentLocals.get(follower.localId)!.name}`, + ); + const value = emitter.emitExpr(follower.start); + const followerStart = B.tmp(); + B.line(`${followerStart} = fptosi double ${value.name} to i64`); + B.line(`store i64 ${followerStart}, ptr ${slot}`); + followerSlots.push(slot); + emitter.integerLoopBindings.set(follower.localId, { + slot, + type: "i64", + signed: true, + range: follower.range, + }); + } countedLimit = emitCountedLoopLimit(emitter, countedLoop); } else if (pairedLoop) { for (const counter of [pairedLoop.low, pairedLoop.high]) { @@ -1212,6 +1231,13 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { B.line(`${old} = load i64, ptr ${integerSlot}`); B.line(`${next} = add nsw i64 ${old}, ${countedLoop.step}`); B.line(`store i64 ${next}, ptr ${integerSlot}`); + countedLoop.followers.forEach((follower, i) => { + const was = B.tmp(); + const now = B.tmp(); + B.line(`${was} = load i64, ptr ${followerSlots[i]}`); + B.line(`${now} = add nsw i64 ${was}, ${follower.step}`); + B.line(`store i64 ${now}, ptr ${followerSlots[i]}`); + }); } else if (pairedLoop) { [pairedLoop.low, pairedLoop.high].forEach((counter, i) => { const old = B.tmp(); @@ -1227,7 +1253,11 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { B.startBlock(le); emitter.releaseScope(emitter.scopes.pop()!); if (integerLoop) emitter.integerLoopBindings.delete(integerLoop.localId); - if (countedLoop) emitter.integerLoopBindings.delete(countedLoop.localId); + if (countedLoop) { + emitter.integerLoopBindings.delete(countedLoop.localId); + for (const follower of countedLoop.followers) + emitter.integerLoopBindings.delete(follower.localId); + } if (pairedLoop) { emitter.integerLoopBindings.delete(pairedLoop.low.localId); emitter.integerLoopBindings.delete(pairedLoop.high.localId); diff --git a/packages/compiler/src/ir/integer-loops.test.ts b/packages/compiler/src/ir/integer-loops.test.ts index 24bbf5be0..e1453abad 100644 --- a/packages/compiler/src/ir/integer-loops.test.ts +++ b/packages/compiler/src/ir/integer-loops.test.ts @@ -426,3 +426,103 @@ test("paired counters closing in on each other run as integers", () => { unused.body = []; expect(matchIntegerPairedForLoop(unused, vars)).toBeNull(); }); + +test("counters stepped beside the tested one run as integers", () => { + const vars = new Map(locals); + for (const id of ["j", "k", "limit", "from"]) + vars.set(id, { id, name: id, type: F64, mutable: true }); + vars.set("s", { id: "s", name: "s", type: STRING, mutable: false }); + const step = (id: string, op: "+" | "-", value = 1): IrStmt => ({ + kind: "assign", + localId: id, + value: { kind: "bin", op, left: ref(id), right: number(value), type: F64, loc }, + loc, + }); + const read = (index: IrExpr): IrStmt => ({ + kind: "exprStmt", + expr: { + kind: "strIntrinsic", + method: "charCodeAt", + receiver: { kind: "varRef", localId: "s", type: STRING, loc }, + args: [index], + type: F64, + loc, + }, + loc, + }); + const comoving = ( + limit: IrExpr, + followerStart: IrExpr = number(0), + update: IrStmt = { kind: "block", body: [step("i", "+"), step("j", "+", 4)], loc }, + ) => + ({ + kind: "for", + loc, + init: { + kind: "block", + body: [ + { kind: "varDecl", localId: "i", init: number(0), loc }, + { kind: "varDecl", localId: "j", init: followerStart, loc }, + ], + loc, + }, + cond: { kind: "bin", op: "<", left: ref("i"), right: limit, type: BOOL, loc }, + update, + body: [read(ref("j"))], + }) as IrStmt & { kind: "for" }; + // A literal limit bounds the follower: at most 9 updates of 4 before the + // last body entry. + expect(matchIntegerCountedForLoop(comoving(number(10)), vars)).toMatchObject({ + localId: "i", + guarded: false, + range: { min: 0, max: 9 }, + followers: [ + { + localId: "j", + step: 4, + startGuarded: false, + tripGuarded: false, + range: { min: 0, max: 36 }, + }, + ], + }); + // An unknown limit or start is tested at run time. + expect(matchIntegerCountedForLoop(comoving(ref("limit")), vars)).toMatchObject({ + guarded: true, + followers: [{ tripGuarded: true, range: { min: 0, max: 2 ** 52 + 2 } }], + }); + expect(matchIntegerCountedForLoop(comoving(number(10), ref("from")), vars)).toMatchObject({ + guarded: true, + followers: [{ startGuarded: true, tripGuarded: true }], + }); + // A literal limit with known starts that can pass 2^53 is refused. + expect(matchIntegerCountedForLoop(comoving(number(10), number(2 ** 53 - 9)), vars)).toBeNull(); + // Nested update blocks (`i++, j += 4, k--`) step three counters. + const three = comoving(number(10), number(0), { + kind: "block", + body: [{ kind: "block", body: [step("i", "+"), step("j", "+", 4)], loc }, step("k", "-")], + loc, + }); + if (three.init?.kind === "block") + three.init.body.push({ kind: "varDecl", localId: "k", init: number(5), loc }); + expect(matchIntegerCountedForLoop(three, vars)?.followers).toMatchObject([ + { localId: "j", range: { min: 0, max: 36 } }, + { localId: "k", step: -1, range: { min: -4, max: 5 } }, + ]); + // A follower without a step, one the body writes, one the limit reads, a + // captured one, and a start that reads another counter are refused. + expect( + matchIntegerCountedForLoop( + comoving(number(10), number(0), { kind: "block", body: [step("i", "+")], loc }), + vars, + ), + ).toBeNull(); + const writing = comoving(number(10)); + writing.body.push(step("j", "+")); + expect(matchIntegerCountedForLoop(writing, vars)).toBeNull(); + expect(matchIntegerCountedForLoop(comoving(ref("j")), vars)).toBeNull(); + const boxed = new Map(vars); + boxed.set("j", { ...vars.get("j")!, boxed: true }); + expect(matchIntegerCountedForLoop(comoving(number(10)), boxed)).toBeNull(); + expect(matchIntegerCountedForLoop(comoving(number(10), ref("i")), vars)).toBeNull(); +}); diff --git a/packages/compiler/src/ir/integer-loops.ts b/packages/compiler/src/ir/integer-loops.ts index e80680d6e..c4cdc327c 100644 --- a/packages/compiler/src/ir/integer-loops.ts +++ b/packages/compiler/src/ir/integer-loops.ts @@ -28,8 +28,36 @@ export interface IntegerCountedForLoop { startGuarded: boolean; guardLimit: number; range: IntegerRange; + /** Counters declared and stepped beside this one that the condition does + * not test, such as `j` in `for (let i = 0, j = 0; i < n; i++, j += 4)`. */ + followers: IntegerLoopFollower[]; } +/** A counter that steps by a constant on every update of a counted loop. + * After `k` updates it holds `start + step * k`. */ +export interface IntegerLoopFollower { + localId: string; + start: IrExpr; + step: number; + /** As the driver's `startGuarded` (part of the loop's `guarded`). */ + startGuarded: boolean; + /** The follower's range depends on the run-time trip count: the versioned + * loop also tests `|start| + |step| * span / |driver step|` against + * `FOLLOWER_GUARD_LIMIT`, where `span` is the distance from the driver's + * start to the limit (part of the loop's `guarded`). */ + tripGuarded: boolean; + /** The follower's values on every body entry. */ + range: IntegerRange; +} + +/** The bound of the run-time follower test. */ +export const FOLLOWER_GUARD_LIMIT = 2 ** 52; +/** The magnitude a follower that passed the test can reach. The test's four + * f64 operations (the step ratio, the span, its product and the sum) each + * round down by at most a factor of 1 - 2^-53, so the exact sum is below + * `2^52 / (1 - 2^-53)^4`, which is less than 2^52 + 3. */ +const FOLLOWER_GUARD_RANGE = FOLLOWER_GUARD_LIMIT + 2; + function constantStep(update: IrStmt | null, id: string): number | null { if (update?.kind === "exprStmt" && update.expr.kind === "incDec" && update.expr.localId === id) return update.expr.op === "+" ? 1 : -1; @@ -115,33 +143,61 @@ export function matchIntegerCountedForLoop( ranges: IntegerRanges = new Map(), unaliased: ReadonlySet | null = null, ): IntegerCountedForLoop | null { - const init = stmt.init; - if (init?.kind !== "varDecl" || !init.init) return null; - const local = locals.get(init.localId); - if (local?.type.kind !== "f64" || !local.mutable || local.boxed || local.tdz) return null; - const start = init.init; - let startRange = - start.kind === "numLit" && Number.isSafeInteger(start.value) && !Object.is(start.value, -0) - ? { min: start.value, max: start.value } - : ranges.get(start); - // An unproven start is tested at run time, on a versioned loop. - const startGuarded = !startRange && start.kind !== "numLit" && start.type.kind === "f64"; - if (startGuarded) startRange = { min: -Number.MAX_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER }; - if ( - !startRange || - startRange.min < -Number.MAX_SAFE_INTEGER || - startRange.max > Number.MAX_SAFE_INTEGER || - !stableLoopValue(start, locals, stmt.body, false, local.id) - ) - return null; - const step = constantStep(stmt.update, local.id); - if (step === null || writesLocal(stmt.body, local.id)) return null; const cond = stmt.cond; if (cond?.kind !== "bin") return null; + const decls = stmt.init?.kind === "block" ? stmt.init.body : stmt.init ? [stmt.init] : []; + // `i++, j += 2, k--` lowers to nested blocks of steps. + const flat = (u: IrStmt | null): (IrStmt | null)[] => + u?.kind === "block" ? u.body.flatMap(flat) : [u]; + const updates = flat(stmt.update); + if (decls.length === 0 || updates.length !== decls.length) return null; + const declared = (e: IrExpr): string | null => + e.kind === "varRef" && decls.some((d) => d.kind === "varDecl" && d.localId === e.localId) + ? e.localId + : null; + // The condition tests the driving counter; every other declaration + // follows it. + const driver = declared(cond.left) ?? declared(cond.right); + const counterIds = decls.map((d) => (d.kind === "varDecl" ? d.localId : "")); + const stepOf = (id: string): number | null => { + const steps = updates.map((u) => constantStep(u, id)).filter((v) => v !== null); + return steps.length === 1 ? steps[0]! : null; + }; + // A counter's start is a stable integer that refers to no counter. An + // unproven start is tested at run time, on a versioned loop. + const counterStart = ( + id: string, + ): { start: IrExpr; range: IntegerRange; guarded: boolean; step: number } | null => { + const decl = decls.find((d) => d.kind === "varDecl" && d.localId === id); + if (decl?.kind !== "varDecl" || !decl.init) return null; + const local = locals.get(id); + if (local?.type.kind !== "f64" || !local.mutable || local.boxed || local.tdz) return null; + const start = decl.init; + const known = + start.kind === "numLit" && Number.isSafeInteger(start.value) && !Object.is(start.value, -0) + ? { min: start.value, max: start.value } + : ranges.get(start); + const guarded = !known && start.kind !== "numLit" && start.type.kind === "f64"; + const range = guarded ? { min: -Number.MAX_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER } : known; + const step = stepOf(id); + if ( + !range || + range.min < -Number.MAX_SAFE_INTEGER || + range.max > Number.MAX_SAFE_INTEGER || + !counterIds.every((counter) => stableLoopValue(start, locals, stmt.body, false, counter)) || + step === null || + writesLocal(stmt.body, id) + ) + return null; + return { start, range, guarded, step }; + }; + const head = driver === null ? null : counterStart(driver); + if (!head) return null; + const local = locals.get(driver!)!; + const { start, step, guarded: startGuarded, range: startRange } = head; let limit = cond.right, op: string = cond.op; if (cond.left.kind !== "varRef" || cond.left.localId !== local.id) { - if (cond.right.kind !== "varRef" || cond.right.localId !== local.id) return null; limit = cond.left; op = ({ "<": ">", "<=": ">=", ">": "<", ">=": "<=" } as Record)[op] ?? ""; } @@ -185,11 +241,68 @@ export function matchIntegerCountedForLoop( const limitGuarded = !boundRange || (step > 0 ? !(boundRange.max <= guardLimit) : !(boundRange.min >= guardLimit)); if (limitGuarded && limit.kind === "numLit") return null; - const guarded = limitGuarded || startGuarded; + const range = + step > 0 + ? { + min: startRange.min, + max: Math.max( + startRange.min, + Math.min( + Number.MAX_SAFE_INTEGER, + boundRange + ? Math.ceil(boundRange.max) - (inclusive ? 0 : 1) + : Number.MAX_SAFE_INTEGER, + ), + ), + } + : { + min: Math.min( + startRange.max, + Math.max( + -Number.MAX_SAFE_INTEGER, + boundRange + ? Math.floor(boundRange.min) + (inclusive ? 0 : 1) + : -Number.MAX_SAFE_INTEGER, + ), + ), + max: startRange.max, + }; + // On body entry k the driver holds `start + step * k`, so k is at most the + // driver's span over |step|, and a follower holds its start plus its own + // step times k. + const trips = Math.floor( + (step > 0 ? range.max - startRange.min : startRange.max - range.min) / Math.abs(step), + ); + const followers: IntegerLoopFollower[] = []; + for (const id of counterIds) { + if (id === local.id) continue; + const follower = counterStart(id); + // The limit is evaluated once; a follower changes on every update. + if (!follower || !stableLoopValue(limit, locals, stmt.body, false, id)) return null; + const reach = follower.step * trips; + const min = follower.range.min + Math.min(0, reach), + max = follower.range.max + Math.max(0, reach); + const tripGuarded = min < -Number.MAX_SAFE_INTEGER || max > Number.MAX_SAFE_INTEGER; + // Known starts and a literal limit are not tested at run time. + if (tripGuarded && !follower.guarded && !startGuarded && limit.kind === "numLit") return null; + followers.push({ + localId: id, + start: follower.start, + step: follower.step, + startGuarded: follower.guarded, + tripGuarded, + range: tripGuarded + ? { min: Math.max(-FOLLOWER_GUARD_RANGE, min), max: Math.min(FOLLOWER_GUARD_RANGE, max) } + : { min, max }, + }); + } + const guarded = + limitGuarded || startGuarded || followers.some((f) => f.startGuarded || f.tripGuarded); let useful = false; let cost = 0; const counter = (e: IrExpr): boolean => - (e.kind === "varRef" && e.localId === local.id) || + (e.kind === "varRef" && + (e.localId === local.id || followers.some((f) => f.localId === e.localId))) || (e.kind === "bin" && (e.op === "+" || e.op === "-" || e.op === "*") && (counter(e.left) || counter(e.right))); @@ -241,32 +354,6 @@ export function matchIntegerCountedForLoop( return (!guarded || ++cost <= 80) && everyStmtChild(s, expr, bodyStmt); }; if (!stmt.body.every(bodyStmt) || !useful) return null; - const range = - step > 0 - ? { - min: startRange.min, - max: Math.max( - startRange.min, - Math.min( - Number.MAX_SAFE_INTEGER, - boundRange - ? Math.ceil(boundRange.max) - (inclusive ? 0 : 1) - : Number.MAX_SAFE_INTEGER, - ), - ), - } - : { - min: Math.min( - startRange.max, - Math.max( - -Number.MAX_SAFE_INTEGER, - boundRange - ? Math.floor(boundRange.min) + (inclusive ? 0 : 1) - : -Number.MAX_SAFE_INTEGER, - ), - ), - max: startRange.max, - }; return { localId: local.id, start, @@ -278,6 +365,7 @@ export function matchIntegerCountedForLoop( startGuarded, guardLimit, range, + followers, }; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7141-comoving-loop-counters.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7141-comoving-loop-counters.ts.json new file mode 100644 index 000000000..392c9f10d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7141-comoving-loop-counters.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7141-comoving-loop-counters.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7141-comoving-loop-counters.ts b/tests/corpus/7141-comoving-loop-counters.ts new file mode 100644 index 000000000..a756b7a46 --- /dev/null +++ b/tests/corpus/7141-comoving-loop-counters.ts @@ -0,0 +1,144 @@ +// For loops with a second counter that steps beside the tested one +// (`i++, j += 4`): pixel strides over typed arrays, interleaving into number +// arrays, descending and inclusive loops, three counters, early exits, +// starts and limits that are fractions, negative zero, NaN, infinite or +// near 2^53, counters that live across a yield or an await, and counters a +// closure captures, the body writes or the condition reads. +function luminance(px: Uint8Array, count: number): number[] { + const out: number[] = []; + for (let i = 0, p = 0; i < count; i++, p += 4) { + out.push((px[p]! * 77 + px[p + 1]! * 150 + px[p + 2]! * 29) >> 8); + } + return out; +} +const px = new Uint8Array(32); +for (let k = 0; k < px.length; k++) px[k] = (k * 37 + 11) & 255; +console.log(luminance(px, 8).join(",")); +// Past the end, reads are undefined and the sums NaN, which `>> 8` makes 0. +console.log(luminance(px, 10).join(",")); +console.log(luminance(px, 0).length, luminance(px, -3).length, luminance(px, 2.5).join(",")); +console.log(luminance(px, NaN).length); + +function interleave(a: number[], b: number[]): number[] { + const out: number[] = new Array(a.length * 2).fill(-1); + const n = a.length; + for (let i = 0, j = 0; i < n; i++, j += 2) { + out[j] = a[i]!; + out[j + 1] = b[i]!; + } + return out; +} +console.log(interleave([1, 2, 3, 4], [10, 20, 30, 40]).join(",")); +console.log(interleave([], []).length); + +// The second counter's start is not proven: charCodeAt truncates its index, +// and the counter's own values keep JS semantics. +function codes(s: string, count: number, from: number, step: number): string { + const out: (number | string)[] = []; + if (step === 3) { + for (let i = 0, j = from; i < count; i++, j += 3) out.push(s.charCodeAt(j), j); + } else { + for (let i = 0, j = from; i < count; i++, j -= 2) out.push(s.charCodeAt(j), j); + } + return out.join(","); +} +const word = "abcdefghijklmnop"; +console.log(codes(word, 4, 0, 3), "|", codes(word, 4, 15, -2)); +console.log(codes(word, 4, 0.5, 3), "|", codes(word, 3, 9.75, -2)); +const negativeZero = -0; +console.log(codes(word, 2, negativeZero, 3), Object.is(negativeZero, -0)); +console.log(codes(word, 2, NaN, 3), "|", codes(word, 2, Infinity, -2), "|", codes(word, 2, -Infinity, 3)); +// Near 2^52 the run-time test passes or fails on the trip count; near 2^53 +// the double counter rounds (odd values above 2^53 are not representable). +console.log(codes(word, 4, 2 ** 52 - 16, 3)); +console.log(codes(word, 6, 2 ** 52 - 4, 3)); +console.log(codes(word, 6, 2 ** 53 - 10, 3)); +console.log(codes(word, 4, -(2 ** 53) + 3, -2)); +// A limit that is not finite or not an integer. +function capped(s: string, limit: number): string { + const out: number[] = []; + for (let i = 0, j = 1; i < limit; i++, j += 2) { + if (j >= s.length) break; + out.push(s.charCodeAt(j)); + } + return out.join(","); +} +console.log(capped(word, Infinity), "|", capped(word, 3.5), "|", capped(word, NaN), "|", capped(word, -Infinity)); + +// A descending driver with an ascending follower, inclusive bounds and +// larger steps. +function mirror(s: string): string { + const out: string[] = []; + for (let i = s.length - 1, j = 0; i >= 0; i--, j += 2) out.push(`${s.charCodeAt(i)}@${j}`); + return out.join(" "); +} +console.log(mirror("scriptc")); +function stepped(bytes: Uint8Array, last: number): string { + const out: number[] = []; + for (let i = 0, j = bytes.length - 1; i <= last; i += 3, j -= 5) + out.push(bytes[i]! | 0, bytes[j]! | 0, j); + return out.join(","); +} +console.log(stepped(px, 9), "|", stepped(px, 30)); + +// Three counters, continue and break. +function triple(a: Uint8Array, n: number): string { + const out: number[] = []; + for (let i = 0, j = 10, k = 100; i < n; i++, j -= 1, k += 7) { + if (i % 3 === 1) continue; + if (k > 150) break; + out.push(a[i]!, a[j]!, k); + } + return out.join(","); +} +console.log(triple(px, 12), "|", triple(px, 4)); + +// A counted limit with a known range and a follower used by a remainder. +let rotated = 0; +for (let i = 0, j = 5; i < 1000; i++, j += 3) rotated = (rotated * 31 + px[j % 32]!) % 1000003; +console.log(rotated); + +// The follower starting from a driver's start of unproven exactness. +function window(bytes: Uint8Array, from: number, to: number): string { + const out: number[] = []; + for (let i = from, j = 0; i < to; i++, j += 2) out.push(bytes[i]! | 0, bytes[j]! | 0, i, j); + return out.join(","); +} +console.log(window(px, 2, 5), "|", window(px, 1.5, 4), "|", window(px, -0, 2)); + +// Counters a closure captures keep one binding per iteration; a follower the +// body writes, one the condition reads, and a start that reads another +// counter keep their JS values. +const reads: (() => number)[] = []; +for (let i = 0, j = 0; i < 3; i++, j += 4) { + reads.push(() => j); + void px[j]; +} +console.log(reads.map((read) => read()).join(",")); +const written: number[] = []; +for (let i = 0, j = 0; i < 5; i++, j += 2) { + if (i === 2) j += 7; + written.push(px[j]!, j); +} +console.log(written.join(",")); +const chased: number[] = []; +for (let i = 0, j = 5; i < j; i += 2, j++) chased.push(px[i]!, i, j); +console.log(chased.join(",")); +const offset: number[] = []; +for (let i = 1, j = i + 1; i < 4; i++, j += 2) offset.push(px[j]!, j); +console.log(offset.join(",")); + +// Counters that live across a yield or an await. +function* strided(bytes: Uint8Array, n: number): Generator { + for (let i = 0, j = 1; i < n; i++, j += 3) yield bytes[j]! + i; +} +console.log([...strided(px, 6)].join(",")); +async function weighted(bytes: Uint8Array, n: number): Promise { + let sum = 0; + for (let i = 0, j = 2; i < n; i++, j += 5) { + await null; + sum += bytes[j]! * (i + 1); + } + return sum; +} +void (async () => console.log(await weighted(px, 6), await weighted(px, 3)))(); diff --git a/tests/harness/corpus-costs/7141-comoving-loop-counters.ts.json b/tests/harness/corpus-costs/7141-comoving-loop-counters.ts.json new file mode 100644 index 000000000..7c0606885 --- /dev/null +++ b/tests/harness/corpus-costs/7141-comoving-loop-counters.ts.json @@ -0,0 +1 @@ +{ "plain": 4260, "san": 6600 } From 0318671352edbc3d699dc9beeaca53316af149d6 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 09:17:49 -0500 Subject: [PATCH 13/19] Reuse the class dispatch passes' per-function scans while bodies are unchanged --- .../lowering/caught-value-dispatch.ts | 24 +- .../frontend/lowering/class-construction.ts | 20 +- .../lowering/class-dynamic-dispatch.ts | 118 ++------ .../deferred-module-initializers.test.ts | 9 + .../lowering/deferred-module-initializers.ts | 14 +- .../frontend/lowering/dispatch-sites.test.ts | 60 ++++ .../src/frontend/lowering/dispatch-sites.ts | 272 ++++++++++++++++++ .../lowering/generator-dynamic-dispatch.ts | 50 ++-- .../compiler/src/frontend/lowering/lowerer.ts | 18 +- 9 files changed, 417 insertions(+), 168 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/dispatch-sites.test.ts create mode 100644 packages/compiler/src/frontend/lowering/dispatch-sites.ts diff --git a/packages/compiler/src/frontend/lowering/caught-value-dispatch.ts b/packages/compiler/src/frontend/lowering/caught-value-dispatch.ts index 606d10d56..07d68baec 100644 --- a/packages/compiler/src/frontend/lowering/caught-value-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/caught-value-dispatch.ts @@ -11,6 +11,7 @@ import { } from "../../ir/ir.js"; import { everyStmtList, transformStmtList } from "../../ir/traverse.js"; import { classInstanceOf } from "./class-dynamic-dispatch.js"; +import type { DispatchSites } from "./dispatch-sites.js"; import type { ClassInfo } from "./lower-classes.js"; import type { Lowerer } from "./lowerer.js"; @@ -26,25 +27,16 @@ export class CaughtValueDispatch { private readonly payloadHelpers = new Map(); private readonly payloadHelperFns = new Set(); - process(lowerer: Lowerer, functions: readonly IrFunction[]): boolean { + process(lowerer: Lowerer, functions: readonly IrFunction[], sites: DispatchSites): boolean { const consumers: IrFunction[] = []; for (const fn of functions) { if (fn === this.helper) continue; - let consumes = false; - everyStmtList(fn.body, { - stmt: (stmt) => { - if (stmt.kind === "throw" && stmt.value.type.kind === "object") { - const info = lowerer.classes.get(stmt.value.type.className); - if (info) this.thrownClasses.add(info.def.name); - } - return true; - }, - expr: (expr) => { - if (expr.kind === "caughtToDyn") consumes = true; - return true; - }, - }); - if (consumes) consumers.push(fn); + const found = sites.of(fn); + for (const className of found.thrown) { + const info = lowerer.classes.get(className); + if (info) this.thrownClasses.add(info.def.name); + } + if (found.caughtValues) consumers.push(fn); } if (!this.helper && consumers.length === 0) return false; let changed = false; diff --git a/packages/compiler/src/frontend/lowering/class-construction.ts b/packages/compiler/src/frontend/lowering/class-construction.ts index 30f504b4a..66ec547bc 100644 --- a/packages/compiler/src/frontend/lowering/class-construction.ts +++ b/packages/compiler/src/frontend/lowering/class-construction.ts @@ -13,7 +13,8 @@ import { type IrType, type SrcLoc, } from "../../ir/ir.js"; -import { everyStmtList, transformExpr, transformStmtList } from "../../ir/traverse.js"; +import { transformExpr, transformStmtList } from "../../ir/traverse.js"; +import type { DispatchSites } from "./dispatch-sites.js"; import type { Lowerer } from "./lowerer.js"; import type { ParamShape } from "./call-signatures.js"; import { classPrototypeData } from "./class-prototypes.js"; @@ -254,22 +255,17 @@ export class ClassConstructionDispatch { private prototypeHelper: IrFunction | null = null; private readonly prototypes = new Set(); - process(lowerer: Lowerer, functions: readonly IrFunction[]): boolean { + process(lowerer: Lowerer, functions: readonly IrFunction[], sites: DispatchSites): boolean { const discover = (type: IrType): void => { if (type.kind === "classval") this.constructors.add(type.className); else if (type.kind === "union") lowerer.unions.get(type.unionId)?.arms.forEach(discover); }; const prototypeReads = new Set(); - for (const fn of functions) - everyStmtList(fn.body, { - stmt: () => true, - expr: (expr) => { - if (expr.kind === "dynFrom") discover(expr.value.type); - if (expr.kind === "libCall" && expr.fn === "dyn.classBasePrototype") - prototypeReads.add(fn); - return true; - }, - }); + for (const fn of functions) { + const found = sites.of(fn); + found.values.forEach(discover); + if (found.basePrototypes) prototypeReads.add(fn); + } let changed = false; for (const fn of functions) { if (fn === this.prototypeHelper || !prototypeReads.has(fn)) continue; diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index fefe85d0c..f2ccb0ec6 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -77,6 +77,7 @@ import { isClassCallback } from "./class-callbacks.js"; import { CaughtValueDispatch } from "./caught-value-dispatch.js"; import { refreshDescriptorGuards } from "./class-descriptors.js"; import { ClassConstructionDispatch } from "./class-construction.js"; +import { DispatchSites } from "./dispatch-sites.js"; import { emitterRooted, lowerDynamicEmitterEmit, @@ -226,7 +227,7 @@ const KEY_CALLS = new Map([ ["dyn.keySetComputed", "value"], ]); -function toPrimitiveHint(expr: IrExpr): ToPrimitiveHint | null { +export function toPrimitiveHint(expr: IrExpr): ToPrimitiveHint | null { if (expr.kind === "toString") return expr.operand.type.kind === "dyn" ? "string" : null; if (expr.kind === "dynKeyGet") return expr.key.type.kind === "dyn" ? "string" : null; if (expr.kind !== "libCall") return null; @@ -780,6 +781,8 @@ export class ClassDynamicDispatch { private readonly generatorDispatch = new GeneratorDynamicDispatch(); private readonly caughtDispatch = new CaughtValueDispatch(); private readonly constructionDispatch = new ClassConstructionDispatch(); + /** What each function's body holds for this pass and its sub-passes. */ + readonly sites = new DispatchSites(); /** Property names a delete can remove (null: any name), for the field * creation tracking decision. */ private deletedNames: Set | null | undefined; @@ -862,12 +865,13 @@ export class ClassDynamicDispatch { ) fn.body.shift(); } - let changed = this.caughtDispatch.process(lowerer, functions); + let changed = this.caughtDispatch.process(lowerer, functions, this.sites); changed = refreshErrorMethodDispatch(lowerer, functions) || changed; changed = refreshClassMethodValueSelections(lowerer) || changed; changed = refreshErrorPropertyDispatch(lowerer, functions) || changed; - changed = this.generatorDispatch.process(lowerer, functions, this.generated) || changed; - changed = this.constructionDispatch.process(lowerer, functions) || changed; + changed = + this.generatorDispatch.process(lowerer, functions, this.generated, this.sites) || changed; + changed = this.constructionDispatch.process(lowerer, functions, this.sites) || changed; const seenTypes = new Set(); const rewrite = new Set(); const toPrimitiveSites = new Set(); @@ -891,100 +895,18 @@ export class ClassDynamicDispatch { }; const boxedBefore = this.boxed.size; const boxedConstructorsBefore = this.boxedConstructors.size; - for (const fn of functions) - everyStmtList(fn.body, { - stmt: (statement) => { - // Field-creation instrumentation can box a base-constructor receiver - // even when only its derived instances otherwise cross the boundary. - if ( - statement.kind === "fieldSet" && - lowerer.classes.get(statement.className)?.def.tracksOwnFields && - isClassOwnEnumerableFieldName(statement.field) - ) - discover(statement.obj.type); - return true; - }, - expr: (expr) => { - if ( - expr.kind === "jsonStringify" || - (expr.kind === "libCall" && - (expr.fn === "json.stringifyValue" || expr.fn === "json.stringifyReplacer")) - ) { - this.serializesClasses = true; - } - if ( - toPrimitiveHint(expr) !== null || - (expr.kind === "libCall" && (expr.fn === "dyn.valueOf" || expr.fn === "dyn.toString")) - ) - toPrimitiveSites.add(fn); - switch (expr.kind) { - case "dynFrom": - discover(expr.value.type); - break; - case "fieldIncDec": - if ( - lowerer.classes.get(expr.className)?.def.tracksOwnFields && - isClassOwnEnumerableFieldName(expr.field) - ) - discover(expr.obj.type); - break; - case "call": - if ( - (expr.callee === "%dyn.class.properties" || - expr.callee === "%dyn.class.readProperties") && - expr.args[0]?.kind === "dynFrom" && - isDynTypedRefType(expr.args[0].value.type) - ) - rewrite.add(fn); - break; - case "dynHasKey": - hasKeySites.add(fn); - break; - case "caughtTest": - if (expr.test === "instanceof") caughtSites.add(fn); - break; - case "caughtNarrow": - case "caughtCheck": - if (expr.type.kind === "object") caughtSites.add(fn); - break; - case "dynKeyGet": - case "dynInvoke": - rewrite.add(fn); - break; - case "dynCheck": - if (isDynTypedRefType(expr.type)) rewrite.add(fn); - // A dynamic function checked into a native function type - // receives its arguments as dynamic values: class instances - // passed to it cross the boundary when it is called (a JS - // `list.map((rec) => rec.match(x))` callback with an untyped - // parameter). - if (expr.type.kind === "func") expr.type.params.forEach(discover); - break; - case "libCall": - if ( - expr.fn === "bytes.construct" || - expr.fn === "dyn.keySet" || - expr.fn === "dyn.keySetComputed" || - expr.fn === "dyn.iterator" || - expr.fn === "dyn.arrayFromIterator" || - expr.fn === "dyn.toString" || - expr.fn === "dyn.stringConstructor" || - expr.fn === "dyn.forInKeys" || - expr.fn === "dyn.forInKeyStrings" || - expr.fn === "dyn.hasKeyComputed" || - expr.fn === "dyn.hasOwnComputed" || - expr.fn === "dyn.propertyIsEnumerableComputed" || - expr.fn === "dyn.defineProperty" || - expr.fn === "dyn.getPrototype" || - expr.fn === "dyn.reflectGet" || - expr.fn === "dyn.reflectSet" - ) - rewrite.add(fn); - break; - } - return true; - }, - }); + for (const fn of functions) { + const found = this.sites.of(fn); + // A class starts tracking its own fields during this pass. + for (const { type, fieldClass } of found.boxing) + if (fieldClass === undefined || lowerer.classes.get(fieldClass)?.def.tracksOwnFields) + discover(type); + if (found.serializes) this.serializesClasses = true; + if (found.conversions) toPrimitiveSites.add(fn); + if (found.dispatches) rewrite.add(fn); + if (found.keyTests) hasKeySites.add(fn); + if (found.caughtClassTests) caughtSites.add(fn); + } // The classes this pass found join the tables in a fixed order, not in // the order the scan met them. sortAdded(lowerer, this.boxed, boxedBefore); diff --git a/packages/compiler/src/frontend/lowering/deferred-module-initializers.test.ts b/packages/compiler/src/frontend/lowering/deferred-module-initializers.test.ts index 28f05bfce..4696659bc 100644 --- a/packages/compiler/src/frontend/lowering/deferred-module-initializers.test.ts +++ b/packages/compiler/src/frontend/lowering/deferred-module-initializers.test.ts @@ -159,6 +159,15 @@ test("fills every empty copy with the exact file and span while preserving popul for (const untouched of [otherFile, otherStart, otherEnd]) expect(untouched.body).toEqual([]); }); +test("reports the functions whose placeholders it fills", () => { + const { initializers, pending, lowerer } = fixture(); + const functions = [fn("init", [block(loc)]), fn("use", [read("registry")])]; + pending.set("registry", [{ loc, lower: () => [value(4)] }]); + const filled: IrFunction[] = []; + expect(initializers.process(lowerer, functions, (f) => filled.push(f))).toBe(true); + expect(filled).toEqual([functions[0]]); +}); + test("an update of the binding demands its initializer", () => { const { initializers, pending, lowerer } = fixture(); const updated = block(loc), diff --git a/packages/compiler/src/frontend/lowering/deferred-module-initializers.ts b/packages/compiler/src/frontend/lowering/deferred-module-initializers.ts index eb22b4085..a44df8f75 100644 --- a/packages/compiler/src/frontend/lowering/deferred-module-initializers.ts +++ b/packages/compiler/src/frontend/lowering/deferred-module-initializers.ts @@ -129,7 +129,14 @@ export class DeferredModuleInitializers { return block; } - process(lowerer: Lowerer, functions: readonly IrFunction[]): boolean { + /** Materializes the initializers that reached bodies observe. `filled` + * receives each function whose body changes inside an unchanged statement + * list. */ + process( + lowerer: Lowerer, + functions: readonly IrFunction[], + filled?: (fn: IrFunction) => void, + ): boolean { if (this.pending.size === 0) return false; // Scans keep only the reads of deferred bindings. for (const key of this.pending.keys()) { @@ -192,7 +199,10 @@ export class DeferredModuleInitializers { } if (holders.length === 0) continue; // These bodies changed inside unchanged statement lists. - for (const fn of holders) this.scans.delete(fn); + for (const fn of holders) { + this.scans.delete(fn); + filled?.(fn); + } // An inserted body can introduce a placeholder needed by another // action in this pass. Unattached bodies are not part of the live IR. for (const nested of scanStatements(body).placeholders) add(nested, holders); diff --git a/packages/compiler/src/frontend/lowering/dispatch-sites.test.ts b/packages/compiler/src/frontend/lowering/dispatch-sites.test.ts new file mode 100644 index 000000000..1d027ac34 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/dispatch-sites.test.ts @@ -0,0 +1,60 @@ +import { expect, test, vi } from "vitest"; +import { DYN, F64, VOID, type IrFunction, type IrStmt, type SrcLoc } from "../../ir/ir.js"; +import * as traversal from "../../ir/traverse.js"; +import { DispatchSites } from "./dispatch-sites.js"; + +const loc: SrcLoc = { file: "sites.ts", start: 0, end: 1 }; +const keyRead = (key: string): IrStmt => ({ + kind: "exprStmt", + expr: { + kind: "dynKeyGet", + value: { kind: "varRef", localId: "value", type: DYN, loc }, + key: { kind: "strLit", value: key, type: DYN, loc }, + type: DYN, + loc, + }, + loc, +}); +const value = (n: number): IrStmt => ({ + kind: "exprStmt", + expr: { kind: "numLit", value: n, type: F64, loc }, + loc, +}); +const fn = (body: IrStmt[]): IrFunction => ({ + name: "f", + body, + params: [], + locals: [], + returnType: VOID, + loc, +}); + +test("reuses a function's sites while its statement list is unchanged", () => { + const sites = new DispatchSites(); + const f = fn([value(1)]); + const walk = vi.spyOn(traversal, "everyStmtList"); + try { + expect(sites.of(f).dispatches).toBe(false); + expect(sites.of(f).dispatches).toBe(false); + expect(walk).toHaveBeenCalledTimes(1); + // A statement added to the same list is found. + f.body.push(keyRead("next")); + expect(sites.of(f)).toMatchObject({ dispatches: true, generatorSteps: true }); + // So is a replaced list. + f.body = [value(2)]; + expect(sites.of(f)).toMatchObject({ dispatches: false, generatorSteps: false }); + expect(walk).toHaveBeenCalledTimes(3); + } finally { + walk.mockRestore(); + } +}); + +test("scans a body again once a change inside an unchanged list is reported", () => { + const sites = new DispatchSites(); + const placeholder: Extract = { kind: "block", body: [], loc }; + const f = fn([placeholder]); + expect(sites.of(f).dispatches).toBe(false); + placeholder.body.push(keyRead("map")); + sites.forget(f); + expect(sites.of(f)).toMatchObject({ dispatches: true, runtimeSteps: true }); +}); diff --git a/packages/compiler/src/frontend/lowering/dispatch-sites.ts b/packages/compiler/src/frontend/lowering/dispatch-sites.ts new file mode 100644 index 000000000..2618dacf9 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/dispatch-sites.ts @@ -0,0 +1,272 @@ +import { InternalCompilerError } from "../../errors.js"; +import { + isClassOwnEnumerableFieldName, + isDynTypedRefType, + typeKey, + type IrFunction, + type IrStmt, + type IrType, +} from "../../ir/ir.js"; +import { everyStmtList } from "../../ir/traverse.js"; +import { toPrimitiveHint } from "./class-dynamic-dispatch.js"; +import { GENERATOR_STEP_METHODS, HELPER_METHODS } from "./generator-dynamic-dispatch.js"; + +/** What one function's body holds for the class dispatch pass and its + * caught-value, generator and class construction sub-passes, collected in + * one walk. Lists keep the order the walk meets their entries. */ +export interface FunctionSites { + statements: IrStmt[]; + length: number; + first: IrStmt | undefined; + last: IrStmt | undefined; + /** The value types of `dynFrom` expressions, once per type key. */ + values: IrType[]; + /** The types the class dispatch scan boxes, once per type key and class: + * `dynFrom` values, the parameter types of dynamic functions checked into + * native function types, and the receivers of own enumerable field writes + * and updates. A receiver counts only while its class (`fieldClass`) + * tracks its own fields. */ + boxing: { type: IrType; fieldClass?: string }[]; + /** JSON serialization of dynamic values. */ + serializes: boolean; + /** ToPrimitive conversions, and `dyn.valueOf` and `dyn.toString` calls. */ + conversions: boolean; + /** Sites the class dispatch rewrite can change. */ + dispatches: boolean; + /** `dynHasKey` tests. */ + keyTests: boolean; + /** `instanceof` tests of caught values, and caught values narrowed or + * checked to class types. */ + caughtClassTests: boolean; + /** Calls and reads of generator steps, and reads of iterator helper + * methods. */ + generatorSteps: boolean; + /** Values the runtime steps: iterator helper calls and reads, `dynIterN`, + * and rest destructuring. */ + runtimeSteps: boolean; + /** `dyn.classBasePrototype` calls. */ + basePrototypes: boolean; + /** The classes of thrown class instances, once each. */ + thrown: string[]; + /** `caughtToDyn` conversions. */ + caughtValues: boolean; +} + +/** The `libCall`s the class dispatch rewrite can change. */ +const DISPATCH_LIB_CALLS = new Set([ + "bytes.construct", + "dyn.keySet", + "dyn.keySetComputed", + "dyn.iterator", + "dyn.arrayFromIterator", + "dyn.toString", + "dyn.stringConstructor", + "dyn.forInKeys", + "dyn.forInKeyStrings", + "dyn.hasKeyComputed", + "dyn.hasOwnComputed", + "dyn.propertyIsEnumerableComputed", + "dyn.defineProperty", + "dyn.getPrototype", + "dyn.reflectGet", + "dyn.reflectSet", +]); + +/** Prototype data helpers (classPrototypeData) gain method slots inside + * their initialization block, so their summaries are not reused. */ +const PROTOTYPE_DATA_PREFIX = "%prototype.data."; + +/** SCRIPTC_CHECK_DISPATCH_SCANS=1 checks each reused summary against a fresh walk. */ +const CHECK_SCANS = process.env["SCRIPTC_CHECK_DISPATCH_SCANS"] === "1"; + +/** The function summaries (FunctionSites) of one class dispatch pass, kept + * across its runs. */ +export class DispatchSites { + private readonly sites = new Map(); + + /** The sites of `fn`. A summary is reused while the function's statement + * list is the same array with the same length and the same first and last + * statements: the dispatch passes replace the lists they rewrite and add + * statements to the helpers they extend. Other changes inside an + * unchanged list are reported with `forget`, except in prototype data + * helpers, which are scanned on every use. */ + of(fn: IrFunction): FunctionSites { + const cached = this.sites.get(fn); + if ( + cached !== undefined && + !fn.name.startsWith(PROTOTYPE_DATA_PREFIX) && + cached.statements === fn.body && + cached.length === fn.body.length && + cached.first === fn.body[0] && + cached.last === fn.body[fn.body.length - 1] + ) { + if (CHECK_SCANS && !sameSites(cached, scanSites(fn.body))) + throw new InternalCompilerError( + `reused dispatch sites of ${fn.name} disagree with a fresh scan`, + ); + return cached; + } + const sites = scanSites(fn.body); + this.sites.set(fn, sites); + return sites; + } + + /** Drops the summary of a function whose body changed inside an unchanged + * statement list. */ + forget(fn: IrFunction): void { + this.sites.delete(fn); + } +} + +function scanSites(statements: IrStmt[]): FunctionSites { + const sites: FunctionSites = { + statements, + length: statements.length, + first: statements[0], + last: statements[statements.length - 1], + values: [], + boxing: [], + serializes: false, + conversions: false, + dispatches: false, + keyTests: false, + caughtClassTests: false, + generatorSteps: false, + runtimeSteps: false, + basePrototypes: false, + thrown: [], + caughtValues: false, + }; + const valueKeys = new Set(); + const boxingKeys = new Set(); + const thrown = new Set(); + const box = (type: IrType, fieldClass?: string): void => { + const key = `${fieldClass ?? ""}\0${typeKey(type)}`; + if (boxingKeys.has(key)) return; + boxingKeys.add(key); + sites.boxing.push(fieldClass === undefined ? { type } : { type, fieldClass }); + }; + everyStmtList(statements, { + stmt: (statement) => { + if (statement.kind === "throw" && statement.value.type.kind === "object") { + const className = statement.value.type.className; + if (!thrown.has(className)) { + thrown.add(className); + sites.thrown.push(className); + } + } + // Field-creation instrumentation can box a base-constructor receiver + // even when only its derived instances otherwise cross the boundary. + if (statement.kind === "fieldSet" && isClassOwnEnumerableFieldName(statement.field)) + box(statement.obj.type, statement.className); + return true; + }, + expr: (expr) => { + if ( + expr.kind === "jsonStringify" || + (expr.kind === "libCall" && + (expr.fn === "json.stringifyValue" || expr.fn === "json.stringifyReplacer")) + ) + sites.serializes = true; + if ( + toPrimitiveHint(expr) !== null || + (expr.kind === "libCall" && (expr.fn === "dyn.valueOf" || expr.fn === "dyn.toString")) + ) + sites.conversions = true; + if ( + (expr.kind === "dynInvoke" && GENERATOR_STEP_METHODS.has(expr.method)) || + (expr.kind === "dynKeyGet" && + expr.key.kind === "strLit" && + (GENERATOR_STEP_METHODS.has(expr.key.value) || HELPER_METHODS.has(expr.key.value))) + ) + sites.generatorSteps = true; + if ( + (expr.kind === "dynInvoke" && HELPER_METHODS.has(expr.method)) || + (expr.kind === "dynKeyGet" && + expr.key.kind === "strLit" && + HELPER_METHODS.has(expr.key.value)) || + expr.kind === "dynIterN" || + (expr.kind === "libCall" && expr.fn === "dyn.destructureRest") + ) + sites.runtimeSteps = true; + switch (expr.kind) { + case "dynFrom": { + const key = typeKey(expr.value.type); + if (!valueKeys.has(key)) { + valueKeys.add(key); + sites.values.push(expr.value.type); + } + box(expr.value.type); + break; + } + case "fieldIncDec": + if (isClassOwnEnumerableFieldName(expr.field)) box(expr.obj.type, expr.className); + break; + case "call": + if ( + (expr.callee === "%dyn.class.properties" || + expr.callee === "%dyn.class.readProperties") && + expr.args[0]?.kind === "dynFrom" && + isDynTypedRefType(expr.args[0].value.type) + ) + sites.dispatches = true; + break; + case "dynHasKey": + sites.keyTests = true; + break; + case "caughtTest": + if (expr.test === "instanceof") sites.caughtClassTests = true; + break; + case "caughtNarrow": + case "caughtCheck": + if (expr.type.kind === "object") sites.caughtClassTests = true; + break; + case "caughtToDyn": + sites.caughtValues = true; + break; + case "dynKeyGet": + case "dynInvoke": + sites.dispatches = true; + break; + case "dynCheck": + if (isDynTypedRefType(expr.type)) sites.dispatches = true; + // A dynamic function checked into a native function type + // receives its arguments as dynamic values: class instances + // passed to it cross the boundary when it is called (a JS + // `list.map((rec) => rec.match(x))` callback with an untyped + // parameter). + if (expr.type.kind === "func") expr.type.params.forEach((type) => box(type)); + break; + case "libCall": + if (expr.fn === "dyn.classBasePrototype") sites.basePrototypes = true; + if (DISPATCH_LIB_CALLS.has(expr.fn)) sites.dispatches = true; + break; + } + return true; + }, + }); + return sites; +} + +function sameSites(left: FunctionSites, right: FunctionSites): boolean { + const sameList = (a: readonly T[], b: readonly T[], same: (x: T, y: T) => boolean): boolean => + a.length === b.length && a.every((item, index) => same(item, b[index]!)); + return ( + sameList(left.values, right.values, (a, b) => a === b) && + sameList( + left.boxing, + right.boxing, + (a, b) => a.type === b.type && a.fieldClass === b.fieldClass, + ) && + sameList(left.thrown, right.thrown, (a, b) => a === b) && + left.serializes === right.serializes && + left.conversions === right.conversions && + left.dispatches === right.dispatches && + left.keyTests === right.keyTests && + left.caughtClassTests === right.caughtClassTests && + left.generatorSteps === right.generatorSteps && + left.runtimeSteps === right.runtimeSteps && + left.basePrototypes === right.basePrototypes && + left.caughtValues === right.caughtValues + ); +} diff --git a/packages/compiler/src/frontend/lowering/generator-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/generator-dynamic-dispatch.ts index 3f43e5e05..db542412f 100644 --- a/packages/compiler/src/frontend/lowering/generator-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/generator-dynamic-dispatch.ts @@ -13,16 +13,20 @@ import { type IrStmt, type IrType, } from "../../ir/ir.js"; -import { everyStmtList, transformStmtList } from "../../ir/traverse.js"; +import { transformStmtList } from "../../ir/traverse.js"; import { genResultRecord } from "../type-mapper.js"; +import type { DispatchSites } from "./dispatch-sites.js"; import type { Lowerer } from "./lowerer.js"; type GeneratorType = Extract; type Invoke = Extract; type KeyGet = Extract; +/** The generator methods a dynamic call or read steps. */ +export const GENERATOR_STEP_METHODS = new Set(["next", "return", "throw"]); + /** The iterator helper methods the runtime answers on checked iterators. */ -const HELPER_METHODS = new Set([ +export const HELPER_METHODS = new Set([ "map", "filter", "take", @@ -101,6 +105,7 @@ export class GeneratorDynamicDispatch { lowerer: Lowerer, functions: readonly IrFunction[], classHelpers: ReadonlySet, + sites: DispatchSites, ): boolean { const seen = new Set(); const rewrite = new Set(); @@ -117,34 +122,14 @@ export class GeneratorDynamicDispatch { lowerer.shapes.get(type.shapeId)?.fields.forEach((field) => discover(field.type)); else if (type.kind === "union") lowerer.unions.get(type.unionId)?.arms.forEach(discover); }; - for (const fn of functions) - everyStmtList(fn.body, { - stmt: () => true, - expr: (expr) => { - if (expr.kind === "dynFrom") discover(expr.value.type); - if (expr.kind === "dynInvoke" && ["next", "return", "throw"].includes(expr.method)) - rewrite.add(fn); - // The runtime steps the values iterator helpers and destructuring - // receive. - if ( - (expr.kind === "dynInvoke" && HELPER_METHODS.has(expr.method)) || - (expr.kind === "dynKeyGet" && - expr.key.kind === "strLit" && - HELPER_METHODS.has(expr.key.value)) || - expr.kind === "dynIterN" || - (expr.kind === "libCall" && expr.fn === "dyn.destructureRest") - ) - runtimeSteps = true; - if ( - expr.kind === "dynKeyGet" && - expr.key.kind === "strLit" && - (["next", "return", "throw"].includes(expr.key.value) || - HELPER_METHODS.has(expr.key.value)) - ) - rewrite.add(fn); - return true; - }, - }); + for (const fn of functions) { + const found = sites.of(fn); + found.values.forEach(discover); + // The runtime steps the values iterator helpers and destructuring + // receive. + if (found.runtimeSteps) runtimeSteps = true; + if (found.generatorSteps) rewrite.add(fn); + } let changed = false; for (const fn of functions) { // The generator helper's fallback may itself dispatch native classes. @@ -159,7 +144,7 @@ export class GeneratorDynamicDispatch { if ( expr.kind === "dynKeyGet" && expr.key.kind === "strLit" && - (["next", "return", "throw"].includes(expr.key.value) || + (GENERATOR_STEP_METHODS.has(expr.key.value) || (HELPER_METHODS.has(expr.key.value) && this.generators.size > 0)) ) { const method = expr.key.value; @@ -173,8 +158,7 @@ export class GeneratorDynamicDispatch { loc: expr.loc, }; } - if (expr.kind !== "dynInvoke" || !["next", "return", "throw"].includes(expr.method)) - return expr; + if (expr.kind !== "dynInvoke" || !GENERATOR_STEP_METHODS.has(expr.method)) return expr; const key = JSON.stringify([expr.method, expr.args.length, expr.calleeName]); let dispatch = this.dispatches.get(key); if (!dispatch) { diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index b02a0a843..7a2bfd9e7 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -8231,13 +8231,17 @@ export class Lowerer { for (;;) { drainInstances(); const declared = unitsInOrder(); - const initializerChanged = this.deferredModuleInitializers.process(this, [ - ...declared, - ...initFunctions, - ...instanceFunctions, - ...byName(this.liftedFns), - ...byName(this.implicitFns), - ]); + const initializerChanged = this.deferredModuleInitializers.process( + this, + [ + ...declared, + ...initFunctions, + ...instanceFunctions, + ...byName(this.liftedFns), + ...byName(this.implicitFns), + ], + (fn) => classDispatch.sites.forget(fn), + ); const dispatchChanged = classDispatch.process(this, [ ...declared, ...initFunctions, From ed66338cb6a2e47c7dfd409487b4fd22853973d5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 09:25:44 -0500 Subject: [PATCH 14/19] Record the order-parity baseline for the byte-mode chunk fence fixture --- .../tests/diagnostics/stream-byte-mode-chunk-fences.ts.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stream-byte-mode-chunk-fences.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stream-byte-mode-chunk-fences.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stream-byte-mode-chunk-fences.ts.json new file mode 100644 index 000000000..8a02db2be --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stream-byte-mode-chunk-fences.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/stream-byte-mode-chunk-fences.ts" + ], + "diags": [] +} From 279cdcffae822a41934914bd1f2f71ff4966659f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 09:39:07 -0500 Subject: [PATCH 15/19] Name source files relative to the entry's directory in structural positions Structural positions started with a file's absolute path, so lowered names (closures, recursive records and unions, and every name keyed by a type that holds one) changed with the directory the program was compiled in. Union arms are ordered by their types' keys, so a program compiled in a new temporary directory could also get its union arms in another order. union-retype.test.ts compiles each program in a fresh temporary directory, and in about one run in ten the arms of the visitor's unions came out in an order where no retag keeps its tags, which failed "lowered identity retags share the box". Positions now name a file by its path relative to the entry's directory (default library files keep their base name), so the same program gets the same names wherever it sits. --- packages/compiler/src/frontend/lowering/lowerer.ts | 10 ++++++---- packages/compiler/test/stable-names.test.ts | 7 +++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 7a2bfd9e7..747e396af 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -181,7 +181,7 @@ export { newFnCtx, type FnCtx } from "./function-context.js"; * - Lexical scoping is resolved here: locals get function-unique ids * ("x.0", "x.1" for shadowing); the IR is scope-flat. */ -import { dirname, resolve } from "node:path"; +import { dirname, relative, resolve } from "node:path"; import { bindingSource } from "../binding-source.js"; import { tsgoPath } from "../dts-paths.js"; import * as ts from "../ts7/adapter.js"; @@ -3192,12 +3192,14 @@ export class Lowerer { errorPropertyDispatchRevision: string | null = null; /** Structural source positions (structural-positions.ts) for names that * stay the same when unrelated code moves. */ - /** Default library files are named by their base name: two checkers - * install them in different directories. */ + /** Files are named by their path relative to the entry's directory, so + * names do not depend on where the program sits, and default library + * files by their base name: two checkers install them in different + * directories. */ readonly positions = new StructuralPositions((sf) => this.program.isSourceFileDefaultLibrary(sf) ? `lib:${sf.fileName.slice(sf.fileName.lastIndexOf("/") + 1)}` - : sf.fileName, + : relative(dirname(this.entry.fileName), sf.fileName), ); /** The outermost node whose token span is exactly `loc`, or null. */ diff --git a/packages/compiler/test/stable-names.test.ts b/packages/compiler/test/stable-names.test.ts index 720472556..c82db039d 100644 --- a/packages/compiler/test/stable-names.test.ts +++ b/packages/compiler/test/stable-names.test.ts @@ -110,3 +110,10 @@ console.log(box.value); expect(locals).toHaveLength(2); expect(locals[0]!.name).not.toBe(locals[1]!.name); }); + +test("names do not depend on the directory the program sits in", async () => { + // Each call lowers in its own temporary directory. + const [first] = await lower(recursive); + const [second] = await lower(recursive); + expect(names(second!)).toEqual(names(first!)); +}); From 8487742019e824472ad29a96135c2e3553147ca6 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 09:48:41 -0500 Subject: [PATCH 16/19] Read the shared decoded value after an untyped listener, and skip discriminated unions - Once a 'message' listener without a typed entry has decoded the message, the listeners after it read that decoded value, so a typed listener sees what an earlier untyped listener changed, as in Node. - A union with a literal discriminator gets no typed message entry: its checked conversion also tests the discriminator, which the message readers do not. Corpus 7361 checks a typed listener after an untyped one that changes the message, and a listener on a discriminated union. --- packages/compiler/src/backend/llvm/dyn.ts | 8 ++-- ...7361-message-listeners-share-value.ts.json | 6 +++ packages/runtime/src/scr_events_emitter.c | 9 ++-- packages/runtime/src/scr_events_emitter.h | 4 +- .../7361-message-listeners-share-value.ts | 45 +++++++++++++++++++ ...7361-message-listeners-share-value.ts.json | 1 + 6 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7361-message-listeners-share-value.ts.json create mode 100644 tests/corpus/7361-message-listeners-share-value.ts create mode 100644 tests/harness/corpus-costs/7361-message-listeners-share-value.ts.json diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index ac01a9209..5c9d8e60f 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -4681,8 +4681,8 @@ export class LlDyn { /** Whether a message reader can build T: numbers, booleans and strings; * records of them with only declared fields, none of union type; and - * boxed unions of those arms and null or undefined, with at most one - * number, boolean and string arm. */ + * boxed unions of those arms and null or undefined without a literal + * discriminator, with at most one number, boolean and string arm. */ private messageReadable(t: IrType, seen: Set): boolean { switch (t.kind) { case "f64": @@ -4706,7 +4706,9 @@ export class LlDyn { } case "union": { const def = this.host.unionsById.get(t.unionId); - if (!def || this.host.nullableUnions.has(t.unionId)) return false; + // A discriminated union's checked conversion also tests the arm's + // literal discriminator, which the readers do not. + if (!def || def.discriminant || this.host.nullableUnions.has(t.unionId)) return false; const scalars = new Set(); for (const arm of def.arms) { if (arm.kind === "nullT" || arm.kind === "undefinedT") continue; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7361-message-listeners-share-value.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7361-message-listeners-share-value.ts.json new file mode 100644 index 000000000..f6a78ee9a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7361-message-listeners-share-value.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7361-message-listeners-share-value.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_events_emitter.c b/packages/runtime/src/scr_events_emitter.c index 5e03c0d6c..93a724ecb 100644 --- a/packages/runtime/src/scr_events_emitter.c +++ b/packages/runtime/src/scr_events_emitter.c @@ -1049,8 +1049,8 @@ SCR_COLD static void scr_ee_listener_mismatch(const ScrStr *name, bool typed_emi } /* A message emit (scr_emitter_emit_message): listeners with a typed message - * entry read the message; the others share one decoded argument, made when - * the first of them runs. */ + * entry read the message until a listener without one runs; that listener + * and every later one share one decoded argument. */ struct ScrEeMessage { const struct ScrMessage *message; ScrDyn *(*decode)(const struct ScrMessage *); @@ -1101,7 +1101,10 @@ static bool scr_ee_emit_core(ScrEmitter *em, ScrStr *name, va_list *ap_template, continue; } } - if (message && e->flex_message && e->flex_message(e->flex_cb, message->message)) { + /* Once a listener has the decoded value, later listeners read that + * value: an earlier listener may have changed it, as in Node. */ + if (message && !message->decoded && e->flex_message && + e->flex_message(e->flex_cb, message->message)) { scr_ee_entry_unref(e); continue; } diff --git a/packages/runtime/src/scr_events_emitter.h b/packages/runtime/src/scr_events_emitter.h index 6e1ef536a..29d4c397f 100644 --- a/packages/runtime/src/scr_events_emitter.h +++ b/packages/runtime/src/scr_events_emitter.h @@ -156,7 +156,9 @@ bool scr_emitter_emit(ScrEmitter *em, ScrStr *name, ...); bool scr_emitter_emit_flex(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc); /* An untyped emit of one message: a listener registered from a function * box with a typed message entry (scr_dyn_func_set_message) reads the - * message itself; the other listeners get decode(message), made once. */ + * message itself while no listener has needed the decoded value; from the + * first listener without an entry on, every listener gets decode(message), + * made once. */ struct ScrMessage; bool scr_emitter_emit_message(ScrEmitter *em, ScrStr *name, const struct ScrMessage *message, ScrDyn *(*decode)(const struct ScrMessage *)); diff --git a/tests/corpus/7361-message-listeners-share-value.ts b/tests/corpus/7361-message-listeners-share-value.ts new file mode 100644 index 000000000..2a4e287df --- /dev/null +++ b/tests/corpus/7361-message-listeners-share-value.ts @@ -0,0 +1,45 @@ +import { MessageChannel, type MessagePort } from "node:worker_threads"; + +// Listeners of one message share its value, as in Node: a typed listener +// that runs after an untyped one sees the changes the untyped one made, +// and a listener on a discriminated union reads the arm its discriminator +// names. +type Item = { id: number; name: string }; +type Shape = { kind: "circle"; size: number } | { kind: "square"; size: number; corner: string }; + +const lines: string[] = []; +let pending = 0; +const ports: MessagePort[] = []; +function finish(): void { + if (--pending > 0) return; + for (const line of lines) console.log(line); + for (const port of ports) port.close(); +} + +const shared = new MessageChannel(); +ports.push(shared.port1); +shared.port1.on("message", (value: Item) => { + lines.push(`typed first ${value.id} ${value.name}`); +}); +shared.port1.on("message", (value: any) => { + value.id = value.id * 10; + value.name = `${value.name}!`; +}); +shared.port1.on("message", (value: Item) => { + lines.push(`typed after ${value.id} ${value.name}`); + finish(); +}); +pending += 2; +shared.port2.postMessage({ id: 1, name: "a" }); +shared.port2.postMessage({ id: 2, name: "b" }); + +const shapes = new MessageChannel(); +ports.push(shapes.port1); +shapes.port1.on("message", (shape: Shape) => { + if (shape.kind === "circle") lines.push(`circle ${shape.size}`); + else lines.push(`square ${shape.size} ${shape.corner}`); + finish(); +}); +pending += 2; +shapes.port2.postMessage({ kind: "circle", size: 3 }); +shapes.port2.postMessage({ kind: "square", size: 4, corner: "round" }); diff --git a/tests/harness/corpus-costs/7361-message-listeners-share-value.ts.json b/tests/harness/corpus-costs/7361-message-listeners-share-value.ts.json new file mode 100644 index 000000000..c428eb8f1 --- /dev/null +++ b/tests/harness/corpus-costs/7361-message-listeners-share-value.ts.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 4600 } From cd2b1e2574ca8fde0f8516d7b8800a861246c312 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 09:55:01 -0500 Subject: [PATCH 17/19] Keep callback loop versions from nesting A callback loop version emits its loop twice. A loop inside either copy could be versioned again, so a nest of loops that each call a callback built just before them (`const g = adder(i)`) doubled at every level: a three-deep nest was emitted seven times per function. A loop inside a callback version, or the versioned loop itself emitted again for each copy, is no longer versioned, so a loop nest is emitted at most twice. The outer loop keeps its version; inner loops keep their calls as they are. --- .../src/backend/llvm/emit-statements.ts | 17 +++++----- .../test/callback-loop-versions.test.ts | 31 +++++++++++++++++++ 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/packages/compiler/src/backend/llvm/emit-statements.ts b/packages/compiler/src/backend/llvm/emit-statements.ts index ffd47c467..4fe1a27bd 100644 --- a/packages/compiler/src/backend/llvm/emit-statements.ts +++ b/packages/compiler/src/backend/llvm/emit-statements.ts @@ -1910,9 +1910,11 @@ export function emitSwitch(emitter: LlEmitter, s: IrStmt & { kind: "switch" }): /** The loop copies a callback loop version needs: the bindings the loop * calls that the constant-callback census resolves to one function, each * with that function's direct entry. Only small loops of optimized - * synchronous functions, and only bindings declared before the loop that - * an enclosing version has not already proved, qualify: the guard reads - * each binding before the loop runs. */ + * synchronous functions, and only bindings declared before the loop, + * qualify: the guard reads each binding before the loop runs. Versions do + * not nest: a loop inside either copy of a version (including the + * versioned loop itself, emitted again for each copy) keeps its calls as + * they are, so a loop nest is emitted at most twice. */ function callbackVersionBindings( emitter: LlEmitter, s: IrStmt & { kind: "for" | "while" | "doWhile" }, @@ -1920,7 +1922,7 @@ function callbackVersionBindings( if ( !emitter.callbackVersionsEnabled || emitter.currentConstantCallbacks.size === 0 || - emitter.callbackVersioning.has(s) + emitter.callbackVersioning.size > 0 ) return null; const found = new Map(); @@ -1930,12 +1932,7 @@ function callbackVersionBindings( const expr = (e: IrExpr): boolean => { if (++cost > MAX_CALLBACK_VERSION_COST) return false; if (e.kind === "assignExpr") bound.add(e.localId); - if ( - e.kind === "callValue" && - e.callee.kind === "varRef" && - !emitter.knownCallbacks.has(e.callee.localId) && - !found.has(e.callee.localId) - ) { + if (e.kind === "callValue" && e.callee.kind === "varRef" && !found.has(e.callee.localId)) { const local = emitter.currentLocals.get(e.callee.localId); const target = local && local.tdz !== true ? emitter.knownCallbackTarget(e.callee) : undefined; diff --git a/packages/compiler/test/callback-loop-versions.test.ts b/packages/compiler/test/callback-loop-versions.test.ts index c26abe1c4..63a669fd4 100644 --- a/packages/compiler/test/callback-loop-versions.test.ts +++ b/packages/compiler/test/callback-loop-versions.test.ts @@ -81,3 +81,34 @@ test("a callback declared in the loop body is not read before the loop", async ( `); expect(llvm).not.toContain("callback.version"); }); + +test("loops inside a callback version are not versioned again", async () => { + // Each inner loop calls a callback a factory builds just before it; without + // the limit, every copy of a version would version the next loop again. + const llvm = await emitLlvm(` + function adder(k: number): (v: number) => number { + return (v: number) => v + k; + } + function nest(n: number, f: (v: number) => number): number { + let s = 0; + for (let i = 0; i < n; i++) { + s += f(i); + const g = adder(i); + for (let j = 0; j < n; j++) { + s += g(j); + const h = adder(j); + for (let k = 0; k < n; k++) s += h(k); + } + } + return s; + } + console.log(nest(3, (v) => v - 1), nest(2, (v) => v * 3)); + `); + // One version for the outer loop of each compiled `nest`; its two copies + // keep the inner loops whole. + const versions = [...llvm.matchAll(/^define [^\n]*@sc_bf_nest[^]*?^}/gm)].map( + ([body]) => body.match(/^callback\.version\.fast\d+:$/gm)?.length ?? 0, + ); + expect(versions.every((count) => count <= 1)).toBe(true); + expect(versions.some((count) => count === 1)).toBe(true); +}); From b82c70d5de2853a368f13edb96ced5f14061fe2e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 09:56:19 -0500 Subject: [PATCH 18/19] Bound a follower's trip count only by a safe-integer span The trip count that bounds a follower's range was the driver's span over its step, computed in doubles. A span above 2^53 - 1 can round down: from -(2^53 - 1) to below 2^52 - 1 the span 3 * 2^52 - 3 becomes 3 * 2^52 - 4, so a follower that reaches 2^53 got the range limit 2^53 - 1 and ran as an unchecked integer. Such a span now bounds no trip count, and the follower is tested at run time (or the loop is refused when every value is known). Below it the quotient rounds to at least its floor, and the follower's products and sums are exact within the safe range. --- packages/compiler/src/ir/integer-loops.test.ts | 16 ++++++++++++++++ packages/compiler/src/ir/integer-loops.ts | 10 ++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/ir/integer-loops.test.ts b/packages/compiler/src/ir/integer-loops.test.ts index e1453abad..b6ac27e3a 100644 --- a/packages/compiler/src/ir/integer-loops.test.ts +++ b/packages/compiler/src/ir/integer-loops.test.ts @@ -525,4 +525,20 @@ test("counters stepped beside the tested one run as integers", () => { boxed.set("j", { ...vars.get("j")!, boxed: true }); expect(matchIntegerCountedForLoop(comoving(number(10)), boxed)).toBeNull(); expect(matchIntegerCountedForLoop(comoving(number(10), ref("i")), vars)).toBeNull(); + // A driver span above 2^53 - 1 bounds no trip count. From -(2^53 - 1) to + // below 2^52 - 1 the span 3 * 2^52 - 3 rounds down to 3 * 2^52 - 4, which + // would put this follower's last value at 2^53 - 1 instead of 2^53. + const wide = comoving(number(2 ** 52 - 1), number(-(2 ** 52) + 3), { + kind: "block", + body: [step("i", "+"), step("j", "+")], + loc, + }); + if (wide.init?.kind === "block" && wide.init.body[0]?.kind === "varDecl") + wide.init.body[0].init = number(-Number.MAX_SAFE_INTEGER); + expect(matchIntegerCountedForLoop(wide, vars)).toBeNull(); + if (wide.init?.kind === "block" && wide.init.body[1]?.kind === "varDecl") + wide.init.body[1].init = ref("from"); + expect(matchIntegerCountedForLoop(wide, vars)?.followers).toMatchObject([ + { startGuarded: true, tripGuarded: true }, + ]); }); diff --git a/packages/compiler/src/ir/integer-loops.ts b/packages/compiler/src/ir/integer-loops.ts index c4cdc327c..c451c0852 100644 --- a/packages/compiler/src/ir/integer-loops.ts +++ b/packages/compiler/src/ir/integer-loops.ts @@ -269,10 +269,12 @@ export function matchIntegerCountedForLoop( }; // On body entry k the driver holds `start + step * k`, so k is at most the // driver's span over |step|, and a follower holds its start plus its own - // step times k. - const trips = Math.floor( - (step > 0 ? range.max - startRange.min : startRange.max - range.min) / Math.abs(step), - ); + // step times k. A span above 2^53 - 1 can round down, so it bounds no + // trip count. Below it the quotient rounds to at least its floor, and the + // follower's products and sums below are exact while they stay within + // ±(2^53 - 1) and round outside it otherwise. + const span = step > 0 ? range.max - startRange.min : startRange.max - range.min; + const trips = span <= Number.MAX_SAFE_INTEGER ? Math.floor(span / Math.abs(step)) : Infinity; const followers: IntegerLoopFollower[] = []; for (const id of counterIds) { if (id === local.id) continue; From ca579641a45bebcd629b2081b101233613375596 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 10:04:30 -0500 Subject: [PATCH 19/19] Index quoted get: and set: method names under their own keys --- .../lowering/class-dynamic-dispatch.ts | 13 +++++----- .../class-quoted-accessor-keys.mjs.json | 6 +++++ tests/corpus/class-quoted-accessor-keys.mjs | 26 +++++++++++++++++++ .../class-quoted-accessor-keys.mjs.json | 1 + 4 files changed, 40 insertions(+), 6 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-quoted-accessor-keys.mjs.json create mode 100644 tests/corpus/class-quoted-accessor-keys.mjs create mode 100644 tests/harness/corpus-costs/class-quoted-accessor-keys.mjs.json diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index 79deb2aeb..e792de957 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -477,7 +477,7 @@ interface ReceiverIndex { /** By a method or generic method its base chain declares. */ methods: Map; /** By a method, generic method or accessor its base chain declares (an - * accessor under its property name), or a field it has. */ + * accessor under its key and under its property name), or a field it has. */ members: Map; rooted: number[]; } @@ -504,11 +504,12 @@ function receiverIndex(lowerer: Lowerer, plans: readonly PropertyReceiver[]): Re for (let c: ClassInfo | null = info; c; c = c.base) { for (const name of c.methods.keys()) { add(methods, name, position); - add( - members, - name.startsWith("get:") || name.startsWith("set:") ? name.slice(4) : name, - position, - ); + // A `get:`/`set:` key is an accessor of the property after the + // prefix, or a method whose quoted name has that prefix; the test + // looks up both. + add(members, name, position); + if (name.startsWith("get:") || name.startsWith("set:")) + add(members, name.slice(4), position); } if (c.genericMethods) for (const name of c.genericMethods.keys()) { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-quoted-accessor-keys.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-quoted-accessor-keys.mjs.json new file mode 100644 index 000000000..234634ebd --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-quoted-accessor-keys.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/class-quoted-accessor-keys.mjs" + ], + "diags": [] +} diff --git a/tests/corpus/class-quoted-accessor-keys.mjs b/tests/corpus/class-quoted-accessor-keys.mjs new file mode 100644 index 000000000..637300b43 --- /dev/null +++ b/tests/corpus/class-quoted-accessor-keys.mjs @@ -0,0 +1,26 @@ +// Methods whose quoted names begin with "get:" or "set:", next to an +// accessor of the property after the prefix, read and called through +// untyped values. +class Quoted { + "get:size"() { + return "method get:size"; + } + "set:size"(value) { + return `method set:size ${value}`; + } + get size() { + return 3; + } +} +class Sized { + constructor() { + this.size = 5; + } +} +const values = [new Quoted(), new Sized(), { "get:size": () => "record get:size", size: 1 }]; +for (const value of values) { + const getter = value["get:size"]; + console.log(typeof getter, getter?.call(value)); + console.log(typeof value["set:size"], value.size); + if (value instanceof Quoted) console.log(value["get:size"](), value["set:size"](7)); +} diff --git a/tests/harness/corpus-costs/class-quoted-accessor-keys.mjs.json b/tests/harness/corpus-costs/class-quoted-accessor-keys.mjs.json new file mode 100644 index 000000000..81da85b90 --- /dev/null +++ b/tests/harness/corpus-costs/class-quoted-accessor-keys.mjs.json @@ -0,0 +1 @@ +{ "plain": 3900, "san": 4000 }