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 }); + } +});