From 3d1aad70e44ee4caf67f6bfa157b1bb084cb641c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 12 Oct 2026 11:17:03 -0700 Subject: [PATCH 1/6] Keep proven int32 arithmetic in 32-bit integers Integer arithmetic with an int32 result range materialized its number with `sitofp i64`, which LLVM does not know to be exact, so it could not fold the conversion back for the next integer use or through the phis of loop-carried locals: a counter written `i = (i + 1) | 0` converted to a double and back on every iteration. With int32 operands and result, `+`, `-` and `*` now emit `add/sub/mul nsw i32` and convert with `sitofp i32`; other int32-range numbers convert from the low half of their i64, and an int32-range read of a number uses `fptosi ... to i32`. The nsw flag stays on the 32-bit operation (a truncated 64-bit result would lose it), so IndVars can still widen such counters into 64-bit address arithmetic. --- .../src/backend/llvm/expr-primitives.ts | 21 ++++++- .../src/backend/llvm/integer-values.ts | 39 +++++++++++- .../test/integer-read-emission.test.ts | 62 +++++++++++++++++++ 3 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/integer-read-emission.test.ts diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 5dfe9cfec..f62b6f05c 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -11,7 +11,13 @@ import { BYTES_ELEM_NUM, closureIdentityEqual, f64Lit } from "./common.js"; import { emitStringInputs } from "./string-lifetimes.js"; import { emitStringParts, numberPart, stringParts } from "./string-construction.js"; import { emitBorrowedInputs } from "./borrowed-inputs.js"; -import { exactInteger, widenInteger, integerNumber } from "./integer-values.js"; +import { + exactInteger, + int32Number, + integerNumber, + narrowInteger, + widenInteger, +} from "./integer-values.js"; import { bitwiseIdentityOperand, integerArithmeticRange, @@ -233,9 +239,20 @@ export function emitOperatorExpr( const li = exactInteger(host, l, e.left), ri = exactInteger(host, r, e.right); if (li && ri) { + const range = e.op === "%" ? null : integerArithmeticRange(e.op, li.range, ri.range); + if (range && withinInt32(range) && withinInt32(li.range) && withinInt32(ri.range)) { + // Int32 operands and result: 32-bit arithmetic that cannot + // overflow. The nsw flag survives where a truncated 64-bit + // result would lose it, so LLVM can widen loop counters such + // as `i = (i + 1) | 0` into 64-bit address arithmetic. + const result = B.tmp(); + B.line( + `${result} = ${e.op === "+" ? "add" : e.op === "-" ? "sub" : "mul"} nsw i32 ${narrowInteger(host, li)}, ${narrowInteger(host, ri)}`, + ); + return int32Number(host, result, range, e.type); + } const left = widenInteger(host, li), right = widenInteger(host, ri); - const range = e.op === "%" ? null : integerArithmeticRange(e.op, li.range, ri.range); if (range) { const result = B.tmp(); B.line( diff --git a/packages/compiler/src/backend/llvm/integer-values.ts b/packages/compiler/src/backend/llvm/integer-values.ts index b20d748d2..63e279d04 100644 --- a/packages/compiler/src/backend/llvm/integer-values.ts +++ b/packages/compiler/src/backend/llvm/integer-values.ts @@ -40,6 +40,12 @@ export function exactInteger( if (value.uint32 !== undefined && range.min >= -2147483648 && range.max <= 2147483647) { return { name: value.uint32, type: "i32", signed: true, range }; } + if (range.min >= -2147483648 && range.max <= 2147483647) { + // An int32 conversion lets LLVM fold it with an int32 source's sitofp. + const name = host.B.tmp(); + host.B.line(`${name} = fptosi double ${value.name} to i32`); + return { name, type: "i32", signed: true, range }; + } const name = host.B.tmp(); host.B.line(`${name} = fptosi double ${value.name} to i64`); return { name, type: "i64", signed: true, range }; @@ -52,6 +58,26 @@ export function widenInteger(host: LlvmEmitterContext, value: LlInteger): string return wide; } +/** The i32 of an integer whose range is within int32. */ +export function narrowInteger(host: LlvmEmitterContext, value: LlInteger): string { + if (value.type === "i32") return value.name; + const narrow = host.B.tmp(); + host.B.line(`${narrow} = trunc i64 ${value.name} to i32`); + return narrow; +} + +/** `integerNumber` for an i32 whose range is within int32. */ +export function int32Number( + host: LlvmEmitterContext, + name: string, + range: IntegerRange, + type: IrType, +): LlValue { + const number = host.B.tmp(); + host.B.line(`${number} = sitofp i32 ${name} to double`); + return { name: number, type, uint32: name, integer: { name, type: "i32", signed: true, range } }; +} + export function integerNumber( host: LlvmEmitterContext, name: string, @@ -60,7 +86,18 @@ export function integerNumber( ): LlValue { const number = host.B.tmp(); const uint32 = host.B.tmp(); - host.B.line(`${number} = sitofp i64 ${name} to double`); host.B.line(`${uint32} = trunc i64 ${name} to i32`); + // A proven int32 converts from its low half. The value is the same, but + // LLVM folds `fptosi (sitofp i32 x)` back to x, also through the phis of + // loop-carried locals (whose other incoming values are int32 too, such as + // `x | 0`), while a 64-bit source is not known to convert exactly. So a + // counter written `i = (i + 1) | 0` stays an integer instead of taking a + // double round trip on every iteration. + const int32 = range.min >= -2147483648 && range.max <= 2147483647; + host.B.line( + int32 + ? `${number} = sitofp i32 ${uint32} to double` + : `${number} = sitofp i64 ${name} to double`, + ); return { name: number, type, uint32, integer: { name, type: "i64", signed: true, range } }; } diff --git a/packages/compiler/test/integer-read-emission.test.ts b/packages/compiler/test/integer-read-emission.test.ts new file mode 100644 index 000000000..92aa8e757 --- /dev/null +++ b/packages/compiler/test/integer-read-emission.test.ts @@ -0,0 +1,62 @@ +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, deserializeModule } from "../src/index.js"; +import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; + +test("int32 counters stay 32-bit integer arithmetic", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-integer-reads-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile( + entry, + ` +class Text { + pos: number; + end: number; + units: Uint16Array; + values: Float64Array; + constructor(n: number) { + this.units = new Uint16Array(n); + this.values = new Float64Array(n); + this.pos = 0; + this.end = n | 0; + } + skip(): number { + const units = this.units; + const end = this.end; + let pos = this.pos; + while (pos < end) { + const c = units[pos] | 0; + if (c === 42 || c > 127) break; + pos = (pos + 1) | 0; + } + this.pos = pos; + return pos; + } + sum(at: number): number { + return this.values[at] + this.units[at]; + } +} +const text = new Text(8); +console.log(text.skip(), text.sum(3), text.sum(9)); +`, + ); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const mod = deserializeModule(await readFile(outPath, "utf8")); + const ll = emitLlvmModule(mod); + const body = (name: string): string => + ll.match(new RegExp(`define internal [^\\n]+ @sc_bf_${name}\\([^\\n]*\\) #0 \\{([\\s\\S]*?)\\n\\}`))![1]!; + const skip = body("_x25_Text_skip"); + // The counter's increment is 32-bit arithmetic that cannot overflow and + // converts from i32, so LLVM folds the double round trip of the local. + expect(skip).toMatch(/ = add nsw i32 /); + expect(skip).not.toMatch(/ = sitofp i64 /); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); From f9368ce8b4c1a88da4f97d28d7525203ef04b450 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 12 Oct 2026 11:17:04 -0700 Subject: [PATCH 2/6] Compute ToUint32 of large magnitudes without frem The slow path of ToUint32 (NaN, the infinities and magnitudes above 2^53) reduced the value with `frem`, which lowers to an `fmod` call. The call is pure, so LLVM sometimes hoisted it above the range test onto the fast path (65,865 calls in one tsc-ts parse). A finite double above 2^53 is its integer mantissa shifted left by exponent - 1075 >= 1, so its low 32 bits are the shifted mantissa's (none once the shift reaches 64), negated for a negative value. NaN and the infinities have the maximum exponent and give 0. The slow path is now integer arithmetic on the double's bits with no branches. --- .../compiler/src/backend/llvm/expr-bytes.ts | 69 +++++------ .../test/integer-bitwise-arithmetic.test.ts | 7 +- .../test/integer-view-emission.test.ts | 2 +- packages/compiler/test/llvm-remainder.test.ts | 5 +- .../corpus/4730-touint32-large-magnitudes.ts | 117 ++++++++++++++++++ 5 files changed, 160 insertions(+), 40 deletions(-) create mode 100644 tests/corpus/4730-touint32-large-magnitudes.ts diff --git a/packages/compiler/src/backend/llvm/expr-bytes.ts b/packages/compiler/src/backend/llvm/expr-bytes.ts index 0c4cfbc8d..a861132d7 100644 --- a/packages/compiler/src/backend/llvm/expr-bytes.ts +++ b/packages/compiler/src/backend/llvm/expr-bytes.ts @@ -361,47 +361,46 @@ export function emitToUint32( B.br(done); B.startBlock(slowLabel); - const ordered = B.tmp(); - const belowInf = B.tmp(); - const aboveNegInf = B.tmp(); - const finiteRange = B.tmp(); - const finite = B.tmp(); - B.line(`${ordered} = fcmp ord double ${value}, ${value}`); - B.line(`${belowInf} = fcmp olt double ${value}, ${F64_INF}`); - B.line(`${aboveNegInf} = fcmp ogt double ${value}, ${f64Lit(-Infinity)}`); - B.line(`${finiteRange} = and i1 ${belowInf}, ${aboveNegInf}`); - B.line(`${finite} = and i1 ${ordered}, ${finiteRange}`); - const finiteLabel = B.newLabel("uint32.coerce.finite"); - const nonfiniteLabel = B.newLabel("uint32.coerce.nonfinite"); - const slowDone = B.newLabel("uint32.coerce.slow.done"); - B.condBr(finite, finiteLabel, nonfiniteLabel); - - B.startBlock(finiteLabel); - // Only magnitudes above 2^53 reach this block, and every such double is - // already an integer: no truncation is needed before the reduction. - const residue = B.tmp(); + // Only NaN, the infinities and magnitudes above 2^53 reach this block. + // Such a finite double is the integer mantissa * 2^shift with shift >= 1, + // so its low 32 bits are the shifted mantissa's (none once shift >= 64), + // negated for a negative value. NaN and the infinities have the maximum + // exponent, a shift of 972, and convert to 0 like ToUint32. Integer + // arithmetic only: the former `frem` became an `fmod` call that LLVM + // could hoist above the range test, onto the fast path. + const bits = B.tmp(); + const exponentBits = B.tmp(); + const exponent = B.tmp(); + const fraction = B.tmp(); + const mantissa = B.tmp(); + const shift = B.tmp(); + const inWord = B.tmp(); + const safeShift = B.tmp(); + const shifted = B.tmp(); + const magnitude = B.tmp(); + const low = B.tmp(); const negative = B.tmp(); - const wrapped = B.tmp(); - const normalized = B.tmp(); - const finiteU32 = B.tmp(); - B.line(`${residue} = frem double ${value}, ${f64Lit(4294967296)}`); - B.line(`${negative} = fcmp olt double ${residue}, ${f64Lit(0)}`); - B.line(`${wrapped} = fadd double ${residue}, ${f64Lit(4294967296)}`); - B.line(`${normalized} = select i1 ${negative}, double ${wrapped}, double ${residue}`); - B.line(`${finiteU32} = fptoui double ${normalized} to i32`); - B.br(slowDone); - - B.startBlock(nonfiniteLabel); - B.br(slowDone); - - B.startBlock(slowDone); + const negated = B.tmp(); const slowU32 = B.tmp(); - B.line(`${slowU32} = phi i32 [ ${finiteU32}, %${finiteLabel} ], [ 0, %${nonfiniteLabel} ]`); + B.line(`${bits} = bitcast double ${value} to i64`); + B.line(`${exponentBits} = lshr i64 ${bits}, 52`); + B.line(`${exponent} = and i64 ${exponentBits}, 2047`); + B.line(`${fraction} = and i64 ${bits}, 4503599627370495`); + B.line(`${mantissa} = or i64 ${fraction}, 4503599627370496`); + B.line(`${shift} = sub i64 ${exponent}, 1075`); + B.line(`${inWord} = icmp ult i64 ${shift}, 64`); + B.line(`${safeShift} = select i1 ${inWord}, i64 ${shift}, i64 0`); + B.line(`${shifted} = shl i64 ${mantissa}, ${safeShift}`); + B.line(`${magnitude} = select i1 ${inWord}, i64 ${shifted}, i64 0`); + B.line(`${low} = trunc i64 ${magnitude} to i32`); + B.line(`${negative} = icmp slt i64 ${bits}, 0`); + B.line(`${negated} = sub i32 0, ${low}`); + B.line(`${slowU32} = select i1 ${negative}, i32 ${negated}, i32 ${low}`); B.br(done); B.startBlock(done); const out = B.tmp(); - B.line(`${out} = phi i32 [ ${fastU32}, %${fastLabel} ], [ ${slowU32}, %${slowDone} ]`); + B.line(`${out} = phi i32 [ ${fastU32}, %${fastLabel} ], [ ${slowU32}, %${slowLabel} ]`); return out; } diff --git a/packages/compiler/test/integer-bitwise-arithmetic.test.ts b/packages/compiler/test/integer-bitwise-arithmetic.test.ts index 7fbeeffa5..f35af5c31 100644 --- a/packages/compiler/test/integer-bitwise-arithmetic.test.ts +++ b/packages/compiler/test/integer-bitwise-arithmetic.test.ts @@ -37,8 +37,11 @@ console.log(safe(0.5)); new RegExp(`define internal [^\\n]+ @sc_f_${name}\\([^\\n]*\\) #0 \\{([\\s\\S]*?)\\n\\}`), )![1]!; expect(llBody("safe")).toContain(" = add nsw i64 "); - // Only the arbitrary input's slow ToUint32 path still adds doubles. - expect(llBody("safe").match(/ = fadd double /g)).toHaveLength(1); + // The arbitrary input's slow ToUint32 path is integer arithmetic: no + // double additions and no `frem` (an `fmod` call LLVM could hoist). + expect(llBody("safe")).toContain("uint32.coerce.slow"); + expect(llBody("safe")).not.toContain(" = fadd double "); + expect(llBody("safe")).not.toContain("frem"); expect(llBody("unknown")).toContain("uint32.coerce.slow"); expect(llBody("unknown")).toContain(" = fadd double "); expect(llBody("negativeZero")).toContain(" = fadd double "); diff --git a/packages/compiler/test/integer-view-emission.test.ts b/packages/compiler/test/integer-view-emission.test.ts index 0644ecbb4..5072e73f8 100644 --- a/packages/compiler/test/integer-view-emission.test.ts +++ b/packages/compiler/test/integer-view-emission.test.ts @@ -46,7 +46,7 @@ console.log(mix(-0, new Uint32Array(3)), floating(1.75, false), observedAfter(1. const mix = body(ll, "mix"); expect(mix).toContain("integer view state"); // The original input is converted once, before entering the loop. - expect(mix.match(/^uint32\.coerce\.slow\.[^\n]*:/gm)).toHaveLength(1); + expect(mix.match(/^uint32\.coerce\.slow\d+:/gm)).toHaveLength(1); const loopStart = mix.search(/\nloop\.b[._\d]/); expect(loopStart).toBeGreaterThan(0); const loop = mix.slice(loopStart); diff --git a/packages/compiler/test/llvm-remainder.test.ts b/packages/compiler/test/llvm-remainder.test.ts index 8c05a7cff..6c8ff12fc 100644 --- a/packages/compiler/test/llvm-remainder.test.ts +++ b/packages/compiler/test/llvm-remainder.test.ts @@ -35,8 +35,9 @@ console.log(remainder(5, 2.5), signed(-7.5), maybeZero(7.5, 2.5)); const fn = (name: string): string => new RegExp(`^define internal [^\\n]*@sc_[bf]+_${name}\\([^]*?^}`, "m").exec(ll)![0]; // Four source remainders keep a floating fallback behind a runtime - // integer check; three ToUint32 coercion fallbacks stay floating. - expect(ll.match(/ = frem double /g)).toHaveLength(7); + // integer check; ToUint32 coercion fallbacks use integer arithmetic. + expect(ll.match(/ = frem double /g)).toHaveLength(4); + expect(ll).toContain("uint32.coerce.slow"); expect(ll.match(/ = srem i64 /g)).toHaveLength(5); expect(ll).toMatch(/ = select i1 [^\n]+double [^\n]+double /); // Unproven operands: both round-trip checks, the 2^53 dividend bound, diff --git a/tests/corpus/4730-touint32-large-magnitudes.ts b/tests/corpus/4730-touint32-large-magnitudes.ts new file mode 100644 index 000000000..43e12efef --- /dev/null +++ b/tests/corpus/4730-touint32-large-magnitudes.ts @@ -0,0 +1,117 @@ +// ToInt32/ToUint32 of doubles beyond 2^53, NaN and the infinities (the slow +// path of every bitwise operator and integer typed-array store), and int32 +// loop counters written `i = (i + 1) | 0`, whose values convert back and +// forth between integers and doubles. + +const big: number[] = [ + 2 ** 53 + 2, + -(2 ** 53 + 2), + 2 ** 53 + 4, + 2 ** 53 * 3 + 6, + 2 ** 54 + 4, + -(2 ** 54 + 12), + 2 ** 60 + 2 ** 33 + 2 ** 12, + -(2 ** 60 + 2 ** 31 + 2 ** 9), + 2 ** 62 + 2 ** 31, + 2 ** 63, + -(2 ** 63), + 2 ** 63 + 2 ** 11, + 2 ** 64 - 2 ** 11, + 2 ** 64, + 2 ** 64 + 2 ** 12, + -(2 ** 64 + 2 ** 12), + 2 ** 84 + 2 ** 33, + 2 ** 85 + 2 ** 33, + 2 ** 1023, + 1.7976931348623157e308, + -1.7976931348623157e308, + 1e21, + -1e21, + 123456789012345680000, + 0 / 0, + 1 / 0, + -1 / 0, +]; + +function show(label: string, value: number): string { + return `${label}=${value}`; +} + +for (const x of big) { + const parts = [ + show("|0", x | 0), + show(">>>0", x >>> 0), + show("~", ~x), + show("&ffff", x & 0xffff), + show("^1", x ^ 1), + show("<<1", x << 1), + show(">>1", x >> 1), + show(">>>1", x >>> 1), + show("1<>> 0) + (value >> 3)) | 0; +} +console.log("mixed", mixed); + +// int32 counters: the loop variable converts to a double for every number use. +function sumCounters(start: number, end: number): number { + let sum = 0; + for (let i = start | 0; i < end; i = (i + 1) | 0) sum += i * 0.5; + return sum; +} +console.log("counters", sumCounters(0, 1000), sumCounters(-500, 500), sumCounters(2147483000, 2147483647)); + +function scanUntil(units: Uint16Array, pos: number, stop: number): number { + let i = pos | 0; + while (i < units.length && (units[i] | 0) !== stop) i = (i + 1) | 0; + return i; +} +const units = new Uint16Array(64); +for (let i = 0; i < units.length; i++) units[i] = 97 + (i % 26); +console.log("scan", scanUntil(units, 0, 122), scanUntil(units, 26, 122), scanUntil(units, 60, 0)); + +// A counter that wraps at 2^31: its increment is not an int32, so it stays a +// double until `| 0` wraps it. +const wrapped: number[] = []; +let w = 2147483645; +for (let n = 0; n < 6; n++) { + wrapped.push(w); + w = (w + 1) | 0; +} +console.log("wrap", wrapped.join(",")); + +// An index that goes negative: integer reads past either end stay undefined. +const small = new Uint16Array([10, 20, 30]); +const reads: string[] = []; +for (let i = -2; i < 5; i = (i + 1) | 0) reads.push(String(small[i])); +console.log("reads", reads.join(",")); From b12b5b98c7e696c028c1394839c399535bc0a938 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 12 Oct 2026 11:17:40 -0700 Subject: [PATCH 3/6] Keep NaN-coded integer reads and their compares as integers A typed-array read at an unproven index joins the element with NaN for an invalid index. For an integer element that join was `phi double [sitofp x, NaN]`, which hides the integer from LLVM, and once `| 0` turned the NaN arm into 0, InstCombine moved the `sitofp` of the result into the join (`phi double [sitofp x, 0.0]`) where nothing folds it back: a loop over `const c = units[i] | 0` converted every element to a double and back, and compared it as a double. An integer element now joins as `phi i32 [x, 0]` plus a validity bit and converts after the join (`select valid, sitofp/uitofp x, NaN`). A declared local whose every store is a bitwise result, and that a loop compares, also keeps an i32 view: its stores already carry the integer, and compares of the view stay integer compares, which also leaves the integer join with more than one user, so the conversion is not folded into it. --- .../compiler/src/backend/llvm/expr-bytes.ts | 22 +++- .../src/backend/llvm/integer-views.ts | 45 ++++++- .../test/integer-read-emission.test.ts | 10 +- .../corpus/4733-typed-array-read-compares.ts | 122 ++++++++++++++++++ 4 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 tests/corpus/4733-typed-array-read-compares.ts diff --git a/packages/compiler/src/backend/llvm/expr-bytes.ts b/packages/compiler/src/backend/llvm/expr-bytes.ts index a861132d7..f5b574516 100644 --- a/packages/compiler/src/backend/llvm/expr-bytes.ts +++ b/packages/compiler/src/backend/llvm/expr-bytes.ts @@ -269,13 +269,29 @@ function emitBytesGetLocal( B.br(done); B.startBlock(done); const out = B.tmp(); - B.line(`${out} = phi double [ ${loaded.name}, %${ok} ], [ ${f64Lit(NaN)}, %${invalid} ]`); + if (loaded.uint32 === undefined) { + B.line(`${out} = phi double [ ${loaded.name}, %${ok} ], [ ${f64Lit(NaN)}, %${invalid} ]`); + return { name: out, type: F64 }; + } // The hint is ToUint32 of the value, and ToUint32(NaN) is 0, so it holds // for an invalid index too. Integer ranges (which would claim more) are // never derived for NaN-coded reads. - if (loaded.uint32 === undefined) return { name: out, type: F64 }; - const u32 = B.tmp(); + // + // An integer element joins as an integer and a validity bit, and the + // number converts after the join. A join of the numbers, `phi [sitofp x, + // NaN]`, hides the integer from LLVM: compares of the number stay floating, + // and once `| 0` makes the NaN arm 0, InstCombine moves the conversion into + // that join (`phi [sitofp x, 0.0]`) and never folds it back. Converting + // the joined integer gives it two users (this conversion and the ToUint32 + // consumers), so a compare of `x | 0` stays an integer compare. + const u32 = B.tmp(), + valid = B.tmp(), + number = B.tmp(); B.line(`${u32} = phi i32 [ ${loaded.uint32}, %${ok} ], [ 0, %${invalid} ]`); + B.line(`${valid} = phi i1 [ true, %${ok} ], [ false, %${invalid} ]`); + const signed = elem === "i8" || elem === "i16" || elem === "i32"; + B.line(`${number} = ${signed ? "sitofp" : "uitofp"} i32 ${u32} to double`); + B.line(`${out} = select i1 ${valid}, double ${number}, double ${f64Lit(NaN)}`); return { name: out, type: F64, uint32: u32 }; } diff --git a/packages/compiler/src/backend/llvm/integer-views.ts b/packages/compiler/src/backend/llvm/integer-views.ts index 49f84c2fd..c38770160 100644 --- a/packages/compiler/src/backend/llvm/integer-views.ts +++ b/packages/compiler/src/backend/llvm/integer-views.ts @@ -6,7 +6,14 @@ import { byteNumberAccess } from "../../ir/byte-numbers.js"; * consumers in loops can reuse it. The number remains authoritative for floating * arithmetic, signed zero, returns and zero-iteration loops. Only explicit * local stores maintain these views; captures and implicit binders stay on - * the ordinary path. No range or integer assumption is made about inputs. */ + * the ordinary path. No range or integer assumption is made about inputs. + * + * A declared local that only ever holds bitwise results (`c = units[i] | 0`) + * also gets a view when a loop compares it: its stores already have the + * integer, and compares of the view stay integer compares. Without it each + * compare converts the stored number back, and LLVM moves that number's + * conversion into the join of a NaN-coded typed-array read (`phi [sitofp x, + * 0.0]`), where nothing folds it back to the integer. */ export function findIntegerViews(fn: IrFunction): Set { const wanted = new Set(); if (fn.async || fn.generator) return wanted; @@ -19,7 +26,19 @@ export function findIntegerViews(fn: IrFunction): Set { if (loopDepth > 0 && expr.kind === "varRef" && expr.type.kind === "f64") wanted.add(expr.localId); }; + const declared = new Set(); + const compared = new Set(); + const nonBitwise = new Set(fn.params.map((p) => p.localId)); + const stored = (localId: string, value: IrExpr | null | undefined): void => { + if (!value || !bitwiseValue(value)) nonBitwise.add(localId); + }; const expr = (node: IrExpr): boolean => { + if (node.kind === "assignExpr") stored(node.localId, node.value); + else if (node.kind === "incDec") nonBitwise.add(node.localId); + else if (loopDepth > 0 && node.kind === "bin" && COMPARES.has(node.op)) { + for (const side of [node.left, node.right]) + if (side.kind === "varRef" && side.type.kind === "f64") compared.add(side.localId); + } if ( node.kind === "bin" && (node.op === "&" || @@ -48,7 +67,11 @@ export function findIntegerViews(fn: IrFunction): Set { node.kind === "while" || node.kind === "doWhile"; if (loop) loopDepth++; - if (node.kind === "varDecl") initialized.add(node.localId); + if (node.kind === "varDecl") { + initialized.add(node.localId); + declared.add(node.localId); + stored(node.localId, node.init); + } else if (node.kind === "assign") stored(node.localId, node.value); if (node.kind === "forOf") excluded.add(node.localId); // Canonical induction is maintained separately by the loop emitter. if (node.kind === "for" && node.init?.kind === "varDecl") excluded.add(node.init.localId); @@ -67,6 +90,7 @@ export function findIntegerViews(fn: IrFunction): Set { // A conversion observed only after a floating loop must not add work to // each iteration. Expression results still carry their existing views. fn.body.every(stmt); + for (const id of compared) if (declared.has(id) && !nonBitwise.has(id)) wanted.add(id); const eligible = new Set( fn.locals .filter( @@ -82,3 +106,20 @@ export function findIntegerViews(fn: IrFunction): Set { for (const id of wanted) if (!eligible.has(id)) wanted.delete(id); return wanted; } + +const COMPARES = new Set(["===", "!==", "<", "<=", ">", ">="]); + +/** Whether a value is always a bitwise result, whose ToUint32 view is free. */ +function bitwiseValue(value: IrExpr): boolean { + if (value.kind === "bin") + return ( + value.op === "&" || + value.op === "|" || + value.op === "^" || + value.op === "<<" || + value.op === ">>" || + value.op === ">>>" + ); + if (value.kind === "unary") return value.op === "~"; + return value.kind === "libCall" && (value.fn === "math.imul" || value.fn === "math.clz32"); +} diff --git a/packages/compiler/test/integer-read-emission.test.ts b/packages/compiler/test/integer-read-emission.test.ts index 92aa8e757..9850c6fa1 100644 --- a/packages/compiler/test/integer-read-emission.test.ts +++ b/packages/compiler/test/integer-read-emission.test.ts @@ -5,7 +5,7 @@ import { expect, test } from "vitest"; import { compile, deserializeModule } from "../src/index.js"; import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; -test("int32 counters stay 32-bit integer arithmetic", async () => { +test("int32 counters and typed-array reads keep integers visible to LLVM", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-integer-reads-")); try { const entry = join(dir, "main.ts"); @@ -56,6 +56,14 @@ console.log(text.skip(), text.sum(3), text.sum(9)); // converts from i32, so LLVM folds the double round trip of the local. expect(skip).toMatch(/ = add nsw i32 /); expect(skip).not.toMatch(/ = sitofp i64 /); + // The read joins its integer and a validity bit; the number converts after the join. + expect(skip).toMatch(/ = phi i32 \[ %\w+, %bytes\.read\.ok\d+ \], \[ 0, %bytes\.read\.invalid\d+ \]/); + expect(skip).toMatch(/ = phi i1 \[ true, %bytes\.read\.ok\d+ \], \[ false, %bytes\.read\.invalid\d+ \]/); + expect(skip).not.toMatch(/ = phi double \[ %\w+, %bytes\.read\.ok/); + // A floating element has no integer view and keeps the joined number. + const sum = body("_x25_Text_sum"); + expect(sum).toMatch(/ = phi double \[ %\w+, %bytes\.read\.ok\d+ \], \[ 0x7FF8000000000000/); + expect(sum).toMatch(/ = phi i1 \[ true, %bytes\.read\.ok/); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/tests/corpus/4733-typed-array-read-compares.ts b/tests/corpus/4733-typed-array-read-compares.ts new file mode 100644 index 000000000..2fcd81918 --- /dev/null +++ b/tests/corpus/4733-typed-array-read-compares.ts @@ -0,0 +1,122 @@ +// Typed-array reads at unproven indices, past the end (undefined, so NaN, +// so 0 after `| 0`), and compares of bitwise locals in loops: signed and +// unsigned elements, values above 2^31, and counters that leave the array. + +function scanUntil(units: Uint16Array, start: number, stop: number): number { + let pos = start | 0; + const end = (units.length + 2) | 0; + while (pos < end) { + const c = units[pos] | 0; + if (c === stop || c === 0) break; + pos = (pos + 1) | 0; + } + return pos; +} + +function classify32(values: Int32Array, extra: number): string { + const out: string[] = []; + for (let i = 0; i < values.length + extra; i = (i + 1) | 0) { + const v = values[i] | 0; + const u = values[i] >>> 0; + let kind = ""; + if (v < 0) kind += "neg"; + else if (v === 0) kind += "zero"; + else if (v <= 127) kind += "small"; + else kind += "big"; + if (u > 2147483647) kind += "/high"; + out.push(`${i}:${kind}`); + } + return out.join(" "); +} + +function classifyU32(values: Uint32Array, extra: number): string { + const out: string[] = []; + for (let i = 0; i < values.length + extra; i = (i + 1) | 0) { + const v = values[i] | 0; + const u = values[i] >>> 0; + let kind = ""; + if (v < 0) kind += "neg"; + else if (v === 0) kind += "zero"; + else if (v <= 127) kind += "small"; + else kind += "big"; + if (u > 2147483647) kind += "/high"; + out.push(`${i}:${kind}`); + } + return out.join(" "); +} + +function classify16(values: Int16Array, extra: number): string { + const out: string[] = []; + for (let i = 0; i < values.length + extra; i = (i + 1) | 0) { + const v = values[i] | 0; + const u = values[i] >>> 0; + let kind = ""; + if (v < 0) kind += "neg"; + else if (v === 0) kind += "zero"; + else if (v <= 127) kind += "small"; + else kind += "big"; + if (u > 2147483647) kind += "/high"; + out.push(`${i}:${kind}`); + } + return out.join(" "); +} + +function classify8(values: Int8Array, extra: number): string { + const out: string[] = []; + for (let i = 0; i < values.length + extra; i = (i + 1) | 0) { + const v = values[i] | 0; + const u = values[i] >>> 0; + let kind = ""; + if (v < 0) kind += "neg"; + else if (v === 0) kind += "zero"; + else if (v <= 127) kind += "small"; + else kind += "big"; + if (u > 2147483647) kind += "/high"; + out.push(`${i}:${kind}`); + } + return out.join(" "); +} + +function countBetween(bytes: Uint8Array, lo: number, hi: number): number { + let n = 0; + for (let i = 0; i <= bytes.length; i = (i + 1) | 0) { + const b = bytes[i] & 255; + if (b >= lo && b <= hi) n = (n + 1) | 0; + const x = bytes[i] ^ 0; + if (x !== b) n = (n + 1000) | 0; + } + return n; +} + +function mixed(units: Uint16Array): number { + let h = 0; + let i = 0; + while (i < units.length + 3) { + const c = units[i] | 0; + const d = ~units[i + 1]; + if (c > 64 && d < -65) h = (Math.imul(h, 31) + c) | 0; + else if (c === 0) h = h ^ 0x5bd1e995; + i = (i + 1) | 0; + } + return h; +} + +const text = "abc/*x*/def\nghi*jk\u00e9\u4e2d\uffff"; +const units = new Uint16Array(text.length); +for (let i = 0; i < text.length; i++) units[i] = text.charCodeAt(i); +console.log(scanUntil(units, 0, 42), scanUntil(units, 3, 10), scanUntil(units, 0, 7), scanUntil(units, -2, 99)); +console.log(scanUntil(units, 2.5, 42), scanUntil(units, units.length - 1, 42), scanUntil(units, 1e10, 42)); + +const i32 = new Int32Array([0, -1, 1, 127, 128, -2147483648, 2147483647, 65536]); +const u32 = new Uint32Array([0, 1, 4294967295, 2147483648, 2147483647, 300]); +const i16 = new Int16Array([-32768, 32767, -1, 0, 5]); +const i8 = new Int8Array([-128, 127, -1, 0]); +console.log(classify32(i32, 2)); +console.log(classifyU32(u32, 1)); +console.log(classify16(i16, 0)); +console.log(classify8(i8, 3)); + +const bytes = new Uint8Array(64); +for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 37 + 11) & 255; +console.log(countBetween(bytes, 0, 0), countBetween(bytes, 32, 126), countBetween(bytes, 200, 255)); +console.log(mixed(units), mixed(new Uint16Array(0)), mixed(new Uint16Array([65, 66, 0, 120]))); From bcad9b38921ccce0384160546f32fc77a77e1655 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 12 Oct 2026 11:17:40 -0700 Subject: [PATCH 4/6] Inline the ASCII paths of UTF-16 and byte offset conversion scr_u16_to_byte_c and scr_byte_to_u16_c run on every native charCodeAt, substring and search result. Most calls are the identity of an ASCII string or a hit in the cached ASCII run, but every call paid the prologue of the full walk. Those two cases are now static inline, and the walk is a separate function. A forward access up to 4 KiB past the cached run first extends the run word by word (with 256 bytes of lookahead) instead of walking from the cursor and re-centring a 128-byte window, which made a scanner reading a long ASCII stretch of a non-ASCII text walk repeatedly. --- packages/runtime/src/scr_string.c | 67 +++++++++- .../corpus/4731-string-mixed-ascii-slices.ts | 121 ++++++++++++++++++ 2 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 tests/corpus/4731-string-mixed-ascii-slices.ts diff --git a/packages/runtime/src/scr_string.c b/packages/runtime/src/scr_string.c index 30dee160c..bec6a8662 100644 --- a/packages/runtime/src/scr_string.c +++ b/packages/runtime/src/scr_string.c @@ -1081,13 +1081,44 @@ static void scr_sidx_ascii_run(const ScrStr *s, ScrSidx *e) { e->ascii_end = end; } +/* How far past its end a forward access may grow the cached ASCII run, and + * how far beyond the access the growth looks ahead. */ +#define SCR_SIDX_ASCII_REACH ((size_t)4096) +#define SCR_SIDX_ASCII_AHEAD ((size_t)256) + +/* Grow the cached ASCII run forward until it covers byte `want` (plus a + * lookahead) or meets a non-ASCII byte. The run stays one contiguous ASCII + * span starting at the unit/byte pair (ascii_cu, ascii_cb), so a scan that + * reads a mostly ASCII text front to back (a scanner taking substrings at + * increasing offsets) tests each byte once instead of walking from the + * cursor and re-centring the run every 128 bytes. Only accesses up to + * SCR_SIDX_ASCII_REACH past the run try: the walk below bounds the rest. */ +static bool scr_sidx_ascii_grow(const ScrStr *s, ScrSidx *e, size_t want) { + size_t end = e->ascii_end; + if (want < end || want >= s->len || want - end > SCR_SIDX_ASCII_REACH) return false; + size_t limit = s->len - want > SCR_SIDX_ASCII_AHEAD ? want + SCR_SIDX_ASCII_AHEAD : s->len; + while (limit - end >= 8) { + uint64_t word; + memcpy(&word, s->data + end, 8); + if (word & UINT64_C(0x8080808080808080)) break; + end += 8; + } + while (end < limit && (unsigned char)s->data[end] < 0x80) end++; + e->ascii_end = end; + return want < end; +} + +static size_t scr_u16_to_byte_walk(const ScrStr *s, ScrSidx *e, size_t u16, + bool *mid); + /* Convert a UTF-16 index to a byte offset from the closest sparse anchor or * hot cursor. If u16 addresses the second (low-surrogate) unit of * an astral char, *mid is set and the returned offset is the START of that * 4-byte sequence. u16 at or past the end returns s->len with *mid false. - * Same contract as a from-scratch scan. */ -static size_t scr_u16_to_byte_c(const ScrStr *s, ScrSidx *e, size_t u16, - bool *mid) { + * Same contract as a from-scratch scan. The identity and ASCII-run cases + * inline into callers; everything else is out of line. */ +static inline size_t scr_u16_to_byte_c(const ScrStr *s, ScrSidx *e, size_t u16, + bool *mid) { if (s->ascii || e->u16len == s->len) { /* all ASCII: identity mapping */ *mid = false; return u16 < s->len ? u16 : s->len; @@ -1098,6 +1129,17 @@ static size_t scr_u16_to_byte_c(const ScrStr *s, ScrSidx *e, size_t u16, *mid = false; return e->cb; } + return scr_u16_to_byte_walk(s, e, u16, mid); +} + +static __attribute__((noinline)) size_t scr_u16_to_byte_walk(const ScrStr *s, ScrSidx *e, + size_t u16, bool *mid) { + if (u16 >= e->ascii_cu && scr_sidx_ascii_grow(s, e, e->ascii_cb + (u16 - e->ascii_cu))) { + e->cb = e->ascii_cb + (u16 - e->ascii_cu); + e->cu = u16; + *mid = false; + return e->cb; + } scr_sidx_extend_to_u16(s, e, u16); /* Extending a far-end lookup can just have proved identity. Do not * materialize an index that the identity fast path will never consult. */ @@ -1151,16 +1193,29 @@ static size_t scr_u16_to_byte_c(const ScrStr *s, ScrSidx *e, size_t u16, return cb; } +static size_t scr_byte_to_u16_walk(const ScrStr *s, ScrSidx *e, size_t byte_off); + /* Convert a byte offset (must be a char boundary) to a UTF-16 index from - * the closest sparse anchor or hot cursor. */ -static size_t scr_byte_to_u16_c(const ScrStr *s, ScrSidx *e, - size_t byte_off) { + * the closest sparse anchor or hot cursor. As above, the identity and + * ASCII-run cases inline into callers. */ +static inline size_t scr_byte_to_u16_c(const ScrStr *s, ScrSidx *e, + size_t byte_off) { if (s->ascii || e->u16len == s->len) return byte_off; /* all ASCII */ if (byte_off >= e->ascii_cb && byte_off < e->ascii_end) { e->cu = e->ascii_cu + (byte_off - e->ascii_cb); e->cb = byte_off; return e->cu; } + return scr_byte_to_u16_walk(s, e, byte_off); +} + +static __attribute__((noinline)) size_t scr_byte_to_u16_walk(const ScrStr *s, ScrSidx *e, + size_t byte_off) { + if (byte_off >= e->ascii_cb && scr_sidx_ascii_grow(s, e, byte_off)) { + e->cu = e->ascii_cu + (byte_off - e->ascii_cb); + e->cb = byte_off; + return e->cu; + } /* A byte search result may be far beyond the existing prefix. Build the * same sparse intervals first, then choose the closest boundary anchor. */ if (byte_off > e->indexed_cb) { diff --git a/tests/corpus/4731-string-mixed-ascii-slices.ts b/tests/corpus/4731-string-mixed-ascii-slices.ts new file mode 100644 index 000000000..4948de693 --- /dev/null +++ b/tests/corpus/4731-string-mixed-ascii-slices.ts @@ -0,0 +1,121 @@ +// Mostly ASCII strings with rare non-ASCII characters (2-, 3- and 4-byte +// UTF-8, the last a surrogate pair), read the way a scanner reads a source +// text: forward substrings and character codes, searches whose results are +// UTF-16 offsets, backward reads and random jumps. Short and long strings +// (the native index keeps them in different caches) of several shapes. + +const words = ["alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", "iota", "kappa"]; +const rare = ["\u00e9", "\u20ac", "\ud83d\ude00", "\u00df\u00e4", "\u4e2d"]; + +let seed = 12345; +function rand(n: number): number { + seed = (seed * 1103515245 + 12345) % 2147483648; + return (seed >>> 8) % n; +} + +function makeText(lines: number, rareEvery: number): string { + const out: string[] = []; + for (let i = 0; i < lines; i++) { + let line = ""; + const count = 3 + rand(8); + for (let j = 0; j < count; j++) { + if (j > 0) line += rand(5) === 0 ? ", " : " "; + line += words[rand(words.length)]!; + if (rareEvery > 0 && rand(rareEvery) === 0) line += rare[rand(rare.length)]!; + } + out.push(line); + } + return out.join("\n"); +} + +function fold(hash: number, s: string): number { + for (let i = 0; i < s.length; i++) hash = (Math.imul(hash, 31) + s.charCodeAt(i)) | 0; + return (Math.imul(hash, 17) + s.length) | 0; +} + +function isWordUnit(c: number): boolean { + return (c >= 97 && c <= 122) || c > 127; +} + +// Forward: extract every word with substring, as a scanner takes token values. +function forward(text: string): string { + let hash = 0; + let count = 0; + let pos = 0; + while (pos < text.length) { + const c = text.charCodeAt(pos); + if (!isWordUnit(c)) { + pos++; + continue; + } + const start = pos; + while (pos < text.length && isWordUnit(text.charCodeAt(pos))) pos++; + hash = fold(hash, text.substring(start, pos)); + count++; + } + return `words=${count} hash=${hash}`; +} + +// Searches: line breaks and a word, with offsets fed back into slices. +function searches(text: string): string { + let hash = 0; + let lines = 0; + for (let i = text.indexOf("\n"); i >= 0; i = text.indexOf("\n", i + 1)) { + lines++; + // Native strings are UTF-8: keep surrogate pairs whole (a lone surrogate would become U+FFFD). + let from = Math.max(0, i - 6); + const unit = text.charCodeAt(from); + if (unit >= 0xdc00 && unit <= 0xdfff) from--; + hash = fold(hash, text.slice(from, i)); + } + let found = 0; + for (let i = text.indexOf("theta"); i >= 0; i = text.indexOf("theta", i + 5)) { + found++; + hash = (hash + i) | 0; + } + const last = text.lastIndexOf("kappa"); + return `lines=${lines} theta=${found} lastKappa=${last} hash=${hash}`; +} + +// Backward reads and random jumps; a position inside a surrogate pair moves to its start. +function jumps(text: string): string { + let hash = 0; + for (let i = text.length - 1; i >= 0; i -= 7) hash = (Math.imul(hash, 3) + text.charCodeAt(i)) | 0; + for (let k = 0; k < 400; k++) { + let a = rand(text.length); + const lowA = text.charCodeAt(a); + if (lowA >= 0xdc00 && lowA <= 0xdfff) a--; + let b = Math.min(text.length, a + 1 + rand(40)); + const lowB = text.charCodeAt(b); + if (b < text.length && lowB >= 0xdc00 && lowB <= 0xdfff) b++; + hash = fold(hash, text.substring(a, b)); + } + return `hash=${hash}`; +} + +const shapes: [string, number, number][] = [ + ["short ascii", 20, 0], + ["short mixed", 40, 6], + ["medium mixed", 900, 40], + ["long sparse", 9000, 400], + ["long dense", 6000, 3], + ["long ascii", 7000, 0], +]; +for (const [name, lines, rareEvery] of shapes) { + const text = makeText(lines, rareEvery); + console.log(name, `length=${text.length}`, forward(text), searches(text), jumps(text)); +} + +// A long ASCII prefix before the first non-ASCII character, read from both ends. +const prefix = "x".repeat(70000) + "\u00e9" + "y".repeat(5000) + "\ud83d\ude00" + "z".repeat(100); +console.log( + prefix.length, + prefix.indexOf("\u00e9"), + prefix.indexOf("y"), + prefix.indexOf("z"), + prefix.substring(69998, 70003), + prefix.substring(75000, 75004), + prefix.charCodeAt(75001), + prefix.charCodeAt(75002), + prefix.lastIndexOf("x"), +); From 008671625076313ee869f70e9ebc346e62a35868 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 12 Oct 2026 11:17:40 -0700 Subject: [PATCH 5/6] Widen utf16le ASCII runs eight bytes at a time Buffer writes and conversions to UTF-16LE stored each ASCII byte as two byte stores; the compiler cannot vectorize the loop because source and destination may alias. On little-endian targets an ASCII run of eight bytes is now widened with two 64-bit stores. --- packages/runtime/src/scr_bytes.c | 26 ++++++++ .../corpus/4732-buffer-write-utf16le-runs.ts | 61 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 tests/corpus/4732-buffer-write-utf16le-runs.ts diff --git a/packages/runtime/src/scr_bytes.c b/packages/runtime/src/scr_bytes.c index 59a7fe152..b5d562900 100644 --- a/packages/runtime/src/scr_bytes.c +++ b/packages/runtime/src/scr_bytes.c @@ -2738,6 +2738,32 @@ double scr_bytes_write_str(ScrBytes *b, const ScrStr *s, const ScrStr *enc, size_t i = 0, written = 0, width = utf16 ? 2 : 1; const uint64_t high = UINT64_C(0x8080808080808080); while (i < s->len && budget - written >= width) { +#if defined(__BYTE_ORDER__) && __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ + /* utf16le of an ASCII run: zero-extend each byte to a little-endian + * unit, eight per step with two word stores (the generic loop below + * stores byte by byte, because the source and destination may alias + * as far as the compiler knows). */ + if (utf16) { + uint8_t *d = b->data + o + written; + size_t n = 0, room = (budget - written) / 16, left = (s->len - i) / 8; + size_t steps = room < left ? room : left; + for (; n < steps; n++) { + uint64_t w; + memcpy(&w, s->data + i + 8 * n, 8); + if (w & high) break; + uint64_t lo = w & UINT64_C(0xffffffff), hi = w >> 32; + lo = (lo | (lo << 16)) & UINT64_C(0x0000ffff0000ffff); + lo = (lo | (lo << 8)) & UINT64_C(0x00ff00ff00ff00ff); + hi = (hi | (hi << 16)) & UINT64_C(0x0000ffff0000ffff); + hi = (hi | (hi << 8)) & UINT64_C(0x00ff00ff00ff00ff); + memcpy(d + 16 * n, &lo, 8); + memcpy(d + 16 * n + 8, &hi, 8); + } + i += 8 * n; + written += 16 * n; + if (n != 0) continue; + } +#endif /* ASCII runs encode byte-for-unit: widen (or copy) eight bytes per * step while the budget holds them all, else one code point below. */ if (s->len - i >= 8 && budget - written >= 8 * width) { diff --git a/tests/corpus/4732-buffer-write-utf16le-runs.ts b/tests/corpus/4732-buffer-write-utf16le-runs.ts new file mode 100644 index 000000000..6757e1eae --- /dev/null +++ b/tests/corpus/4732-buffer-write-utf16le-runs.ts @@ -0,0 +1,61 @@ +// Buffer#write with 'utf16le' (and latin1/ascii) of strings whose ASCII runs +// have every length around the eight-byte steps, mixed with non-ASCII +// characters, at offsets and byte budgets that cut runs, units and surrogate +// pairs; plus the scanner's use: a Uint16Array filled through its buffer. + +function hex(bytes: Uint8Array, count: number): string { + let out = ""; + for (let i = 0; i < count; i++) out += bytes[i]!.toString(16).padStart(2, "0"); + return out; +} + +const pieces = ["", "a", "\u00e9", "\u20ac", "\ud83d\ude00", "\u007f", "\u0080"]; +const strings: string[] = []; +for (let run = 0; run <= 34; run++) { + const ascii = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGH".slice(0, run); + for (const piece of pieces) strings.push(ascii + piece + ascii.slice(0, run % 9)); +} + +let lines = 0; +let checksum = 0; +for (const s of strings) { + const size = 2 * s.length + 6; + const out: string[] = []; + for (const [offset, budget] of [ + [0, -1], + [1, -1], + [3, 7], + [0, 16], + [2, 17], + [0, 2 * s.length - 1], + ]) { + const buf = Buffer.alloc(size, 0xee); + const written = + budget < 0 ? buf.write(s, offset, "utf16le") : buf.write(s, offset, Math.min(budget, size), "utf16le"); + out.push(`${written}:${hex(buf, size)}`); + } + const latin = Buffer.alloc(s.length + 2, 0xee); + const writtenLatin = latin.write(s, 1, "latin1"); + const ascii = Buffer.alloc(s.length + 2, 0xee); + const writtenAscii = ascii.write(s, 0, "ascii"); + const line = `${out.join(" ")} ${writtenLatin} ${hex(latin, latin.length)} ${writtenAscii} ${hex(ascii, ascii.length)}`; + for (let i = 0; i < line.length; i++) checksum = (Math.imul(checksum, 31) + line.charCodeAt(i)) | 0; + if (lines++ % 16 === 0) console.log(JSON.stringify(s), line); +} +console.log("strings", strings.length, "checksum", checksum); + +// The scanner's conversion: the code units of a text, through the bytes of a Uint16Array. +function toCodeUnits(text: string): Uint16Array { + const chars = new Uint16Array(text.length + 4); + Buffer.from(chars.buffer).write(text, "utf16le"); + return chars; +} +let text = ""; +for (let i = 0; i < 3000; i++) text += i % 97 === 0 ? "\u00e9\u20ac" : i % 211 === 0 ? "\ud83d\ude00" : "abcdefgh"[i % 8]; +const units = toCodeUnits(text); +let same = units.length === text.length + 4; +for (let i = 0; i < text.length; i++) if (units[i] !== text.charCodeAt(i)) same = false; +for (let i = text.length; i < units.length; i++) if (units[i] !== 0) same = false; +let sum = 0; +for (let i = 0; i < units.length; i++) sum = (sum * 33 + units[i]!) % 1000000007; +console.log("units", text.length, units.length, same, sum); From a724387e037fb3120cafecdd98bead5fc6720dd5 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 12 Oct 2026 11:28:21 -0700 Subject: [PATCH 6/6] Format the integer read emission test and record order baselines Records the TypeScript preflight/order baselines of corpus programs 4730-4733 (no diagnostics, one module each); existing baselines are unchanged. --- packages/compiler/test/integer-read-emission.test.ts | 12 +++++++++--- .../corpus/4730-touint32-large-magnitudes.ts.json | 6 ++++++ .../corpus/4731-string-mixed-ascii-slices.ts.json | 6 ++++++ .../corpus/4732-buffer-write-utf16le-runs.ts.json | 6 ++++++ .../corpus/4733-typed-array-read-compares.ts.json | 6 ++++++ 5 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4730-touint32-large-magnitudes.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4731-string-mixed-ascii-slices.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4732-buffer-write-utf16le-runs.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4733-typed-array-read-compares.ts.json diff --git a/packages/compiler/test/integer-read-emission.test.ts b/packages/compiler/test/integer-read-emission.test.ts index 9850c6fa1..2abac004e 100644 --- a/packages/compiler/test/integer-read-emission.test.ts +++ b/packages/compiler/test/integer-read-emission.test.ts @@ -50,15 +50,21 @@ console.log(text.skip(), text.sum(3), text.sum(9)); const mod = deserializeModule(await readFile(outPath, "utf8")); const ll = emitLlvmModule(mod); const body = (name: string): string => - ll.match(new RegExp(`define internal [^\\n]+ @sc_bf_${name}\\([^\\n]*\\) #0 \\{([\\s\\S]*?)\\n\\}`))![1]!; + ll.match( + new RegExp(`define internal [^\\n]+ @sc_bf_${name}\\([^\\n]*\\) #0 \\{([\\s\\S]*?)\\n\\}`), + )![1]!; const skip = body("_x25_Text_skip"); // The counter's increment is 32-bit arithmetic that cannot overflow and // converts from i32, so LLVM folds the double round trip of the local. expect(skip).toMatch(/ = add nsw i32 /); expect(skip).not.toMatch(/ = sitofp i64 /); // The read joins its integer and a validity bit; the number converts after the join. - expect(skip).toMatch(/ = phi i32 \[ %\w+, %bytes\.read\.ok\d+ \], \[ 0, %bytes\.read\.invalid\d+ \]/); - expect(skip).toMatch(/ = phi i1 \[ true, %bytes\.read\.ok\d+ \], \[ false, %bytes\.read\.invalid\d+ \]/); + expect(skip).toMatch( + / = phi i32 \[ %\w+, %bytes\.read\.ok\d+ \], \[ 0, %bytes\.read\.invalid\d+ \]/, + ); + expect(skip).toMatch( + / = phi i1 \[ true, %bytes\.read\.ok\d+ \], \[ false, %bytes\.read\.invalid\d+ \]/, + ); expect(skip).not.toMatch(/ = phi double \[ %\w+, %bytes\.read\.ok/); // A floating element has no integer view and keeps the joined number. const sum = body("_x25_Text_sum"); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4730-touint32-large-magnitudes.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4730-touint32-large-magnitudes.ts.json new file mode 100644 index 000000000..4dae64ef6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4730-touint32-large-magnitudes.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4730-touint32-large-magnitudes.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4731-string-mixed-ascii-slices.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4731-string-mixed-ascii-slices.ts.json new file mode 100644 index 000000000..7469b82cf --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4731-string-mixed-ascii-slices.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4731-string-mixed-ascii-slices.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4732-buffer-write-utf16le-runs.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4732-buffer-write-utf16le-runs.ts.json new file mode 100644 index 000000000..3f81e2a71 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4732-buffer-write-utf16le-runs.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4732-buffer-write-utf16le-runs.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4733-typed-array-read-compares.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4733-typed-array-read-compares.ts.json new file mode 100644 index 000000000..090a348ea --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4733-typed-array-read-compares.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4733-typed-array-read-compares.ts" + ], + "diags": [] +}