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.
This commit is contained in:
Malte Ubl
2026-10-12 11:17:03 -07:00
parent 35ab79ecc3
commit 3d1aad70e4
3 changed files with 119 additions and 3 deletions
@@ -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(
@@ -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 } };
}
@@ -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 });
}
});