diff --git a/packages/compiler/src/backend/llvm/expr-bytes.ts b/packages/compiler/src/backend/llvm/expr-bytes.ts index 0c4cfbc8d..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 }; } @@ -361,47 +377,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/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/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-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-read-emission.test.ts b/packages/compiler/test/integer-read-emission.test.ts new file mode 100644 index 000000000..2abac004e --- /dev/null +++ b/packages/compiler/test/integer-read-emission.test.ts @@ -0,0 +1,76 @@ +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 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"); + 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 /); + // 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/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/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": [] +} 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/packages/runtime/src/scr_string.c b/packages/runtime/src/scr_string.c index a9b8fe03b..99d56d8a7 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/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(",")); 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"), +); 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); 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])));