Let scan reads reload after string read fallbacks

This commit is contained in:
Chris Tate
2026-10-13 21:24:38 -05:00
parent 124e3204c1
commit 7166adf712
4 changed files with 105 additions and 9 deletions
@@ -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";
@@ -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 });
}
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5170-string-scan-eviction.ts"
],
"diags": []
}
+46
View File
@@ -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]!));