diff --git a/packages/compiler/src/backend/llvm/string-reads.ts b/packages/compiler/src/backend/llvm/string-reads.ts index 4aeeecf42..f1ec32bfd 100644 --- a/packages/compiler/src/backend/llvm/string-reads.ts +++ b/packages/compiler/src/backend/llvm/string-reads.ts @@ -21,15 +21,13 @@ import { exactIndexLines } from "./common.js"; export const STRING_UTF16_LEN_DECL = "declare double @scr_str_utf16_len(ptr)"; export const STRING_CHAR_CODE_AT_DECL = "declare double @scr_str_char_code_at(ptr, double)"; -/** The runtime fallbacks are cold: both common cases read inline, so a - * loop keeps its values in registers around the calls. They read only the - * receiver and write only runtime-private caches (index entries, unit - * arrays) and the proven-ASCII bit, a hint that is safe to read stale. A - * string's content changes only through runtime calls that write memory - * (in-place append), which these attributes leave ordered. */ -const LENGTH_CALL_ATTRS = "cold nocallback nounwind willreturn memory(argmem: read)"; -const READ_CALL_ATTRS = - "cold nocallback nounwind willreturn memory(argmem: read, inaccessiblemem: readwrite)"; +/** The runtime fallbacks are cold: both common cases read inline. They + * carry no + * memory restriction: a fallback can evict an index entry, which resets + * the published scan receiver (@scr_str_scan, read inline below) and frees + * its unit array, so every scan read after the call must reload. */ +const LENGTH_CALL_ATTRS = "cold nocallback nounwind willreturn"; +const READ_CALL_ATTRS = "cold nocallback nounwind willreturn"; /** Requests the integer-index helper below; an LLVM comment line among the * declarations, so it never declares anything itself. */ export const STRING_CHAR_CODE_AT_INT_MARKER = "; uses @sc_str_char_code_at_int"; diff --git a/packages/compiler/test/string-scan-emission.test.ts b/packages/compiler/test/string-scan-emission.test.ts new file mode 100644 index 000000000..427e2d543 --- /dev/null +++ b/packages/compiler/test/string-scan-emission.test.ts @@ -0,0 +1,46 @@ +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, validateModule } from "../src/index.js"; +import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; + +// The runtime fallbacks of the inline string reads can evict an index +// entry: that resets the published scan receiver (@scr_str_scan) and frees +// its unit array. A memory restriction on those calls would let LLVM keep +// the receiver's units in a register across them and read freed memory. +test("string read fallbacks let scan receiver reads reload after every call", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-string-scan-")); + try { + const entry = join(dir, "main.ts"), + output = join(dir, "main.ir.json"); + await writeFile( + entry, + ` +function scan(s: string, a: string, b: string): number { + let sum = 0; + for (let i = 0; i < s.length; i++) sum += s.charCodeAt(i) + a.length + b.length; + return sum; +} +function at(s: string, i: number): number { + return s.charCodeAt(i); +} +console.log(scan("é-é", "ü-ü", "ö-ö"), at("é", 0.5)); +`, + ); + const result = await compile(entry, { outDir: dir, outPath: output, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const module = deserializeModule(await readFile(output, "utf8")); + expect(validateModule(module)).toEqual([]); + const llvm = emitLlvmModule(module, { pointerBits: 64 }); + expect(llvm).toContain("@scr_str_scan = external global"); + const fallbacks = llvm + .split("\n") + .filter((line) => /call double @scr_str_(utf16_len|char_code_at)\(/.test(line)); + expect(fallbacks.length).toBeGreaterThanOrEqual(3); + for (const line of fallbacks) expect(line).not.toMatch(/\bmemory\(/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5170-string-scan-eviction.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5170-string-scan-eviction.ts.json new file mode 100644 index 000000000..78ec88db2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5170-string-scan-eviction.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5170-string-scan-eviction.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5170-string-scan-eviction.ts b/tests/corpus/5170-string-scan-eviction.ts new file mode 100644 index 000000000..80cc14d63 --- /dev/null +++ b/tests/corpus/5170-string-scan-eviction.ts @@ -0,0 +1,46 @@ +// Indexed reads of a non-ASCII string stay correct while length reads of +// other non-ASCII strings evict the runtime's per-string unit caches. +function build(seed: number, n: number): string { + let s = ""; + for (let i = 0; i < n; i++) s += i % 7 === 0 ? "é" : String.fromCharCode(97 + ((seed + i) % 26)); + return s; +} + +// Reading every unit publishes `s` as the runtime's scan receiver. +function warm(s: string): number { + let sum = 0; + for (let i = 0; i < s.length; i++) sum += s.charCodeAt(i); + return sum; +} + +// Four receivers longer than 64 bytes: reading their lengths claims every +// index slot between two reads of `s`. +function scan(s: string, a: string, b: string, c: string, d: string): number { + let sum = 0; + for (let i = 0; i < s.length; i++) { + sum = (sum * 31 + s.charCodeAt(i)) % 1000003; + if (i % 37 === 36) sum = (sum + a.length + b.length + c.length + d.length) % 1000003; + } + return sum; +} + +function scanAt(s: string, a: string, b: string, c: string, d: string): string { + let out = ""; + for (let i = 0; i < s.length; i++) { + out += s.charAt(i); + if (i % 41 === 40) out += a.length + b.length + c.length + d.length; + } + return out; +} + +const others = [build(1, 70), build(2, 71), build(4, 72), build(6, 73)]; +for (const n of [600, 4000]) { + const subject = build(n, n); + console.log(warm(subject)); + console.log(scan(subject, others[0]!, others[1]!, others[2]!, others[3]!)); + console.log(warm(subject)); + console.log(scan(subject, others[0]!, others[1]!, others[2]!, others[3]!)); +} +const tail = build(5, 200); +console.log(warm(tail)); +console.log(scanAt(tail, others[0]!, others[1]!, others[2]!, others[3]!));