perf(llvm): inline numeric bitwise operations (#366)

- Emit JavaScript bitwise operators with native i32 instructions.
- Cover native emission and hot-loop coercion semantics.

Fixes #357

Co-authored-by: Fábio Pereira <300015+redvulps@users.noreply.github.com>
This commit is contained in:
Chris Tate
2026-09-21 10:51:41 -05:00
committed by GitHub
co-authored by Fábio Pereira
parent f5ed014cc7
commit c5129449ab
7 changed files with 93 additions and 22 deletions
@@ -97,7 +97,7 @@ import { emitJsMarshal, emitJsOp, emitJsExit, islandAdapter, islandTypedAdapter
import { dynKind, raceAdapterFor, genResultThunkFor, childExitThunkFor, childExitSignalThunkFor, childDataThunkFor, execFileThunkFor, emitterFixedAdapter, wrapEmitterListener, unwrapNullableClosure, closeBindThunkFor, closeOverrideWrapFor } from "./expr-callbacks.js";
import { streamDataAdapter, streamDoneFnFor, cryptoBytesThunkFor, fsRenameThunkFor, streamCbThunkFor, zlibBytesThunkFor } from "./expr-stream-callbacks.js";
import { resolveThunkFor, tagInSet, arrPush, emitArrayCopyLoop, emitStrIntrinsic, emitArrIntrinsic, wrapNullable, emitMapNew, mapSet, emitMapLikeIntrinsic, emitSetNew } from "./expr-containers.js";
import { emitBytesReceiver, emitIntegerLoopIndex, emitBytesIndex, emitBytesData, emitBytesLength, emitBytesGet, emitBytesU32, emitBytesSet, emitBytesIntrinsic } from "./expr-bytes.js";
import { emitBytesReceiver, emitIntegerLoopIndex, emitBytesIndex, emitBytesData, emitBytesLength, emitBytesGet, emitToUint32, emitBytesSet, emitBytesIntrinsic } from "./expr-bytes.js";
import { emitRegexIntrinsic, emitRecordKeyGet, keyedRecordReadInto } from "./expr-records.js";
import { dynPromiseAdapter, streamTypedRefCommitAdapter, liveDynUnionRefAdapter, streamTypedRefBoxValue, streamTypedRefMaterializeAdapter, streamFromArrayAdapter } from "./expr-stream-bridges.js";
import { emitWebLibCall, emitDynamicLibCall, emitFilesystemLibCall, emitPathUrlLibCall, emitPrimitiveLibCall } from "./lib-filesystem.js";
@@ -4313,8 +4313,8 @@ class LlEmitter {
return emitBytesGet(this.expressionContext(), elem, receiver, index, integerIndex);
}
private emitBytesU32(value: string): string {
return emitBytesU32(this.expressionContext(), value);
private emitToUint32(value: string): string {
return emitToUint32(this.expressionContext(), value);
}
private emitBytesSet(elem: IrBytesElem, receiver: string, index: string, value: string, integerIndex = false): void {
@@ -182,7 +182,7 @@ export function emitBytesGet(host: LlvmEmitterContext, elem: IrBytesElem, receiv
return { name: out, type: F64 };
}
export function emitBytesU32(host: LlvmEmitterContext, value: string): string {
export function emitToUint32(host: LlvmEmitterContext, value: string): string {
const B = host.B;
const aboveMin = B.tmp();
const belowMax = B.tmp();
@@ -191,9 +191,9 @@ export function emitBytesU32(host: LlvmEmitterContext, value: string): string {
B.line(`${belowMax} = fcmp ole double ${value}, ${f64Lit(9007199254740992)}`);
B.line(`${fast} = and i1 ${aboveMin}, ${belowMax}`);
const fastLabel = B.newLabel("bytes.coerce.fast");
const slowLabel = B.newLabel("bytes.coerce.slow");
const done = B.newLabel("bytes.coerce.done");
const fastLabel = B.newLabel("uint32.coerce.fast");
const slowLabel = B.newLabel("uint32.coerce.slow");
const done = B.newLabel("uint32.coerce.done");
B.condBr(fast, fastLabel, slowLabel);
B.startBlock(fastLabel);
@@ -214,9 +214,9 @@ export function emitBytesU32(host: LlvmEmitterContext, value: string): string {
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("bytes.coerce.finite");
const nonfiniteLabel = B.newLabel("bytes.coerce.nonfinite");
const slowDone = B.newLabel("bytes.coerce.slow.done");
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);
@@ -252,7 +252,7 @@ export function emitBytesU32(host: LlvmEmitterContext, value: string): string {
export function emitBytesSet(host: LlvmEmitterContext, elem: IrBytesElem, receiver: string, index: string, value: string, integerIndex = false): void {
const B = host.B;
const idx = host.emitBytesIndex(receiver, index, integerIndex);
const stored = elem === "f32" ? null : host.emitBytesU32(value);
const stored = elem === "f32" ? null : host.emitToUint32(value);
const data = host.emitBytesData(receiver);
const p = B.tmp();
if (elem === "u8") {
@@ -70,7 +70,7 @@ export interface LlvmEmitterContext extends ShapeHost {
emitBytesIntrinsic(e: IrExpr & { kind: "bytesIntrinsic" }): LlValue;
emitBytesLength(elem: IrBytesElem, receiver: string, bytes: boolean): LlValue;
emitBytesReceiver(receiver: IrExpr, following: IrExpr[]): LlValue;
emitBytesU32(value: string): string;
emitToUint32(value: string): string;
emitCallExpr(e: ExprOf<"call" | "ffiCall" | "closure" | "callValue" | "selfRef" | "new" | "classRef" | "newValue" | "instanceOfValue" | "promiseVoidWiden" | "upcast" | "downcast" | "instanceOf" | "virtualCall">): LlValue;
emitChildProcessLibCall(e: LibCallExpr): LlValue;
emitContainerExpr(e: ExprOf<"arrayLit" | "arrayNewLen" | "arrayGet" | "arrayHas" | "arrayState" | "arrIntrinsic" | "bytesNew" | "bytesIntrinsic" | "mapNew" | "mapIntrinsic" | "setIntrinsic" | "setNew">): LlValue;
@@ -0,0 +1,40 @@
import { expect, test } from "vitest";
import { F64, VOID, type IrExpr, type IrModule, type IrStmt } from "../../ir/ir.js";
import { emitLlvmModule } from "./emitter.js";
const loc = { file: "bitwise-emission.ts", start: 0, end: 0 };
function fixture(): IrModule {
const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc });
const body: IrStmt[] = (["&", "|", "^", "<<", ">>", ">>>"] as const).map((op) => ({
kind: "exprStmt",
expr: { kind: "bin", op, left: num(5), right: num(3), type: F64, loc },
loc,
}));
body.push({
kind: "exprStmt",
expr: { kind: "unary", op: "~", operand: num(5), type: F64, loc },
loc,
});
return {
irVersion: 11,
sourceFile: loc.file,
entry: "__main",
functions: [{ name: "__main", params: [], returnType: VOID, locals: [], body, loc }],
};
}
test("LLVM emits bitwise number operators as native i32 instructions", () => {
const llvm = emitLlvmModule(fixture());
expect(llvm).not.toContain(["@", "scr", "_bit_"].join(""));
expect(llvm).toMatch(/ = and i32 .*?, .*?$/m);
expect(llvm).toMatch(/ = or i32 .*?, .*?$/m);
expect(llvm).toMatch(/ = xor i32 .*?, .*?$/m);
expect(llvm).toMatch(/ = shl i32 .*?, .*?$/m);
expect(llvm).toMatch(/ = ashr i32 .*?, .*?$/m);
expect(llvm).toMatch(/ = lshr i32 .*?, .*?$/m);
expect(llvm).toMatch(/ = and i32 .*?, 31$/m);
expect(llvm).toMatch(/ = xor i32 .*?, -1$/m);
expect(llvm).toMatch(/ = uitofp i32 .*? to double$/m);
expect(llvm).toMatch(/ = sitofp i32 .*? to double$/m);
});
@@ -81,13 +81,13 @@ export function emitOperatorExpr(host: LlvmEmitterContext, e: ExprOf<"bin" | "un
const arith: Record<string, string> = { "+": "fadd", "-": "fsub", "*": "fmul", "/": "fdiv" };
const cmp: Record<string, string> = { "<": "olt", "<=": "ole", ">": "ogt", ">=": "oge", "===": "oeq", "!==": "une" };
const libm: Record<string, string> = { "%": "fmod", "**": "pow" };
const bit: Record<string, string> = {
"&": "scr_bit_and",
"|": "scr_bit_or",
"^": "scr_bit_xor",
"<<": "scr_bit_shl",
">>": "scr_bit_shr",
">>>": "scr_bit_ushr",
const bit: Record<string, "and" | "or" | "xor" | "shl" | "ashr" | "lshr"> = {
"&": "and",
"|": "or",
"^": "xor",
"<<": "shl",
">>": "ashr",
">>>": "lshr",
};
if ((e.op === "===" || e.op === "!==") && e.left.type.kind === "bool") {
B.line(`${t} = icmp ${e.op === "===" ? "eq" : "ne"} i1 ${l.name}, ${r.name}`);
@@ -99,8 +99,22 @@ export function emitOperatorExpr(host: LlvmEmitterContext, e: ExprOf<"bin" | "un
if (e.left.type.kind !== "f64") throw new LlvmUnsupportedError(`bin:${e.op}:${e.left.type.kind}`, e.loc);
if (arith[e.op] !== undefined) B.line(`${t} = ${arith[e.op]} double ${l.name}, ${r.name}`);
else B.line(`${t} = fcmp ${cmp[e.op]} double ${l.name}, ${r.name}`);
} else if (bit[e.op] !== undefined) {
if (e.left.type.kind !== "f64" || e.right.type.kind !== "f64") {
throw new LlvmUnsupportedError(`bin:${e.op}:${e.left.type.kind}:${e.right.type.kind}`, e.loc);
}
const left = host.emitToUint32(l.name);
let right = host.emitToUint32(r.name);
if (e.op === "<<" || e.op === ">>" || e.op === ">>>") {
const shift = B.tmp();
B.line(`${shift} = and i32 ${right}, 31`);
right = shift;
}
const result = B.tmp();
B.line(`${result} = ${bit[e.op]} i32 ${left}, ${right}`);
B.line(`${t} = ${e.op === ">>>" ? "uitofp" : "sitofp"} i32 ${result} to double`);
} else {
const fn = libm[e.op] ?? bit[e.op];
const fn = libm[e.op];
if (fn === undefined) throw new LlvmUnsupportedError(`bin:${e.op}`, e.loc);
host.declare(`declare double @${fn}(double, double)`);
B.line(`${t} = call double @${fn}(double ${l.name}, double ${r.name})`);
@@ -113,8 +127,10 @@ export function emitOperatorExpr(host: LlvmEmitterContext, e: ExprOf<"bin" | "un
if (e.op === "-") B.line(`${t} = fneg double ${v.name}`);
else if (e.op === "!") B.line(`${t} = xor i1 ${v.name}, true`);
else {
host.declare(`declare double @scr_bit_not(double)`);
B.line(`${t} = call double @scr_bit_not(double ${v.name})`);
const value = host.emitToUint32(v.name);
const result = B.tmp();
B.line(`${result} = xor i32 ${value}, -1`);
B.line(`${t} = sitofp i32 ${result} to double`);
}
return { name: t, type: e.type };
}
@@ -6432,6 +6432,12 @@
],
"diags": []
},
"<repo>/tests/corpus/2937-bitwise-hot-loop.ts": {
"order": [
"<repo>/tests/corpus/2937-bitwise-hot-loop.ts"
],
"diags": []
},
"<repo>/tests/corpus/300-if-else.ts": {
"order": [
"<repo>/tests/corpus/300-if-else.ts"
+9
View File
@@ -0,0 +1,9 @@
// Dependent bitwise chains keep JavaScript's 32-bit coercion and unsigned
// result semantics when every intermediate value feeds the next operation.
let value = 0x12345678;
for (let i = 0; i < 1_000_000; i++) {
value = (value ^ (value << 13)) >>> 0;
value = (value ^ (value >>> 17)) >>> 0;
value = (value ^ (value << 5)) >>> 0;
}
console.log(value);