Store number arrays of proven int32s in 4-byte slots across direct calls (#1960)

This commit is contained in:
Chris Tate
2026-10-23 10:42:51 -05:00
committed by GitHub
7 changed files with 692 additions and 87 deletions
+17 -5
View File
@@ -68,7 +68,7 @@ import {
type StringSplit,
} from "./split-loops.js";
import { scalarizeNumericRecords } from "../../ir/scalar-records.js";
import { addRecordResultEntries } from "./record-results.js";
import { addRecordResultEntries, resultEntryName } from "./record-results.js";
import { specializeCallbackCalls } from "../../ir/callback-specialization.js";
import {
type ChunkResult,
@@ -82,6 +82,7 @@ import { versionLoopBoundParameters } from "../../ir/parameter-versions.js";
import {
allocatedLiteral,
fillAllocationLength,
int32ArrayKey,
selectInt32Arrays,
} from "../../ir/int32-arrays.js";
import { everyExpr, everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js";
@@ -510,6 +511,15 @@ function provenInt32Arrays(mod: IrModule): ReadonlySet<string> {
return new Set([...slots.int32Arrays, ...slots.checkedInt32Arrays]);
}
/** The functions whose calls the backend may emit as calls of their
* result-slot entries (record-results.ts). */
function resultEntryTargets(mod: IrModule): ReadonlySet<string> {
const names = new Set(mod.functions.map((fn) => fn.name));
const targets = new Set<string>();
for (const name of names) if (names.has(resultEntryName(name))) targets.add(name);
return targets;
}
/** The emitter for `input` after the module passes every emission runs. */
function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter {
// Facts are keyed by node identity: emit the same module whether it was
@@ -545,6 +555,7 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter {
),
),
provenInt32Arrays,
resultEntryTargets,
)
: mod,
options,
@@ -781,7 +792,7 @@ export class LlEmitter {
everyStmtList(fn.body, {
stmt: (s) => {
if (s.kind !== "varDecl" && s.kind !== "assign") return true;
const key = `${fn.name}\0${s.localId}`;
const key = int32ArrayKey(fn.name, s.localId);
const checked = checkedInt32Arrays.has(key);
if (!checked && !int32Arrays.has(key)) return true;
const value = s.kind === "varDecl" ? s.init : s.value;
@@ -799,15 +810,16 @@ export class LlEmitter {
});
}
/** Whether `arr` is one of the current function's int32 arrays
* (ir/int32-arrays.ts, kept by the int32 slot analysis). */
/** Whether `arr` reads one of the int32 arrays (ir/int32-arrays.ts, kept
* by the int32 slot analysis): a local or parameter of the current
* function, or a module global. */
int32Array(arr: IrExpr): boolean {
if (arr.kind !== "varRef") return false;
if (this.int32KindArray(arr)) return true;
if (this.int32Slots.int32Arrays.size === 0) return false;
return (
this.int32ArrayTemps.has(arr.localId) ||
this.int32Slots.int32Arrays.has(`${this.currentFunctionName}\0${arr.localId}`)
this.int32Slots.int32Arrays.has(int32ArrayKey(this.currentFunctionName, arr.localId))
);
}
+391 -52
View File
@@ -1,3 +1,4 @@
import { exprFunctionReference, moduleFunctionReferences } from "./function-references.js";
import { F64, type IrExpr, type IrFunction, type IrModule, type IrStmt } from "./ir.js";
type SwitchCase = Extract<IrStmt, { kind: "switch" }>["cases"][number];
@@ -6,22 +7,29 @@ import {
everyExprChild,
everyStmtChild,
everyStmtList,
mapExprChildren,
mapStmtChildren,
transformExpr,
transformStmt,
transformStmtList,
type IrTransform,
} from "./traverse.js";
/** Local number arrays the backend may store as int32 slots.
/** Number arrays the backend may store as int32 slots.
*
* A candidate is a local `number[]` of a synchronous function that no
* other binding can hold and only compiled element operations reach:
* every write of the local stores a fresh array (an array literal, or
* `new Array(n).fill(v)`), and every read of it is the receiver of an
* element read, write, state test, `length`, `push` or `pop` (a discarded
* `pop` included), or the array of an element-reader helper call. The
* array never reaches a runtime function that reads its slots, a library
* call, a closure, a return or untyped code.
* A candidate is a `number[]` binding that only compiled element
* operations reach: a local of a synchronous function, a module global, or
* a parameter. Every write of a local or a global stores a fresh array (an
* array literal, or `new Array(n).fill(v)`); a parameter is never written.
* Every read of a candidate is the receiver of an element read, write,
* state test, `length`, `push` or `pop` (a discarded `pop` included), the
* array of an element-reader helper call, or the argument of a direct call
* whose parameter at that position is a candidate itself. A parameter is a
* candidate only when its function is reached by direct calls alone and
* every call passes a candidate there, so every binding that can hold one
* of these arrays is a candidate: the array never reaches a runtime
* function that reads its slots, a library call, a closure, a return or
* untyped code.
*
* Candidates are only proposed here. The int32 slot analysis
* (int32-slots.ts) keeps the ones whose every stored element is a proven
@@ -33,6 +41,18 @@ import {
* calls are inlined first (rewriteCandidates), so no shared helper ever
* receives an int32 array. */
/** The candidate key of the binding `localId` as `fnName` reads it:
* `function\0local` for a local or parameter, `\0global` for a module
* global (`%g.` ids), which every function reads alike. */
export function int32ArrayKey(fnName: string, localId: string): string {
return localId.startsWith("%g.") ? `\0${localId}` : `${fnName}\0${localId}`;
}
/** The int32 element slot (int32-slots.ts) of a candidate key. */
export function int32ArraySlot(key: string): string {
return key.startsWith("\0") ? `g:${key.slice(1)}` : `l:${key}`;
}
/** Array methods a candidate may be the receiver of: the runtime paths
* behind them read and write int32 slots through the double-valued slot
* accessors. */
@@ -202,10 +222,70 @@ function writesLocal(e: IrExpr, id: string): boolean {
return !expr(e);
}
/** The candidate locals of `fn` (see the module comment). */
export function int32ArrayCandidates(fn: IrFunction, readers: ReadonlySet<string>): Set<string> {
/** Expression kinds an index can be built from without running any code. */
const PURE_INDEX_KINDS: ReadonlySet<string> = new Set([
"varRef",
"numLit",
"boolLit",
"bin",
"unary",
"ternary",
"logical",
"arrayGet",
"arrayState",
]);
/** Whether evaluating an index runs no code (no call, accessor or
* conversion): plain reads, literals and their operators only. */
function pureIndex(e: IrExpr): boolean {
return PURE_INDEX_KINDS.has(e.kind) && everyExprChild(e, pureIndex, () => false);
}
/** The parameters and globals a module's functions share as candidates,
* still assumed to qualify. */
export interface SharedCandidates {
/** Candidate parameters, as `function\0local` keys. */
params: ReadonlySet<string>;
/** Candidate globals, as `%g.` ids. */
globals: ReadonlySet<string>;
/** Whether the parameter at `index` of `callee` is a candidate. */
paramAt: (callee: string, index: number) => boolean;
}
const NO_SHARED: SharedCandidates = {
params: new Set(),
globals: new Set(),
paramAt: () => false,
};
/** The candidates of `fn` (see the module comment): its own locals, its
* parameters in `shared.params`, and the globals of `shared.globals` that
* it reads only in candidate positions. `written` receives the globals it
* assigns fresh arrays. */
export function int32ArrayCandidates(
fn: IrFunction,
readers: ReadonlySet<string>,
shared: SharedCandidates = NO_SHARED,
written: Set<string> = new Set(),
): Set<string> {
const result = new Set<string>();
if (fn.async || fn.generator) return result;
if (fn.async || fn.generator) {
// Globals read here leave the candidates.
for (const id of shared.globals) result.add(id);
everyStmtList(fn.body, {
expr: (e) => {
if ((e.kind === "varRef" || e.kind === "assignExpr") && result.has(e.localId))
result.delete(e.localId);
return true;
},
stmt: (s) => {
if ((s.kind === "varDecl" || s.kind === "assign") && result.has(s.localId))
result.delete(s.localId);
return true;
},
});
return result;
}
const captured = new Set(
[...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId),
);
@@ -217,11 +297,12 @@ export function int32ArrayCandidates(fn: IrFunction, readers: ReadonlySet<string
!local.boxed &&
!local.tdz &&
!captured.has(local.id) &&
!params.has(local.id)
(!params.has(local.id) || shared.params.has(`${fn.name}\0${local.id}`))
)
result.add(local.id);
for (const id of shared.globals) result.add(id);
if (result.size === 0) return result;
const written = new Set<string>();
const allocated = new Set<string>();
// A receiver position: the candidate is read there without escaping.
const receiver = (e: IrExpr): void => {
if (e.kind !== "varRef") expr(e);
@@ -255,13 +336,28 @@ export function int32ArrayCandidates(fn: IrFunction, readers: ReadonlySet<string
return true;
}
case "call": {
if (!readers.has(e.callee) || e.args.length !== 2) break;
const [array, index] = e.args;
// Inlining evaluates the index before the array read.
if (array!.kind === "varRef" && writesLocal(index!, array!.localId))
result.delete(array!.localId);
receiver(array!);
expr(index!);
if (readers.has(e.callee) && e.args.length === 2) {
const [array, index] = e.args;
// Inlining evaluates the index before the array read: an index
// that rebinds the array keeps it out, and so does one that can run
// code when the array is a module global (a call can reassign it).
if (
array!.kind === "varRef" &&
(writesLocal(index!, array!.localId) ||
(array!.localId.startsWith("%g.") && !pureIndex(index!)))
)
result.delete(array!.localId);
receiver(array!);
expr(index!);
return true;
}
// An argument at a candidate parameter position passes the array
// to another candidate binding (moduleInt32ArrayCandidates checks
// every such call).
e.args.forEach((arg, index) => {
if (shared.paramAt(e.callee, index)) receiver(arg);
else expr(arg);
});
return true;
}
}
@@ -272,7 +368,8 @@ export function int32ArrayCandidates(fn: IrFunction, readers: ReadonlySet<string
case "varDecl":
case "assign": {
const value = s.kind === "varDecl" ? s.init : s.value;
if (value && int32ArrayAllocation(value)) written.add(s.localId);
if (params.has(s.localId)) result.delete(s.localId);
else if (value && int32ArrayAllocation(value)) allocated.add(s.localId);
else if (value) result.delete(s.localId);
if (value) expr(value);
return true;
@@ -297,10 +394,228 @@ export function int32ArrayCandidates(fn: IrFunction, readers: ReadonlySet<string
return everyStmtChild(s, expr, stmt);
};
fn.body.every(stmt);
for (const id of result) if (!written.has(id)) result.delete(id);
for (const id of result) {
if (shared.globals.has(id)) {
if (allocated.has(id)) written.add(id);
} else if (!params.has(id) && !allocated.has(id)) result.delete(id);
}
return result;
}
/** The members of `ids` (none when absent) that are in `live`. */
function liveIn(ids: ReadonlySet<string> | undefined, live: ReadonlySet<string>): Set<string> {
const result = new Set<string>();
if (ids) for (const id of ids) if (live.has(id)) result.add(id);
return result;
}
/** The candidates of every function of `mod` that has any, only those
* whose keys are in `keep` when given. Parameters and globals are shared:
* starting from every one that may qualify, each pass drops the ones a
* function reads outside candidate positions, the parameters some call
* passes anything but a candidate, and the globals never assigned a fresh
* array, until a pass drops nothing. Functions in `redirected` keep their
* parameters: some of their calls reach another function. */
export function moduleInt32ArrayCandidates(
mod: IrModule,
readers: ReadonlySet<string>,
keep: ReadonlySet<string> | null = null,
redirected: ReadonlySet<string> = new Set(),
): Map<string, Set<string>> {
const kept = (key: string): boolean => keep === null || keep.has(key);
const arrayParams = new Map<string, number[]>();
for (const fn of mod.functions) {
// Class methods, constructors and runtime helpers (`%` names) keep
// their parameters.
if (fn.name.startsWith("%") || fn.async || fn.generator || redirected.has(fn.name)) continue;
const indexes: number[] = [];
fn.params.forEach((p, i) => {
if (p.type.kind === "array" && p.type.elem.kind === "f64") indexes.push(i);
});
if (indexes.length > 0) arrayParams.set(fn.name, indexes);
}
let globals = new Set<string>();
for (const g of mod.globals ?? [])
if (
g.type.kind === "array" &&
g.type.elem.kind === "f64" &&
g.initFlag === undefined &&
kept(`\0${g.id}`)
)
globals.add(g.id);
const result = new Map<string, Set<string>>();
if (arrayParams.size === 0 && globals.size === 0) {
for (const fn of mod.functions) {
const candidates = int32ArrayCandidates(fn, readers);
for (const id of candidates) if (!kept(`${fn.name}\0${id}`)) candidates.delete(id);
if (candidates.size > 0) result.set(fn.name, candidates);
}
return result;
}
// One walk: the functions reached other than by direct calls, the direct
// calls of functions with number[] parameters, the globals each function
// reads or writes, and whether a module namespace object exists (its
// member reads can reach any global by name).
const reached = new Set(moduleFunctionReferences(mod));
const calls: { caller: string; call: IrExpr & { kind: "call" } }[] = [];
const globalsIn = new Map<string, Set<string>>();
let namespaces = false;
for (const fn of mod.functions) {
const used = new Set<string>();
const note = (id: string): void => {
if (globals.has(id)) used.add(id);
};
everyStmtList(fn.body, {
expr: (e) => {
const name = exprFunctionReference(e);
if (name !== undefined) reached.add(name);
if (e.kind === "moduleNsRef") namespaces = true;
if (e.kind === "call" && arrayParams.has(e.callee))
calls.push({ caller: fn.name, call: e });
if (e.kind === "varRef" || e.kind === "assignExpr") note(e.localId);
return true;
},
stmt: (s) => {
if (s.kind === "varDecl" || s.kind === "assign") note(s.localId);
return true;
},
});
if (used.size > 0) globalsIn.set(fn.name, used);
}
if (namespaces) {
globals = new Set();
globalsIn.clear();
}
const paramIds = new Map<string, string[]>();
let params = new Set<string>();
for (const fn of mod.functions) {
const indexes = arrayParams.get(fn.name);
if (!indexes || reached.has(fn.name)) continue;
paramIds.set(
fn.name,
fn.params.map((p) => p.localId),
);
for (const i of indexes) {
const key = `${fn.name}\0${fn.params[i]!.localId}`;
if (kept(key)) params.add(key);
}
}
for (;;) {
const current = params;
const written = new Set<string>();
const liveGlobals = new Set(globals);
result.clear();
for (const fn of mod.functions) {
const shared: SharedCandidates = {
params: current,
globals: liveIn(globalsIn.get(fn.name), globals),
paramAt: (callee, index) => {
const id = paramIds.get(callee)?.[index];
return id !== undefined && current.has(`${callee}\0${id}`);
},
};
const candidates = int32ArrayCandidates(fn, readers, shared, written);
for (const id of shared.globals) if (!candidates.has(id)) liveGlobals.delete(id);
for (const id of candidates)
if (!shared.globals.has(id) && !kept(`${fn.name}\0${id}`)) candidates.delete(id);
if (candidates.size > 0) result.set(fn.name, candidates);
}
for (const id of liveGlobals) if (!written.has(id)) liveGlobals.delete(id);
const live = new Set(
[...current].filter((key) => {
const cut = key.indexOf("\0");
return result.get(key.slice(0, cut))?.has(key.slice(cut + 1)) === true;
}),
);
// A parameter needs a candidate at that position of every call; a call
// with fewer arguments passes undefined.
for (const { caller, call } of calls) {
const ids = paramIds.get(call.callee);
if (!ids) continue;
ids.forEach((id, i) => {
const key = `${call.callee}\0${id}`;
if (!live.has(key)) return;
const arg = call.args[i];
const ok =
arg?.kind === "varRef" &&
(arg.localId.startsWith("%g.")
? liveGlobals.has(arg.localId)
: result.get(caller)?.has(arg.localId) === true);
if (!ok) live.delete(key);
});
}
if (live.size === current.size && liveGlobals.size === globals.size) break;
params = live;
globals = liveGlobals;
}
return result;
}
/** The candidates one array can reach together: a direct call's argument
* and the parameter it binds hold the same array, so their element facts
* stand or fall together. `group` maps each of `keys` to its group's keys
* (itself alone for most), and `globalReaders` each candidate global's
* key to the functions that read or write it. */
export function int32ArrayGroups(
mod: IrModule,
keys: ReadonlySet<string>,
): { group: Map<string, string[]>; globalReaders: Map<string, Set<string>> } {
const parent = new Map<string, string>();
const root = (key: string): string => {
let r = key;
while (parent.has(r)) r = parent.get(r)!;
if (r !== key) parent.set(key, r);
return r;
};
const globalReaders = new Map<string, Set<string>>();
const params = new Map(mod.functions.map((fn) => [fn.name, fn.params]));
const paramKeys = new Set(
mod.functions.flatMap((fn) => fn.params.map((p) => `${fn.name}\0${p.localId}`)),
);
if ([...keys].some((key) => key.startsWith("\0") || paramKeys.has(key))) {
for (const fn of mod.functions) {
const touch = (id: string): void => {
const key = int32ArrayKey(fn.name, id);
if (!key.startsWith("\0") || !keys.has(key)) return;
const set = globalReaders.get(key) ?? new Set<string>();
set.add(fn.name);
globalReaders.set(key, set);
};
everyStmtList(fn.body, {
expr: (e) => {
if (e.kind === "varRef" || e.kind === "assignExpr") touch(e.localId);
if (e.kind === "call")
e.args.forEach((arg, i) => {
const param = params.get(e.callee)?.[i];
if (arg.kind !== "varRef" || !param) return;
const a = int32ArrayKey(fn.name, arg.localId),
p = `${e.callee}\0${param.localId}`;
if (!keys.has(a) || !keys.has(p)) return;
const ra = root(a),
rp = root(p);
if (ra !== rp) parent.set(ra, rp);
});
return true;
},
stmt: (s) => {
if (s.kind === "varDecl" || s.kind === "assign") touch(s.localId);
return true;
},
});
}
}
const members = new Map<string, string[]>();
for (const key of keys) {
const r = root(key);
const list = members.get(r);
if (list) list.push(key);
else members.set(r, [key]);
}
const group = new Map<string, string[]>();
for (const key of keys) group.set(key, members.get(root(key))!);
return { group, globalReaders };
}
/** The element values stored into each candidate: element writes, pushed
* values, literal elements and fill values. */
export function int32ArrayStores(
@@ -341,16 +656,34 @@ export function int32ArrayStores(
return stores;
}
const REPEATED_INDEX_OPS = new Set(["+", "-", "*", "&", "|", "^", "<<", ">>", ">>>"]);
/** An index the inlined read may evaluate at each of its uses: a plain read,
* a literal, or integer arithmetic over them. Repeating it keeps the forms
* the backend derives from its operands at every use, such as the exact i64
* of an int32 product, where a bound local holds only the double. */
function repeatableIndex(e: IrExpr): boolean {
if (e.kind === "varRef" || e.kind === "numLit") return true;
if (e.kind === "unary") return e.op === "-" && repeatableIndex(e.operand);
return (
e.kind === "bin" &&
REPEATED_INDEX_OPS.has(e.op) &&
repeatableIndex(e.left) &&
repeatableIndex(e.right)
);
}
/** Rewrite a function's candidates for the backend. A discarded pop uses
* the candidate in place of its hidden local. An element-reader helper call
* whose array is a candidate is inlined: the helper's returned ternary, with
* the candidate for its array and the index for its index, so the read means
* the same. An index that is not a plain read or literal is bound to a fresh
* local first: in its own declaration just before the statement when the
* call is the whole value of a declaration or assignment (the form lowering
* gives `a[i]!`, and the one the backend reads without a box), otherwise in a
* sequence around the ternary. Selection keeps out indexes that write the
* array, so evaluating the index before the array read is unobservable. */
* the same. An index that is not repeatable (repeatableIndex) is bound to a
* fresh local first: in its own declaration just before the statement when
* the call is the whole value of a declaration or assignment (the form
* lowering gives `a[i]!`, and the one the backend reads without a box),
* otherwise in a sequence around the ternary. Selection keeps out indexes
* that write the array, so evaluating the index before the array read is
* unobservable. */
function rewriteCandidates(
fn: IrFunction,
candidates: ReadonlySet<string>,
@@ -374,14 +707,13 @@ function rewriteCandidates(
candidates.has(e.args[0]!.localId)
? e
: null;
const simple = (e: IrExpr): boolean => e.kind === "varRef" || e.kind === "numLit";
// The statements of a list, with complex read indexes declared first.
const hoist = (list: IrStmt[]): IrStmt[] =>
list.flatMap((s): IrStmt[] => {
if (s.kind !== "varDecl" && s.kind !== "assign") return [s];
const value = s.kind === "varDecl" ? s.init : s.value;
const call = readerCall(value);
if (!call || simple(call.args[1]!)) return [s];
if (!call || repeatableIndex(call.args[1]!)) return [s];
const { decl, ref } = bind(call.args[1]!);
const read: IrExpr = { ...call, args: [call.args[0]!, ref] };
if (s.kind === "varDecl") return [decl, { ...s, init: read }];
@@ -444,21 +776,23 @@ function rewriteCandidates(
if (ret.kind !== "return" || !ret.value) return e;
let index = call.args[1]!;
const stmts: IrStmt[] = [];
if (!simple(index)) {
if (!repeatableIndex(index)) {
const { decl, ref } = bind(index);
stmts.push(decl);
index = ref;
}
const operand = index;
const body = transformExpr(reparseExpr(ret.value), {
expr: (x) => {
if (x.kind !== "varRef") return x;
if (x.localId === arrayParam!.localId) return reparseExpr(array);
if (x.localId === indexParam!.localId) return reparseExpr(operand);
return x;
},
stmt: (x) => x,
});
// The arguments' copies are not searched again: an index may read a
// local that has the id of one of the helper's parameters.
const substituteExpr = (x: IrExpr): IrExpr => {
if (x.kind !== "varRef") return mapExprChildren(x, substituteExpr, substituteStmt);
if (x.localId === arrayParam!.localId) return reparseExpr(array);
if (x.localId === indexParam!.localId) return reparseExpr(operand);
return x;
};
const substituteStmt = (x: IrStmt): IrStmt =>
mapStmtChildren(x, substituteExpr, substituteStmt);
const body = substituteExpr(reparseExpr(ret.value));
// The ternary is a fresh copy (reparseExpr): it takes the call's location.
const typed = body;
typed.loc = e.loc;
@@ -471,40 +805,45 @@ function rewriteCandidates(
return { ...fn, body, locals: locals.filter((local) => !popLocals.has(local.id)) };
}
/** Propose the module's int32 array candidates (`function\0local` keys in
/** Propose the module's int32 array candidates (int32ArrayKey keys in
* `int32ArrayCandidates`), only those in `keep` when given, and rewrite
* their functions. */
export function markInt32ArrayCandidates(
mod: IrModule,
keep: ReadonlySet<string> | null = null,
redirected: ReadonlySet<string> = new Set(),
): IrModule {
if (mod.lib !== undefined) return mod;
const readers = new Map<string, IrFunction>();
for (const fn of mod.functions) if (isElementReader(fn)) readers.set(fn.name, fn);
const names = new Set(readers.keys());
const keys: string[] = [];
const all = moduleInt32ArrayCandidates(mod, new Set(readers.keys()), keep, redirected);
if (all.size === 0) return mod;
const keys = new Set<string>();
const functions = mod.functions.map((fn) => {
const candidates = int32ArrayCandidates(fn, names);
for (const id of candidates)
if (keep === null || keep.has(`${fn.name}\0${id}`)) keys.push(`${fn.name}\0${id}`);
else candidates.delete(id);
return candidates.size === 0 ? fn : rewriteCandidates(fn, candidates, readers);
const candidates = all.get(fn.name);
if (!candidates) return fn;
for (const id of candidates) keys.add(int32ArrayKey(fn.name, id));
return rewriteCandidates(fn, candidates, readers);
});
return keys.length === 0 ? mod : { ...mod, functions, int32ArrayCandidates: keys };
return { ...mod, functions, int32ArrayCandidates: [...keys] };
}
/** The module the backend emits: rewritten for exactly the candidates the
* int32 slot analysis (`prove`, returning its int32Arrays) keeps, so a
* module without int32 arrays is `mod` itself. A dropped candidate's
* rewrite is undone by marking again without it, until every candidate
* marked is kept. */
* marked is kept. `redirected` names the functions some of whose calls the
* backend emits as calls of another function (a result-slot entry): their
* parameters keep doubles. */
export function selectInt32Arrays(
mod: IrModule,
prove: (marked: IrModule) => ReadonlySet<string>,
redirected: (mod: IrModule) => ReadonlySet<string> = () => new Set(),
): IrModule {
let keep: ReadonlySet<string> | null = null;
const others = redirected(mod);
for (let round = 0; round < 4; round++) {
const marked = markInt32ArrayCandidates(mod, keep);
const marked = markInt32ArrayCandidates(mod, keep, others);
if (marked === mod) return mod;
const kept = prove(marked);
const proposed = marked.int32ArrayCandidates ?? [];
+72 -30
View File
@@ -20,7 +20,13 @@ import {
type IrNodeKind,
} from "./node-index.js";
import { findInitializerBindings, withInitializerBindings } from "./initializer-bindings.js";
import { checkableInt32Allocations, int32ArrayStores } from "./int32-arrays.js";
import {
checkableInt32Allocations,
int32ArrayGroups,
int32ArrayKey,
int32ArraySlot,
int32ArrayStores,
} from "./int32-arrays.js";
import { everyExprChild, everyStmtChild, everyTypeChild } from "./traverse.js";
/** The storage slot an array-valued expression reads, as an
@@ -257,9 +263,10 @@ export class Int32Slots {
/** Whether the module has int32 array candidates (the narrowedReads
* range fact). */
narrowedReads = false;
/** Local number arrays (`function\0local`) stored as int32 slots: the
* module's int32 array candidates whose every stored element the
* fixpoint proved an int32. */
/** Number arrays stored as int32 slots (int32ArrayKey keys: locals and
* parameters as `function\0local`, globals as `\0global`): the module's
* int32 array candidates whose every stored element, through every
* binding that can hold the same array, the fixpoint proved an int32. */
readonly int32Arrays = new Set<string>();
/** Local number arrays that may hold int32 slots with checked stores: the
* candidates whose element stores the fixpoint proved exact integers but
@@ -847,17 +854,29 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}):
for (const key of contracts?.int32Locals ?? []) slots.addInt32Local(key);
for (const key of contracts?.int32Elements ?? []) slots.addInt32Elements(key);
// Int32 array candidates (ir/int32-arrays.ts): assumed to hold int32
// elements until a store the fixpoint cannot prove drops one.
// elements until a store the fixpoint cannot prove drops one, with every
// candidate the same array can reach (its group).
const arrayCandidates = new Map<string, Set<string>>();
slots.narrowedReads = (mod.int32ArrayCandidates?.length ?? 0) > 0;
for (const key of mod.int32ArrayCandidates ?? []) {
const globalArrays = new Set<string>();
const arrayKeys = new Set(mod.int32ArrayCandidates ?? []);
slots.narrowedReads = arrayKeys.size > 0;
for (const key of arrayKeys) {
const cut = key.indexOf("\0");
const fnName = key.slice(0, cut);
const set = arrayCandidates.get(fnName) ?? new Set<string>();
set.add(key.slice(cut + 1));
arrayCandidates.set(fnName, set);
slots.addInt32Elements(`l:${key}`);
if (cut === 0) globalArrays.add(key.slice(1));
else {
const fnName = key.slice(0, cut);
const set = arrayCandidates.get(fnName) ?? new Set<string>();
set.add(key.slice(cut + 1));
arrayCandidates.set(fnName, set);
}
slots.addInt32Elements(int32ArraySlot(key));
}
const arrayGroups = arrayKeys.size > 0 ? int32ArrayGroups(mod, arrayKeys) : null;
// Functions that read or write a candidate global.
const globalArrayFunctions = new Set<string>();
if (arrayGroups)
for (const names of arrayGroups.globalReaders.values())
for (const name of names) globalArrayFunctions.add(name);
/* ── candidate fields ── */
const children = new Map<string, string[]>();
@@ -1139,6 +1158,7 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}):
const candidateWrites = nodes?.scan(CANDIDATE_WRITE_KINDS);
const hasCandidateWrite = (fn: IrFunction): boolean => {
if (arrayCandidates.has(fn.name)) return true;
if (globalArrayFunctions.has(fn.name)) return true;
const box = (localId: string): boolean => slots.hasBox(slots.boxGroup(fn.name, localId));
const returns = slots.hasReturn(fn.name);
let found = false;
@@ -1209,25 +1229,49 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}):
// (checkableInt32Allocations), it keeps int32 slots with checked stores
// (checkedInt32Arrays), which widen it to doubles in place at the first
// store that is not an int32.
const arrays = arrayCandidates.get(fn.name);
if (arrays) {
let dropped = false;
const locals = arrayCandidates.get(fn.name);
let arrays = locals;
if (globalArrayFunctions.has(fn.name)) {
const both = new Set<string>();
if (locals) for (const id of locals) both.add(id);
for (const id of globalArrays) both.add(id);
arrays = both;
}
if (arrays && arrayGroups) {
const requeue = (name: string): void => {
if (queued.has(name)) return;
queued.add(name);
queue.push(name);
};
const params = new Set(fn.params.map((param) => param.localId));
for (const [id, values] of int32ArrayStores(fn, arrays)) {
const key = `l:${fn.name}\0${id}`;
if (!slots.hasInt32ElementSlot(key) || values.every(int32)) continue;
slots.dropInt32Elements(key);
const key = int32ArrayKey(fn.name, id);
if (!slots.hasInt32ElementSlot(int32ArraySlot(key)) || values.every(int32)) continue;
// The array's group drops together: every function reading a
// member is analyzed again without its facts.
const group = arrayGroups.group.get(key) ?? [key];
for (const member of group) {
slots.dropInt32Elements(int32ArraySlot(member));
if (member.startsWith("\0")) {
const names = arrayGroups.globalReaders.get(member);
if (names) for (const name of names) requeue(name);
} else requeue(member.slice(0, member.indexOf("\0")));
}
// Checked stores serve a lone local: its allocations are the only
// arrays it holds.
const checkable = (value: IrExpr): boolean => {
const range = ranges.get(value);
return int32(value) || range != null;
};
if (values.every(checkable) && checkableInt32Allocations(fn, id))
slots.checkedInt32Arrays.add(`${fn.name}\0${id}`);
else reason(`array:${fn.name}\0${id}`, "an element store is not int32");
dropped = true;
}
if (dropped && !queued.has(fn.name)) {
queued.add(fn.name);
queue.push(fn.name);
if (
group.length === 1 &&
!key.startsWith("\0") &&
!params.has(id) &&
values.every(checkable) &&
checkableInt32Allocations(fn, id)
)
slots.checkedInt32Arrays.add(key);
else for (const member of group) reason(`array:${member}`, "an element store is not int32");
}
}
// An int32 written into a slot still assumed non-negative: widen the
@@ -1298,10 +1342,8 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}):
}
for (const proven of provenByFunction.values())
for (const value of proven) slots.provenWrites.add(value);
for (const [fnName, ids] of arrayCandidates)
for (const id of ids)
if (slots.hasInt32ElementSlot(`l:${fnName}\0${id}`))
slots.int32Arrays.add(`${fnName}\0${id}`);
for (const key of arrayKeys)
if (slots.hasInt32ElementSlot(int32ArraySlot(key))) slots.int32Arrays.add(key);
const ranged = rangedParameters(
slots,
functions,
@@ -220,3 +220,42 @@ test("only the candidates the proof keeps are rewritten", async () => {
for (const fn of selected.functions)
if (fn.name !== "compose") expect(fn).toBe(mod.functions.find((f) => f.name === fn.name));
});
const sharedSource = `
const data: number[] = [];
for (let i = 0; i < 64; i++) data.push((i * 7) & 255);
function fnv(values: number[]): number {
let h = 2166136261;
for (let i = 0; i < values.length; i++) h = Math.imul(h ^ values[i]!, 16777619) >>> 0;
return h;
}
function mean(values: number[]): number {
let s = 0;
for (let i = 0; i < values.length; i++) s += values[i]!;
return s / values.length;
}
function first(values: number[]): number {
return values[0]!;
}
const ints = [1, 2, 3];
const halves = [0.5, 1.5];
const pick: (values: number[]) => number = first;
console.log(fnv(data), mean(ints), mean(halves), pick([4]));
`;
test("arrays passed to helpers and held in module globals get 4-byte slots", async () => {
const mod = await lowerIr(sharedSource);
const proposed = markInt32ArrayCandidates(mod).int32ArrayCandidates ?? [];
// A global and the parameter every call passes it to; a function used as
// a value keeps its parameter.
expect(proposed).toEqual(expect.arrayContaining(["\0%g.e.data", "fnv\0values.0"]));
expect(proposed.some((key) => key.startsWith("first\0"))).toBe(false);
const llvm = await emitLlvm(sharedSource);
// The helper reads int32 slots; `mean`, also called with fractions,
// reads doubles.
expect(functions(llvm, "fnv")).toMatch(
/getelementptr inbounds i32, ptr %\w+, i64 %\w+\n\s+%\w+ = load i32/,
);
expect(functions(llvm, "mean")).not.toMatch(/getelementptr inbounds i32, ptr/);
expect(llvm).toMatch(/@scr_arr_new(_small)?\(i32 7/);
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7580-int32-arrays-across-calls.ts"
],
"diags": []
}
@@ -0,0 +1,166 @@
// Number arrays of proven int32s that reach other functions: passed to
// helpers called directly, along chains of helpers and recursion, and held
// in module globals that several functions read. A helper that also
// receives an array of other numbers or one that is not a fresh array,
// stores a fraction through its parameter, or escapes as a value keeps
// every array it can reach in doubles, as does a global an async function
// reads. Each case uses its own arrays and helpers.
const data: number[] = [];
let seed = 7;
for (let i = 0; i < 2000; i++) {
seed = (Math.imul(seed, 48271) + 11) & 0x7fffffff;
data.push(seed % 1000);
}
function fnv(values: number[]): number {
let h = 2166136261;
for (let i = 0; i < values.length; i++) {
h ^= values[i]! & 0xff;
h = Math.imul(h, 16777619) >>> 0;
}
return h;
}
function histogram(values: number[], buckets: number): number[] {
const counts: number[] = new Array(buckets).fill(0);
for (let i = 0; i < values.length; i++) {
const b = values[i]! % buckets;
counts[b] = counts[b]! + 1;
}
return counts;
}
// A helper that writes through its parameter and calls another helper.
function scale(values: number[], factor: number): number {
for (let i = 0; i < values.length; i++) values[i] = (values[i]! * factor) & 0xffff;
return sum(values);
}
function sum(values: number[]): number {
let s = 0;
for (let i = 0; i < values.length; i++) s = (s + values[i]!) | 0;
return s;
}
// Recursion passes the array on, and the elements index another array.
function chase(next: number[], at: number, steps: number): number {
if (steps === 0) return at;
return chase(next, next[at]!, steps - 1);
}
function pushAll(target: number[], count: number): void {
for (let i = 0; i < count; i++) target.push((i * 3) & 0xffff);
}
// `n` is a power of two: the masked elements are indexes of `perm`.
function locals(n: number): string {
const perm: number[] = [];
for (let i = 0; i < n; i++) perm.push((i * 37 + 11) & (n - 1));
const copy: number[] = [];
pushAll(copy, n);
const s1 = scale(copy, 5);
return `locals ${chase(perm, 0, 1000)} ${s1} ${copy[n - 1]} ${copy.length} ${sum(perm)}`;
}
// Read only through the element reader (`a[i] ?? 0`) and states.
function readSparse(values: number[], at: number): string {
return `${values[at] ?? -1} ${at in values} ${values.length}`;
}
// The same helper receives an array of other numbers too: the arrays it
// can reach keep doubles.
function mean(values: number[]): number {
let s = 0;
for (let i = 0; i < values.length; i++) s += values[i]!;
return s / values.length;
}
// A store through the parameter that is not an int32.
function halve(values: number[]): void {
for (let i = 0; i < values.length; i++) values[i] = values[i]! / 2;
}
// A function used as a value keeps its parameters.
function first(values: number[]): number {
return values[0]!;
}
let reassigned: number[] = [1, 2, 3];
function regrow(n: number): number {
reassigned = [];
for (let i = 0; i < n; i++) reassigned.push(i & 15);
let s = 0;
for (let i = 0; i < reassigned.length; i++) s += reassigned[i]!;
return s;
}
// Not a fresh array: the result of a call.
function total(values: number[]): number {
let s = 0;
for (let i = 0; i < values.length; i++) s += values[i]!;
return s;
}
const awaited: number[] = [4, 5, 6];
async function later(): Promise<number> {
return awaited[0]! + awaited.length;
}
// A helper whose record result its callers only project: the backend may
// call its result-slot entry instead, so its parameter keeps doubles.
interface Span {
lo: number;
hi: number;
}
function spanAt(values: number[], i: number): Span {
return { lo: values[i]!, hi: values[i + 1]! };
}
const spans: number[] = [5, 9, 2, 7, 11];
function spanTotal(): number {
let total = 0;
for (let i = 0; i < 4; i++) {
const span = spanAt(spans, i);
total += span.hi * 10 - span.lo;
}
return total;
}
const ints: number[] = [3, 4, 5];
const fractions: number[] = [0.5, 1.5, 2.5];
const halved: number[] = [8, 6, 4];
halve(halved);
const pick: (values: number[]) => number = first;
const viaValue: number[] = [9, 8, 7];
// An index that calls a function reassigning the global: the read uses the
// array the global held before the index ran, as in Node.
let swapped: number[] = [1, 2, 3];
function swapIn(): number {
swapped = [];
for (let i = 0; i < 4; i++) swapped.push(i + 10);
return 1;
}
function fillSwapped(n: number): number {
swapped = [];
for (let i = 0; i < n; i++) swapped.push(i & 15);
let s = 0;
for (let i = 0; i < swapped.length; i++) s += swapped[i]!;
return s;
}
function readSwapped(): number | undefined {
return swapped[swapIn()];
}
console.log(`fnv ${fnv(data)} ${fnv(data)}`);
const h = histogram(data, 10);
console.log(`hist ${h.length} ${h[0]} ${h[9]} ${total(h)}`);
console.log(locals(128));
console.log(readSparse(data, 5), readSparse(data, 5000));
console.log(`mean ${mean(ints)} ${mean(fractions)}`);
console.log(`halved ${halved[0]} ${halved[2]} ${halved.length}`);
console.log(`value ${pick(viaValue)} ${viaValue.length}`);
console.log(`regrow ${regrow(40)} ${reassigned.length} ${reassigned[39]} ${regrow(5)}`);
console.log(`span ${spanTotal()}`);
console.log(`swap ${fillSwapped(6)} ${readSwapped()} ${swapped[1]}`);
void later().then((v) => console.log(`later ${v}`));
@@ -0,0 +1 @@
{ "plain": 3100, "san": 3500 }