Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions packages/compiler/src/backend/c/exprs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { OVERFLOW_MEMBER } from "./shapes.js";
import { dynDestrCheckHelper, dynIterNHelper, dynKeyGetHelper } from "./walkers.js";
import { collectFfiRetainedOps, parseFfiCallbackKey } from "../ffi-callbacks.js";
import { genResultThunkFor } from "./async.js";
import { isStableBytesOperand, matchStringSelfConcat, newValueMayThrow, streamTypedRefEligible, undefinedArmTag } from "../../ir/analysis.js";
import { isStableReceiverOperand, matchStringSelfConcat, newValueMayThrow, streamTypedRefEligible, undefinedArmTag } from "../../ir/analysis.js";

function streamTypedRefCommitAdapter(
emitter: CEmitter,
Expand Down Expand Up @@ -611,14 +611,14 @@ function dynPromiseAdapter(



/** Evaluate a bytes receiver as a borrow when it is a direct, unboxed
/** Evaluate a receiver as a borrow when it is a direct, unboxed
* binding and all later operands are stable. The binding's scope/global
* owner then keeps the value alive, avoiding retain/release traffic around
* every indexed access. Any uncertain shape falls back to an owned temp. */
export function emitBytesReceiver(emitter: CEmitter, receiver: IrExpr, following: IrExpr[]): Temp {
export function emitStableReceiver(emitter: CEmitter, receiver: IrExpr, following: IrExpr[]): Temp {
if (
receiver.kind === "varRef" &&
following.every((operand) => isStableBytesOperand(operand, receiver.localId))
following.every((operand) => isStableReceiverOperand(operand, receiver.localId))
) {
const local = emitter.currentLocals.get(receiver.localId);
if (local && !local.boxed) {
Expand Down Expand Up @@ -1699,7 +1699,11 @@ function emitContainerExpr(
return emitter.newTemp(e.type, `scr_arr_state(${arr.name}, ${idx.name})`);
}
case "arrIntrinsic": {
const r = emitter.emitExpr(e.receiver);
// getNumber copies out a scalar without invoking user code. A
// stable binding can own its receiver until that lookup finishes.
const r = e.method === "getNumber"
? emitStableReceiver(emitter, e.receiver, e.args)
: emitter.emitExpr(e.receiver);
if (e.receiver.type.kind !== "array") throw new InternalCompilerError("emitter bug: arrIntrinsic on non-array");
const acc = elemAccess(e.receiver.type.elem);
const method = e.method;
Expand Down Expand Up @@ -1932,7 +1936,7 @@ function emitContainerExpr(
const method = e.method;
const directElementAccess = method === "length" || method === "byteLength" || method === "get";
const r = directElementAccess
? emitBytesReceiver(emitter, e.receiver, e.args)
? emitStableReceiver(emitter, e.receiver, e.args)
: emitter.emitExpr(e.receiver);
const integerIndex = method === "get" ? emitter.integerLoopIndex(e.args[0]!) : null;
const args = integerIndex === null ? e.args.map((a) => emitter.emitExpr(a)) : [];
Expand Down
6 changes: 3 additions & 3 deletions packages/compiler/src/backend/c/stmts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { mangleField, mangleGlobal, mangleLocal, mangleRawParam } from "../mangl
import { BOOL, CAUGHT, IrExpr, IrStmt, RUNTIME_ERROR_CLASSES, isRefCounted } from "../../ir/ir.js";
import { boxAccess, cDecl, cStringLiteral, elemAccess, vAdapters } from "./types.js";
import { OVERFLOW_MEMBER } from "./shapes.js";
import { emitBytesReceiver } from "./exprs.js";
import { emitStableReceiver } from "./exprs.js";
import { matchIntegerBytesForLoop } from "../../ir/integer-loops.js";
import { endsWithJump, matchStringSelfConcat } from "../../ir/analysis.js";

Expand Down Expand Up @@ -357,7 +357,7 @@ export function emitStmt(emitter: CEmitter, s: IrStmt): void {
emitter.line(`for (;;) {`);
emitter.indent++;
if (integerLoop && integerShadow) {
const receiver = emitBytesReceiver(emitter, integerLoop.limitReceiver, []);
const receiver = emitStableReceiver(emitter, integerLoop.limitReceiver, []);
emitter.line(`if (!(${integerShadow} < ${receiver.name}->len)) break;`);
} else if (s.cond) {
const cond = emitter.emitCondition(s.cond);
Expand Down Expand Up @@ -436,7 +436,7 @@ export function emitStmt(emitter: CEmitter, s: IrStmt): void {
// JS-exactly), so no ownership moves. Any invalid index traps — no
// append. The IR carries the element kind; do not rediscover it in
// the generic runtime accessor on every loop iteration.
const arr = emitBytesReceiver(emitter, s.arr, [s.index, s.value]);
const arr = emitStableReceiver(emitter, s.arr, [s.index, s.value]);
const integerIndex = emitter.integerLoopIndex(s.index);
const idx = integerIndex === null ? emitter.emitExpr(s.index).name : integerIndex;
const v = emitter.emitExpr(s.value);
Expand Down
12 changes: 6 additions & 6 deletions packages/compiler/src/backend/llvm/emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ import { emitJsInteropExpr, emitExpr } from "./expr-dispatch.js";
import { emitJsMarshal, emitJsOp, emitJsExit, islandAdapter, islandTypedAdapter } from "./expr-island.js";
import { dynKind, raceAdapterFor, genResultThunkFor, childExitThunkFor, childExitSignalThunkFor, childDataThunkFor, execFileThunkFor, ipcMessageThunkFor, ipcSendThunkFor, 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, emitToUint32, emitBytesSet, emitBytesIntrinsic } from "./expr-bytes.js";
import { emitStableReceiver, resolveThunkFor, tagInSet, arrPush, emitArrayCopyLoop, emitStrIntrinsic, emitArrIntrinsic, wrapNullable, emitMapNew, mapSet, emitMapLikeIntrinsic, emitSetNew } from "./expr-containers.js";
import { 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";
Expand Down Expand Up @@ -3238,7 +3238,7 @@ class LlEmitter {
// JS-exactly), so no ownership moves. Any invalid index traps — no
// append. IrBytesElem is static, so never rediscover it through the
// generic runtime switch in a hot loop.
const arr = this.emitBytesReceiver(s.arr, [s.index, s.value]);
const arr = this.emitStableReceiver(s.arr, [s.index, s.value]);
const integerIndex = this.emitIntegerLoopIndex(s.index);
const idx = integerIndex ?? this.emitExpr(s.index).name;
const v = this.emitExpr(s.value);
Expand Down Expand Up @@ -3522,7 +3522,7 @@ class LlEmitter {
B.br(lc);
B.startBlock(lc);
if (integerLoop && integerSlot) {
const receiver = this.emitBytesReceiver(integerLoop.limitReceiver, []);
const receiver = this.emitStableReceiver(integerLoop.limitReceiver, []);
const lenPtr = B.tmp();
const len = B.tmp();
const index = B.tmp();
Expand Down Expand Up @@ -4298,8 +4298,8 @@ class LlEmitter {
return emitSetNew(this.expressionContext(), e);
}

private emitBytesReceiver(receiver: IrExpr, following: IrExpr[]): LlValue {
return emitBytesReceiver(this.expressionContext(), receiver, following);
private emitStableReceiver(receiver: IrExpr, following: IrExpr[]): LlValue {
return emitStableReceiver(this.expressionContext(), receiver, following);
}

private emitIntegerLoopIndex(expr: IrExpr): string | null {
Expand Down
18 changes: 1 addition & 17 deletions packages/compiler/src/backend/llvm/expr-bytes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
/* Focused LLVM expression emission extracted from emitter.ts. */
import { InternalCompilerError } from "../../errors.js";
import { isStableBytesOperand } from "../../ir/analysis.js";
import { F64, IrBytesElem, IrExpr } from "../../ir/ir.js";
import type { LlvmEmitterContext, LlValue } from "./expr-context.js";
import { F64_INF, f64Lit } from "./common.js";
Expand Down Expand Up @@ -53,21 +52,6 @@ const DV_SET_KIND: Record<string, number> = {
dvSetFloat64: 7,
};

export function emitBytesReceiver(host: LlvmEmitterContext, receiver: IrExpr, following: IrExpr[]): LlValue {
if (
receiver.kind === "varRef" &&
following.every((operand) => isStableBytesOperand(operand, receiver.localId))
) {
const b = host.binding(receiver.localId);
if (b.kind !== "boxed") {
const value = host.B.tmp();
host.B.line(`${value} = load ptr, ptr ${b.slot}`);
return { name: value, type: receiver.type };
}
}
return host.emitExpr(receiver);
}

export function emitIntegerLoopIndex(host: LlvmEmitterContext, expr: IrExpr): string | null {
if (expr.kind !== "varRef") return null;
const slot = host.integerLoopBindings.get(expr.localId);
Expand Down Expand Up @@ -327,7 +311,7 @@ export function emitBytesIntrinsic(host: LlvmEmitterContext, e: IrExpr & { kind:
const method = e.method;
const directElementAccess = method === "length" || method === "byteLength" || method === "get";
const r = directElementAccess
? host.emitBytesReceiver(e.receiver, e.args)
? host.emitStableReceiver(e.receiver, e.args)
: host.emitExpr(e.receiver);
const integerIndex = method === "get" ? host.emitIntegerLoopIndex(e.args[0]!) : null;
const args = integerIndex === null ? e.args.map((a) => host.emitExpr(a)) : [];
Expand Down
23 changes: 21 additions & 2 deletions packages/compiler/src/backend/llvm/expr-containers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* Focused LLVM expression emission extracted from emitter.ts. */
import { InternalCompilerError } from "../../errors.js";
import { undefinedArmTag } from "../../ir/analysis.js";
import { isStableReceiverOperand, undefinedArmTag } from "../../ir/analysis.js";
import { IrExpr, IrType, isRefCounted, typeEquals, typeKey } from "../../ir/ir.js";
import { mangleResolveThunk } from "../mangle.js";
import { elemAccess, FN_ATTRS, mapKeyAccess, mapKeyKindNum, mapValKindNum, traceArg, vAdapters } from "./shapes.js";
Expand Down Expand Up @@ -182,9 +182,28 @@ export function emitStrIntrinsic(host: LlvmEmitterContext, e: IrExpr & { kind: "
}
}

export function emitStableReceiver(host: LlvmEmitterContext, receiver: IrExpr, following: IrExpr[]): LlValue {
if (
receiver.kind === "varRef" &&
following.every((operand) => isStableReceiverOperand(operand, receiver.localId))
) {
const b = host.binding(receiver.localId);
if (b.kind !== "boxed") {
const value = host.B.tmp();
host.B.line(`${value} = load ptr, ptr ${b.slot}`);
return { name: value, type: receiver.type };
}
}
return host.emitExpr(receiver);
}

export function emitArrIntrinsic(host: LlvmEmitterContext, e: IrExpr & { kind: "arrIntrinsic" }): LlValue {
const B = host.B;
const r = host.emitExpr(e.receiver);
// getNumber copies out a scalar without invoking user code. A stable
// binding can own its receiver until that lookup finishes.
const r = e.method === "getNumber"
? host.emitStableReceiver(e.receiver, e.args)
: host.emitExpr(e.receiver);
if (e.receiver.type.kind !== "array") throw new InternalCompilerError("llvm emitter bug: arrIntrinsic on non-array");
const elem = e.receiver.type.elem;
const acc = elemAccess(elem);
Expand Down
2 changes: 1 addition & 1 deletion packages/compiler/src/backend/llvm/expr-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export interface LlvmEmitterContext extends ShapeHost {
emitBytesIndex(receiver: string, index: string, integerIndex?: boolean): string;
emitBytesIntrinsic(e: IrExpr & { kind: "bytesIntrinsic" }): LlValue;
emitBytesLength(elem: IrBytesElem, receiver: string, bytes: boolean): LlValue;
emitBytesReceiver(receiver: IrExpr, following: IrExpr[]): LlValue;
emitStableReceiver(receiver: IrExpr, following: IrExpr[]): LlValue;
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;
Expand Down
29 changes: 27 additions & 2 deletions packages/compiler/src/ir/analysis.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, test } from "vitest";
import { matchStringSelfConcat } from "./analysis.js";
import { F64, STRING, type IrExpr } from "./ir.js";
import { isStableReceiverOperand, matchStringSelfConcat } from "./analysis.js";
import { BOOL, F64, STRING, arrayOf, bytesOf, type IrExpr } from "./ir.js";

const loc = { file: "analysis.ts", start: 0, end: 0 };
const str = (value: string): IrExpr => ({ kind: "strLit", value, type: STRING, loc });
Expand All @@ -21,3 +21,28 @@ test("matchStringSelfConcat rejects non-canonical and non-string shapes", () =>
expect(matchStringSelfConcat("acc", concat(ref("acc", F64), str("x")))).toBeNull();
expect(matchStringSelfConcat("acc", concat(ref("acc"), str("x"), F64))).toBeNull();
});

test("receiver borrowing accepts scalar index updates and stable byte reads", () => {
const index: IrExpr = { kind: "assignExpr", localId: "index", value: ref("next", F64), type: F64, loc };
const read: IrExpr = { kind: "bytesIntrinsic", method: "get", receiver: ref("bytes", bytesOf("u8")), args: [index], type: F64, loc };
expect(isStableReceiverOperand(index, "values")).toBe(true);
expect(isStableReceiverOperand(read, "values")).toBe(true);
});

test("receiver borrowing rejects replacement hidden inside numeric or byte indices", () => {
const type = arrayOf(F64);
const replace: IrExpr = { kind: "assignExpr", localId: "values", value: ref("replacement", type), type, loc };
const index: IrExpr = {
kind: "ternary", cond: { kind: "toBool", operand: replace, type: BOOL, loc },
then: { kind: "numLit", value: 0, type: F64, loc }, else_: ref("index", F64), type: F64, loc,
};
const read: IrExpr = { kind: "bytesIntrinsic", method: "get", receiver: ref("bytes", bytesOf("u8")), args: [index], type: F64, loc };
expect(isStableReceiverOperand(index, "values")).toBe(false);
expect(isStableReceiverOperand(read, "values")).toBe(false);
});

test("receiver borrowing rejects calls even when nested in arithmetic", () => {
const call: IrExpr = { kind: "call", callee: "index", args: [], type: F64, loc };
const index: IrExpr = { kind: "bin", op: "+", left: ref("offset", F64), right: call, type: F64, loc };
expect(isStableReceiverOperand(index, "values")).toBe(false);
});
24 changes: 13 additions & 11 deletions packages/compiler/src/ir/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,32 +75,34 @@ export function dynDesc(
}
}

/** Whether evaluating an index/value expression can overwrite a bytes
* receiver binding. Deliberately conservative: uncertain shapes are false. */
export function isStableBytesOperand(e: IrExpr, receiverLocalId: string): boolean {
/** Whether an operand preserves a direct receiver binding until its last
* borrowed use. No user calls or suspension may intervene. This does not
* prove the operation itself safe to borrow; callers must establish that
* separately. Deliberately conservative: uncertain shapes are false. */
export function isStableReceiverOperand(e: IrExpr, receiverLocalId: string): boolean {
switch (e.kind) {
case "numLit":
case "boolLit":
case "varRef":
case "incDec":
return true;
case "assignExpr":
return e.localId !== receiverLocalId && isStableBytesOperand(e.value, receiverLocalId);
return e.localId !== receiverLocalId && isStableReceiverOperand(e.value, receiverLocalId);
case "bin":
case "logical":
return isStableBytesOperand(e.left, receiverLocalId) &&
isStableBytesOperand(e.right, receiverLocalId);
return isStableReceiverOperand(e.left, receiverLocalId) &&
isStableReceiverOperand(e.right, receiverLocalId);
case "unary":
case "toBool":
return isStableBytesOperand(e.operand, receiverLocalId);
return isStableReceiverOperand(e.operand, receiverLocalId);
case "ternary":
return isStableBytesOperand(e.cond, receiverLocalId) &&
isStableBytesOperand(e.then, receiverLocalId) &&
isStableBytesOperand(e.else_, receiverLocalId);
return isStableReceiverOperand(e.cond, receiverLocalId) &&
isStableReceiverOperand(e.then, receiverLocalId) &&
isStableReceiverOperand(e.else_, receiverLocalId);
case "bytesIntrinsic":
return (e.method === "get" || e.method === "length" || e.method === "byteLength") &&
e.receiver.kind === "varRef" &&
e.args.every((arg) => isStableBytesOperand(arg, receiverLocalId));
e.args.every((arg) => isStableReceiverOperand(arg, receiverLocalId));
default:
return false;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/compiler/test/numeric-array-reads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ test("ordinary numeric-array arithmetic uses one lookup per read without boxing"
expect(llvm.match(/call double @scr_arr_get_number\(/g)).toHaveLength(3);
expect(c).not.toContain("scr_arr_state(");
expect(llvm).not.toContain("@scr_arr_state(");
expect(c).not.toContain("scr_arr_retain(");
expect(llvm).not.toContain("@scr_arr_retain_v");
} finally {
await rm(dir, { recursive: true, force: true });
}
Expand Down
53 changes: 53 additions & 0 deletions tests/corpus/2968-numeric-array-borrow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Borrow direct bindings only while index evaluation preserves their owner.
const globalValues = [3, 5, -0];
let globalIndex = 0;
console.log("global", globalValues[globalIndex++] + globalValues[globalIndex], globalIndex);
console.log("signed-zero", 1 / (globalValues[2] * 1));

function localReads(values: number[]): void {
let index = 0;
const alias = values;
const indices = new Uint8Array([1, 0]);
console.log("local", values[index++] + values[index], index);
console.log("assign-index", values[index = 0] * 2, index);
console.log("bytes-index", values[indices[0]] + 1);
console.log("nested", values[values[0] - 3] + 1);
values = [90];
console.log("alias", alias[0] + values[0]);
}
localReads([3, 5]);

let current = [11];
const replacement = [70];
console.log("replace-global", current[(current = replacement) ? 0 : 0] + 1, current[0]);

function replaceLocal(): void {
let values = [13];
const replacement = [80];
console.log("replace-local", values[(values = replacement) ? 0 : 0] * 2, values[0]);
values = [17];
const indices = new Uint8Array([0]);
console.log("replace-nested", values[indices[(values = replacement) ? 0 : 0]] + 1, values[0]);
}
replaceLocal();

function captured(): void {
let values = [19];
function read(): number { return values[0] + 1; }
function replace(): number { values = [100]; return 0; }
console.log("captured", read(), values[replace()] + 1, read());
}
captured();

function indexTdz(): void {
function read(values: number[]): number { return values[index] + 1; }
try { console.log(read([23])); } catch (e) { console.log("index-tdz", (e as Error).name); }
const index = 0;
console.log("after-tdz", read([23]));
}
indexTdz();

function consume(values: number[]): number { return values[0] + 1; }
console.log("owned-argument", consume([29]));
console.log("temporary", [31][0] + 1);
console.log("conditional", (globalIndex > 0 ? [37] : [41])[0] + 1);
Loading