From 5de340f9333dce92dec1ca60e0cd0b5ca32e2659 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 5 Oct 2026 10:35:06 -0500 Subject: [PATCH] Optimize binary numeric pipelines - Specialize standard Buffer and DataView numeric reads and writes while preserving checked errors, byte order, and unaligned access. - Carry integer values through binary-processing loops and remove proven field bounds and receiver ownership overhead. - Strengthen numeric IR validation and cover binary pipelines, edge cases, and native compiler parity. --- .../compiler/src/backend/llvm/byte-numbers.ts | 231 ++++++++++++++++++ .../compiler/src/backend/llvm/bytes-bounds.ts | 14 +- .../compiler/src/backend/llvm/expr-bytes.ts | 32 ++- .../src/backend/llvm/integer-views.ts | 4 + .../backend/llvm/reference-effects.test.ts | 12 + .../src/backend/llvm/reference-effects.ts | 5 +- packages/compiler/src/ir/analysis.ts | 4 +- packages/compiler/src/ir/byte-numbers.test.ts | 42 ++++ packages/compiler/src/ir/byte-numbers.ts | 82 +++++++ packages/compiler/src/ir/integer-loops.ts | 21 +- packages/compiler/src/ir/integer-ranges.ts | 8 +- packages/compiler/src/ir/validate.test.ts | 21 ++ packages/compiler/src/ir/validate.ts | 9 +- .../test/byte-number-emission.test.ts | 46 ++++ .../test/ts7/baselines/order-parity.json | 6 + tests/corpus/byte-number-pipeline.ts | 104 ++++++++ tests/corpus/counted-loop-numbers.ts | 21 ++ .../self-hosting-integer-ranges.test.ts | 2 +- tests/harness/self-hosting-validator-cases.ts | 9 + tests/harness/self-hosting-validator.test.ts | 1 + tests/harness/wasi-differential.test.ts | 3 + 21 files changed, 645 insertions(+), 32 deletions(-) create mode 100644 packages/compiler/src/backend/llvm/byte-numbers.ts create mode 100644 packages/compiler/src/ir/byte-numbers.test.ts create mode 100644 packages/compiler/src/ir/byte-numbers.ts create mode 100644 packages/compiler/test/byte-number-emission.test.ts create mode 100644 tests/corpus/byte-number-pipeline.ts diff --git a/packages/compiler/src/backend/llvm/byte-numbers.ts b/packages/compiler/src/backend/llvm/byte-numbers.ts new file mode 100644 index 000000000..2fb7f656a --- /dev/null +++ b/packages/compiler/src/backend/llvm/byte-numbers.ts @@ -0,0 +1,231 @@ +import { byteNumberRange, type ByteNumberAccess } from "../../ir/byte-numbers.js"; +import type { IrExpr } from "../../ir/ir.js"; +import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; +import { f64Lit } from "./common.js"; +import { exactInteger, widenInteger } from "./integer-values.js"; + +/** Supported LLVM targets are little-endian. All memory accesses use align + * 1: Buffer fields and DataView windows can start at arbitrary byte offsets. */ +function endianBits(host: LlvmEmitterContext, raw: string, width: number, little: string): string { + if (width === 1 || little === "true") return raw; + const B = host.B, bits = width * 8; + const swapBits = bits <= 16 ? 16 : bits <= 32 ? 32 : 64; + let operand = raw; + if (bits !== swapBits) { + operand = B.tmp(); + B.line(`${operand} = zext i${bits} ${raw} to i${swapBits}`); + } + host.declare(`declare i${swapBits} @llvm.bswap.i${swapBits}(i${swapBits})`); + let swapped = B.tmp(); + B.line(`${swapped} = call i${swapBits} @llvm.bswap.i${swapBits}(i${swapBits} ${operand})`); + if (bits !== swapBits) { + const shifted = B.tmp(), narrowed = B.tmp(); + B.line(`${shifted} = lshr i${swapBits} ${swapped}, ${swapBits - bits}`); + B.line(`${narrowed} = trunc i${swapBits} ${shifted} to i${bits}`); + swapped = narrowed; + } + if (little === "false") return swapped; + const selected = B.tmp(); + B.line(`${selected} = select i1 ${little}, i${bits} ${raw}, i${bits} ${swapped}`); + return selected; +} + +/** Emit the successful numeric access directly. The original runtime call + * remains the error path, preserving Buffer's value-before-offset errors, + * DataView's ToIndex behavior, and exception cleanup through the host. All + * operands have already been evaluated, exactly once and in source order. */ +export function emitByteNumber( + host: LlvmEmitterContext, + e: IrExpr & { kind: "bytesIntrinsic" }, + spec: ByteNumberAccess, + receiver: LlValue, + offset: LlValue, + value: LlValue | null, + little: string, + fallback: () => LlValue, +): LlValue { + const B = host.B, size = host.sizeType; + const slow = B.newLabel("bytes.number.slow"), access = B.newLabel("bytes.number.access"); + const fastDone = B.newLabel("bytes.number.fast.done"), slowDone = B.newLabel("bytes.number.slow.done"); + const done = B.newLabel("bytes.number.done"); + const bits = spec.width * 8; + const integer = exactInteger(host, offset, e.args[spec.offsetArg]); + let index: string, fits: string, room = "true"; + if (integer && host.bytesBounds.has(e)) { + const wide = widenInteger(host, integer); + index = size === "i64" ? wide : B.tmp(); + if (size !== "i64") B.line(`${index} = trunc i64 ${wide} to ${size}`); + fits = "true"; + } else { + const lengthPtr = B.tmp(), length = B.tmp(), capacity = B.tmp(); + room = B.tmp(); + B.line(`${lengthPtr} = getelementptr inbounds %ScrBytes, ptr ${receiver.name}, i64 0, i32 1`); + B.line(`${length} = load ${size}, ptr ${lengthPtr}`); + B.line(`${room} = icmp uge ${size} ${length}, ${spec.width}`); + B.line(`${capacity} = sub ${size} ${length}, ${spec.width}`); + if (integer) { + const wide = widenInteger(host, integer); + const cap = size === "i64" ? capacity : B.tmp(); + if (size !== "i64") B.line(`${cap} = zext ${size} ${capacity} to i64`); + fits = B.tmp(); + B.line(`${fits} = icmp ule i64 ${wide}, ${cap}`); + if (spec.dataView && integer.range.max > Number.MAX_SAFE_INTEGER) { + const safe = B.tmp(), both = B.tmp(); + B.line(`${safe} = icmp ule i64 ${wide}, ${Number.MAX_SAFE_INTEGER}`); + B.line(`${both} = and i1 ${fits}, ${safe}`); + fits = both; + } + index = size === "i64" ? wide : B.tmp(); + if (size !== "i64") B.line(`${index} = trunc i64 ${wide} to ${size}`); + } else { + let normalized = offset.name; + if (spec.dataView) { + const nan = B.tmp(), finiteOrZero = B.tmp(); + normalized = B.tmp(); + B.line(`${nan} = fcmp uno double ${offset.name}, ${offset.name}`); + B.line(`${finiteOrZero} = select i1 ${nan}, double ${f64Lit(0)}, double ${offset.name}`); + host.declare("declare double @llvm.trunc.f64(double)"); + B.line(`${normalized} = call double @llvm.trunc.f64(double ${finiteOrZero})`); + } + const cap = B.tmp(), positive = B.tmp(), bounded = B.tmp(), representable = B.tmp(), range = B.tmp(); + const addressRange = B.tmp(), initial = B.tmp(), convert = B.newLabel("bytes.number.index"); + B.line(`${cap} = uitofp ${size} ${capacity} to double`); + B.line(`${positive} = fcmp oge double ${normalized}, ${f64Lit(0)}`); + B.line(`${bounded} = fcmp ole double ${normalized}, ${cap}`); + // The separate address limit makes fptoui safe even if converting a + // maximum size_t length to double rounds upward on a 64-bit target. + B.line(`${representable} = fcmp olt double ${normalized}, ${f64Lit(size === "i64" ? 2 ** 64 : 2 ** 32)}`); + B.line(`${range} = and i1 ${positive}, ${bounded}`); + B.line(`${addressRange} = and i1 ${range}, ${representable}`); + B.line(`${initial} = and i1 ${room}, ${addressRange}`); + B.condBr(initial, convert, slow); + B.startBlock(convert); + index = B.tmp(); + B.line(`${index} = fptoui double ${normalized} to ${size}`); + fits = B.tmp(); + const roundTrip = B.tmp(); + B.line(`${roundTrip} = uitofp ${size} ${index} to double`); + B.line(`${fits} = fcmp oeq double ${roundTrip}, ${normalized}`); + const withinCapacity = B.tmp(), exactBounds = B.tmp(); + B.line(`${withinCapacity} = icmp ule ${size} ${index}, ${capacity}`); + B.line(`${exactBounds} = and i1 ${fits}, ${withinCapacity}`); + fits = exactBounds; + if (spec.dataView) { + const safe = B.tmp(), both = B.tmp(); + B.line(`${safe} = fcmp ole double ${normalized}, ${f64Lit(Number.MAX_SAFE_INTEGER)}`); + B.line(`${both} = and i1 ${fits}, ${safe}`); + fits = both; + } + } + } + let valid = B.tmp(); + B.line(`${valid} = and i1 ${room}, ${fits}`); + if (spec.write && !spec.dataView && !spec.floating) { + const range = byteNumberRange(spec)!; + const known = host.integerRanges.get(e.args[spec.valueArg]!); + if (!known || known.min < range.min || known.max > range.max) { + const above = B.tmp(), below = B.tmp(), valueFits = B.tmp(), both = B.tmp(); + // Buffer integer writes accept NaN (stored as zero) and fractions + // within the declared range. Unordered comparisons preserve that. + B.line(`${above} = fcmp uge double ${value!.name}, ${f64Lit(range.min)}`); + B.line(`${below} = fcmp ule double ${value!.name}, ${f64Lit(range.max)}`); + B.line(`${valueFits} = and i1 ${above}, ${below}`); + B.line(`${both} = and i1 ${valid}, ${valueFits}`); + valid = both; + } + } + B.condBr(valid, access, slow); + B.startBlock(access); + const data = host.emitBytesData(receiver.name), pointer = B.tmp(); + B.line(`${pointer} = getelementptr inbounds i8, ptr ${data}, ${size} ${index}`); + let fastResult = "", fastInteger = ""; + const readRange = !spec.write ? byteNumberRange(spec) : null; + const integerType = bits <= 32 ? "i32" : "i64"; + if (spec.write) { + let raw: string; + if (spec.floating) { + let stored = value!.name; + if (bits === 32) { + stored = B.tmp(); + B.line(`${stored} = fptrunc double ${value!.name} to float`); + } + raw = B.tmp(); + B.line(`${raw} = bitcast ${bits === 32 ? "float" : "double"} ${stored} to i${bits}`); + } else if (spec.dataView) { + const stored = host.emitToUint32(value!.name, e.args[spec.valueArg], value!.uint32); + raw = stored; + if (bits < 32) { + raw = B.tmp(); + B.line(`${raw} = trunc i32 ${stored} to i${bits}`); + } + } else { + const integerValue = exactInteger(host, value!, e.args[spec.valueArg]); + if (integerValue && (integerValue.type === "i64" || bits <= 32)) { + raw = integerValue.name; + if (integerValue.type !== `i${bits}`) { + raw = B.tmp(); + B.line(`${raw} = trunc ${integerValue.type} ${integerValue.name} to i${bits}`); + } + } else { + const nan = B.tmp(), normalized = B.tmp(), whole = B.tmp(); + B.line(`${nan} = fcmp uno double ${value!.name}, ${value!.name}`); + B.line(`${normalized} = select i1 ${nan}, double ${f64Lit(0)}, double ${value!.name}`); + B.line(`${whole} = fptosi double ${normalized} to i64`); + raw = B.tmp(); + B.line(`${raw} = trunc i64 ${whole} to i${bits}`); + } + } + const stored = endianBits(host, raw, spec.width, little); + B.line(`store i${bits} ${stored}, ptr ${pointer}, align 1`); + if (!spec.dataView) { + fastResult = B.tmp(); + B.line(`${fastResult} = fadd double ${offset.name}, ${f64Lit(spec.width)}`); + } + } else { + const loaded = B.tmp(); + B.line(`${loaded} = load i${bits}, ptr ${pointer}, align 1`); + const raw = endianBits(host, loaded, spec.width, little); + fastResult = B.tmp(); + if (spec.floating) { + if (bits === 32) { + const float = B.tmp(); + B.line(`${float} = bitcast i32 ${raw} to float`); + B.line(`${fastResult} = fpext float ${float} to double`); + } else B.line(`${fastResult} = bitcast i64 ${raw} to double`); + } else { + B.line(`${fastResult} = ${spec.signed ? "sitofp" : "uitofp"} i${bits} ${raw} to double`); + if (readRange) { + fastInteger = raw; + if (`i${bits}` !== integerType) { + fastInteger = B.tmp(); + B.line(`${fastInteger} = ${spec.signed ? "sext" : "zext"} i${bits} ${raw} to ${integerType}`); + } + } + } + } + B.br(fastDone); + B.startBlock(fastDone); + B.br(done); + B.startBlock(slow); + const failure = fallback(); + let slowInteger = ""; + if (readRange) { + slowInteger = B.tmp(); + B.line(`${slowInteger} = ${spec.signed ? "fptosi" : "fptoui"} double ${failure.name} to ${integerType}`); + } + B.br(slowDone); + B.startBlock(slowDone); + B.br(done); + B.startBlock(done); + if (e.type.kind === "void") return { name: "", type: e.type }; + const result = B.tmp(); + B.line(`${result} = phi double [ ${fastResult}, %${fastDone} ], [ ${failure.name}, %${slowDone} ]`); + if (readRange) { + const integer = B.tmp(); + B.line(`${integer} = phi ${integerType} [ ${fastInteger}, %${fastDone} ], [ ${slowInteger}, %${slowDone} ]`); + const uint32 = integerType === "i32" ? integer : B.tmp(); + if (integerType !== "i32") B.line(`${uint32} = trunc i64 ${integer} to i32`); + return { name: result, type: e.type, uint32, integer: { name: integer, type: integerType, signed: spec.signed, range: readRange } }; + } + return { name: result, type: e.type }; +} diff --git a/packages/compiler/src/backend/llvm/bytes-bounds.ts b/packages/compiler/src/backend/llvm/bytes-bounds.ts index ac0664c9c..b48efa204 100644 --- a/packages/compiler/src/backend/llvm/bytes-bounds.ts +++ b/packages/compiler/src/backend/llvm/bytes-bounds.ts @@ -3,6 +3,7 @@ import type { IntegerRanges } from "../../ir/integer-ranges.js"; import { integerArithmeticRange, type IntegerRange } from "../../ir/integer-ranges.js"; import { everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js"; import { isStableReceiverOperand } from "../../ir/analysis.js"; +import { byteNumberAccess } from "../../ir/byte-numbers.js"; interface Extent { receiver: string; offset: number } interface Bound extends Extent { counter: string; minimum: number } @@ -34,7 +35,8 @@ export function findBytesBounds(fn: IrFunction, ranges: IntegerRanges): Readonly const cached = new Map(); const resolving = new Set(); function extent(e: IrExpr, cache: boolean): Extent | null { - if (e.kind === "bytesIntrinsic" && e.method === "length" && e.receiver.kind === "varRef" && + if (e.kind === "bytesIntrinsic" && (e.method === "length" || e.method === "byteLength" && + e.receiver.type.kind === "bytes" && (e.receiver.type.elem === "u8" || e.receiver.type.elem === "u8c")) && e.receiver.kind === "varRef" && eligible(e.receiver.localId) && (!cache || !changed.has(e.receiver.localId))) return { receiver: e.receiver.localId, offset: 0 }; if (e.kind === "varRef" && eligible(e.localId) && !changed.has(e.localId)) { const previous = cached.get(e.localId); @@ -112,7 +114,7 @@ export function findBytesBounds(fn: IrFunction, ranges: IntegerRanges): Readonly } return result && stable(s.body, result.receiver, counter) ? result : null; } - function access(node: Node, array: IrExpr, index: IrExpr, active: Bound[]): void { + function access(node: Node, array: IrExpr, index: IrExpr, active: Bound[], width: number): void { const integer = ranges.get(index); let safe = false; if (array.kind === "varRef" && eligible(array.localId)) { @@ -120,17 +122,19 @@ export function findBytesBounds(fn: IrFunction, ranges: IntegerRanges): Readonly if (array.localId !== bound.receiver) continue; if (!integer && !affineRange(index, bound)) continue; const offset = affine(index, bound.counter); - if (offset !== null && bound.minimum + offset >= 0 && bound.offset + offset <= 0) { safe = true; break; } + if (offset !== null && bound.minimum + offset >= 0 && bound.offset + offset + width - 1 <= 0) { safe = true; break; } } } proved.set(node, (proved.get(node) ?? true) && safe); } function expr(e: IrExpr, active: Bound[]): boolean { - if (e.kind === "bytesIntrinsic" && e.method === "get" && e.args[0]) access(e, e.receiver, e.args[0], active); + if (e.kind === "bytesIntrinsic" && e.method === "get" && e.args[0]) access(e, e.receiver, e.args[0], active, 1); + const numeric = byteNumberAccess(e); + if (numeric && e.kind === "bytesIntrinsic") access(e, e.receiver, e.args[numeric.offsetArg]!, active, numeric.width); return everyExprChild(e, (child) => expr(child, active), (child) => stmt(child, active)); } function stmt(s: IrStmt, active: Bound[]): boolean { - if (s.kind === "bytesSet") access(s, s.arr, s.index, active); + if (s.kind === "bytesSet") access(s, s.arr, s.index, active, 1); if (s.kind === "for") { if (s.init) stmt(s.init, active); if (s.cond) expr(s.cond, active); diff --git a/packages/compiler/src/backend/llvm/expr-bytes.ts b/packages/compiler/src/backend/llvm/expr-bytes.ts index 5db1afc81..ee3a59620 100644 --- a/packages/compiler/src/backend/llvm/expr-bytes.ts +++ b/packages/compiler/src/backend/llvm/expr-bytes.ts @@ -5,6 +5,8 @@ import { BYTES_ELEMENT_SIZE, F64, type IrBytesElem, type IrExpr } from "../../ir import type { LlvmEmitterContext, LlValue } from "./expr-context.js"; import { F64_INF, f64Lit } from "./common.js"; import { exactInteger, widenInteger } from "./integer-values.js"; +import { byteNumberAccess } from "../../ir/byte-numbers.js"; +import { emitByteNumber } from "./byte-numbers.js"; const BYTES_NUM_KIND: Record = { u8: { kind: 0, le: false }, @@ -333,27 +335,31 @@ export function emitBytesIntrinsic(host: LlvmEmitterContext, e: IrExpr & { kind: if (e.method === "readNum" || e.method === "writeNum" || e.method === "readNumVar" || e.method === "writeNumVar") { const tok = e.args[0]!; if (tok.kind !== "strLit") throw new InternalCompilerError(`llvm emitter bug: bytesIntrinsic ${e.method} kind must be a strLit`); - const r0 = emitBorrowedInput(host, e.receiver); + const r0 = host.emitStableReceiver(e.receiver, e.args.slice(1)); const rest = e.args.slice(1).map((a) => host.emitExpr(a)); + const access = byteNumberAccess(e); + const numeric = (fallback: () => LlValue): LlValue => access + ? emitByteNumber(host, e, access, r0, rest[access.offsetArg - 1]!, access.write ? rest[access.valueArg - 1]! : null, String(access.littleEndian), fallback) + : fallback(); if (e.method === "readNum" || e.method === "writeNum") { const spec = BYTES_NUM_KIND[tok.value]; if (!spec) throw new InternalCompilerError(`llvm emitter bug: bytes numeric kind '${tok.value}'`); - return e.method === "readNum" + return numeric(() => e.method === "readNum" ? call("scr_bytes_read_num", "double (ptr, double, i32, i1 zeroext)", `ptr ${r0.name}, double ${rest[0]!.name}, i32 ${spec.kind}, i1 ${spec.le}`, false, true) : call("scr_bytes_write_num", "double (ptr, double, double, i32, i1 zeroext)", - `ptr ${r0.name}, double ${rest[0]!.name}, double ${rest[1]!.name}, i32 ${spec.kind}, i1 ${spec.le}`, false, true); + `ptr ${r0.name}, double ${rest[0]!.name}, double ${rest[1]!.name}, i32 ${spec.kind}, i1 ${spec.le}`, false, true)); } const spec = BYTES_NUM_VAR[tok.value]; if (!spec) throw new InternalCompilerError(`llvm emitter bug: bytes variable-width kind '${tok.value}'`); - return e.method === "readNumVar" + return numeric(() => e.method === "readNumVar" ? call("scr_bytes_read_var", "double (ptr, double, double, i1 zeroext, i1 zeroext)", `ptr ${r0.name}, double ${rest[0]!.name}, double ${rest[1]!.name}, i1 ${spec.sign}, i1 ${spec.le}`, false, true) : call("scr_bytes_write_var", "double (ptr, double, double, double, i1 zeroext, i1 zeroext)", - `ptr ${r0.name}, double ${rest[0]!.name}, double ${rest[1]!.name}, double ${rest[2]!.name}, i1 ${spec.sign}, i1 ${spec.le}`, false, true); + `ptr ${r0.name}, double ${rest[0]!.name}, double ${rest[1]!.name}, double ${rest[2]!.name}, i1 ${spec.sign}, i1 ${spec.le}`, false, true)); } const method = e.method; - const directElementAccess = method === "length" || method === "byteLength" || method === "get"; + const directElementAccess = method === "length" || method === "byteLength" || method === "get" || byteNumberAccess(e) !== null; const r = directElementAccess ? host.emitStableReceiver(e.receiver, e.args) : emitBorrowedInput(host, e.receiver); @@ -615,16 +621,17 @@ export function emitBytesIntrinsic(host: LlvmEmitterContext, e: IrExpr & { kind: case "dvGetFloat32": case "dvGetFloat64": case "dvGetBigUint64Number": - case "dvGetBigInt64Number": + case "dvGetBigInt64Number": { // DataView getters: an omitted littleEndian is big-endian (the JS // default). Throw Node's constant RangeError on a bad offset. - return call( + return emitByteNumber(host, e, byteNumberAccess(e)!, r, args[0]!, null, args[1]?.name ?? "false", () => call( "scr_dataview_get", "double (ptr, double, i32, i1 zeroext)", `ptr ${r.name}, double ${args[0]!.name}, i32 ${DV_GET_KIND[method]}, i1 ${args[1]?.name ?? "false"}`, false, true, - ); + )); + } case "dvSetUint8": case "dvSetInt8": case "dvSetUint16": @@ -632,17 +639,18 @@ export function emitBytesIntrinsic(host: LlvmEmitterContext, e: IrExpr & { kind: case "dvSetUint32": case "dvSetInt32": case "dvSetFloat32": - case "dvSetFloat64": + case "dvSetFloat64": { // DataView setters: [offset, value, littleEndian?] — void; throw // the getters' constant RangeError on a bad offset. - return call( + return emitByteNumber(host, e, byteNumberAccess(e)!, r, args[0]!, args[1]!, args[2]?.name ?? "false", () => call( "scr_dataview_set", "void (ptr, double, double, i32, i1 zeroext)", `ptr ${r.name}, double ${args[0]!.name}, double ${args[1]!.name}, ` + `i32 ${DV_SET_KIND[method]}, i1 ${args[2]?.name ?? "false"}`, false, true, - ); + )); + } default: { const _exhaustive: never = method; void _exhaustive; diff --git a/packages/compiler/src/backend/llvm/integer-views.ts b/packages/compiler/src/backend/llvm/integer-views.ts index 5c5caa202..66ca16461 100644 --- a/packages/compiler/src/backend/llvm/integer-views.ts +++ b/packages/compiler/src/backend/llvm/integer-views.ts @@ -1,5 +1,6 @@ import type { IrExpr, IrFunction, IrStmt } from "../../ir/ir.js"; import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; +import { byteNumberAccess } from "../../ir/byte-numbers.js"; /** Keep a computed ToUint32 view beside an ordinary number when integer * consumers in loops can reuse it. The number remains authoritative for floating @@ -23,6 +24,9 @@ export function findIntegerViews(fn: IrFunction): Set { } else if (node.kind === "unary" && node.op === "~") operand(node.operand); else if (node.kind === "libCall" && (node.fn === "math.imul" || node.fn === "math.clz32")) { for (const arg of node.args) operand(arg); + } else if (node.kind === "bytesIntrinsic") { + const numeric = byteNumberAccess(node); + if (numeric?.write && !numeric.floating && numeric.width <= 4) operand(node.args[numeric.valueArg]!); } return everyExprChild(node, expr, stmt); }; diff --git a/packages/compiler/src/backend/llvm/reference-effects.test.ts b/packages/compiler/src/backend/llvm/reference-effects.test.ts index 0fad3fafc..a6c4cf805 100644 --- a/packages/compiler/src/backend/llvm/reference-effects.test.ts +++ b/packages/compiler/src/backend/llvm/reference-effects.test.ts @@ -42,6 +42,18 @@ test("suspending bodies and environments cannot inherit synchronous guarantees", expect(preservesRegexInputs("matchAllInto")).toBe(false); }); +test("numeric byte operations preserve owners but argument replacement and callbacks do not", () => { + const bytes: IrExpr = { kind: "varRef", localId: "bytes", type: { kind: "bytes", elem: "u8" }, loc }; + const read: IrExpr = { kind: "bytesIntrinsic", method: "dvGetUint32", receiver: bytes, args: [number], type: F64, loc }; + const write: IrExpr = { kind: "bytesIntrinsic", method: "dvSetUint32", receiver: bytes, args: [number, read], type: VOID, loc }; + const summary = effects([fn("read", [read]), fn("write", [write])]); + expect(summary.functions).toEqual(new Set(["read", "write"])); + expect(summary.preserves(write)).toBe(true); + const replace: IrExpr = { kind: "assignExpr", localId: "bytes", value: bytes, type: bytes.type, loc }; + expect(summary.preserves({ ...read, receiver: replace })).toBe(false); + expect(summary.preserves({ ...read, args: [call("unknown")] })).toBe(false); +}); + test("long call graphs use a worklist and facts are rebuilt for changed bodies", () => { const functions = Array.from({ length: 2000 }, (_, i) => fn(`f${i}`, i === 1999 ? [] : [call(`f${i + 1}`)])); expect(effects(functions).functions.size).toBe(functions.length); diff --git a/packages/compiler/src/backend/llvm/reference-effects.ts b/packages/compiler/src/backend/llvm/reference-effects.ts index 8d89af597..53f7c5a2d 100644 --- a/packages/compiler/src/backend/llvm/reference-effects.ts +++ b/packages/compiler/src/backend/llvm/reference-effects.ts @@ -4,6 +4,7 @@ import { isRefCounted, type IrExpr, type IrFunction, type IrStmt } from "../../i import { everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js"; import { borrowsStringInputs } from "./string-lifetimes.js"; import { borrowsMapReadInputs } from "./map-read-lifetimes.js"; +import { byteNumberAccess } from "../../ir/byte-numbers.js"; type Call = IrExpr & { kind: "call" }; @@ -36,6 +37,8 @@ function expressionPreservesEdges(e: IrExpr, call: (value: Call) => boolean): bo case "libCall": return e.fn === "error.nodeThrow" || isStableReceiverOperand(e, ""); case "call": return call(e); case "arrIntrinsic": return e.method === "length"; + case "bytesIntrinsic": return e.method === "get" || e.method === "length" || e.method === "byteLength" || + e.method === "byteOffset" || byteNumberAccess(e) !== null; default: return false; } } @@ -43,7 +46,7 @@ function expressionPreservesEdges(e: IrExpr, call: (value: Call) => boolean): bo function statementPreservesEdges(s: IrStmt): boolean { switch (s.kind) { case "varDecl": case "exprStmt": case "return": case "if": case "for": - case "while": case "doWhile": case "block": case "break": case "continue": return true; + case "while": case "doWhile": case "block": case "break": case "continue": case "bytesSet": return true; case "assign": case "fieldSet": case "recordSet": return !isRefCounted(s.value.type); default: return false; } diff --git a/packages/compiler/src/ir/analysis.ts b/packages/compiler/src/ir/analysis.ts index 95fc9fc43..bdb06987e 100644 --- a/packages/compiler/src/ir/analysis.ts +++ b/packages/compiler/src/ir/analysis.ts @@ -1,4 +1,5 @@ import { InternalCompilerError } from "../errors.js"; +import { byteNumberAccess } from "./byte-numbers.js"; import { BYTES_ELEMENT_NAME, DYN_HANDLE_KINDS, @@ -119,6 +120,7 @@ export function isStableReceiverOperand(e: IrExpr, receiverLocalId: string): boo switch (e.kind) { case "numLit": case "boolLit": + case "strLit": case "varRef": case "incDec": return true; @@ -136,7 +138,7 @@ export function isStableReceiverOperand(e: IrExpr, receiverLocalId: string): boo isStableReceiverOperand(e.then, receiverLocalId) && isStableReceiverOperand(e.else_, receiverLocalId); case "bytesIntrinsic": - return (e.method === "get" || e.method === "length" || e.method === "byteLength") && + return (e.method === "get" || e.method === "length" || e.method === "byteLength" || byteNumberAccess(e) !== null) && e.receiver.kind === "varRef" && e.args.every((arg) => isStableReceiverOperand(arg, receiverLocalId)); case "libCall": diff --git a/packages/compiler/src/ir/byte-numbers.test.ts b/packages/compiler/src/ir/byte-numbers.test.ts new file mode 100644 index 000000000..6a34569e6 --- /dev/null +++ b/packages/compiler/src/ir/byte-numbers.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "vitest"; +import { F64, STRING, VOID, bytesOf, type IrBytesIntrinsicMethod, type IrExpr, type IrFunction } from "./ir.js"; +import { byteNumberAccess, byteNumberRange, validByteNumberToken } from "./byte-numbers.js"; +import { analyzeIntegerRanges } from "./integer-ranges.js"; + +const loc = { file: "byte-numbers.ts", start: 0, end: 0 }; +const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc }); +const token = (value: string): IrExpr => ({ kind: "strLit", value, type: STRING, loc }); +const receiver: IrExpr = { kind: "varRef", localId: "bytes", type: bytesOf("u8"), loc }; +function access(method: IrBytesIntrinsicMethod, args: IrExpr[]): IrExpr { + return { kind: "bytesIntrinsic", method, receiver, args, type: method.startsWith("dvSet") ? VOID : F64, loc }; +} + +test("standard numeric reads carry exact integer ranges without classifying floats or rounded 64-bit reads", () => { + const cases: [IrExpr, { min: number; max: number } | null][] = [ + [access("readNum", [token("u32le"), num(0)]), { min: 0, max: 4294967295 }], + [access("readNum", [token("i16be"), num(0)]), { min: -32768, max: 32767 }], + [access("readNumVar", [token("ibe"), num(0), num(5)]), { min: -(2 ** 39), max: 2 ** 39 - 1 }], + [access("readNumVar", [token("ule"), num(0), num(6)]), { min: 0, max: 2 ** 48 - 1 }], + [access("dvGetUint8", [num(0)]), { min: 0, max: 255 }], + [access("dvGetInt32", [num(0)]), { min: -(2 ** 31), max: 2 ** 31 - 1 }], + [access("dvGetBigUint64Number", [num(0)]), null], + [access("dvGetFloat32", [num(0)]), null], + [access("readNum", [token("f64le"), num(0)]), null], + [access("writeNum", [token("u32le"), num(1), num(0)]), null], + ]; + const fn: IrFunction = { name: "f", params: [], locals: [], returnType: VOID, loc, + body: cases.map(([expr]) => ({ kind: "exprStmt", expr, loc })) }; + const ranges = analyzeIntegerRanges(fn); + for (const [expr, expected] of cases) expect(ranges.get(expr)).toEqual(expected); +}); + +test("variable widths require a valid literal and fixed tokens cannot inherit prototype entries", () => { + for (const width of [0, 7, 1.5, NaN, Infinity]) expect(byteNumberAccess(access("readNumVar", [token("ule"), num(0), num(width)]))).toBeNull(); + for (const value of ["toString", "constructor", "__proto__", "u8le", "u64le", "f32", "u32xx"]) { + expect(validByteNumberToken("readNum", value)).toBe(false); + expect(byteNumberAccess(access("readNum", [token(value), num(0)]))).toBeNull(); + } + const dynamic: IrExpr = { kind: "varRef", localId: "width", type: F64, loc }; + expect(byteNumberAccess(access("readNumVar", [token("ule"), num(0), dynamic]))).toBeNull(); + expect(byteNumberRange({ width: 8, signed: true, floating: false })).toBeNull(); +}); diff --git a/packages/compiler/src/ir/byte-numbers.ts b/packages/compiler/src/ir/byte-numbers.ts new file mode 100644 index 000000000..13a96e5b6 --- /dev/null +++ b/packages/compiler/src/ir/byte-numbers.ts @@ -0,0 +1,82 @@ +import type { IrExpr } from "./ir.js"; +import type { IntegerRange } from "./integer-ranges.js"; + +/** Numeric widths are properties of the standard byte operation, not of + * ordinary number values. Offsets and values retain their JS contracts. */ +export interface ByteNumberFormat { + width: number; + signed: boolean; + floating: boolean; +} + +export interface ByteNumberAccess extends ByteNumberFormat { + dataView: boolean; + write: boolean; + offsetArg: number; + valueArg: number; + littleEndian: boolean; + endianArg: number; +} + +const FORMATS: Readonly> = { + u8: { width: 1, signed: false, floating: false }, + i8: { width: 1, signed: true, floating: false }, + u16: { width: 2, signed: false, floating: false }, + i16: { width: 2, signed: true, floating: false }, + u32: { width: 4, signed: false, floating: false }, + i32: { width: 4, signed: true, floating: false }, + f32: { width: 4, signed: false, floating: true }, + f64: { width: 8, signed: false, floating: true }, + u64: { width: 8, signed: false, floating: false }, + i64: { width: 8, signed: true, floating: false }, +}; + +const VIEW_FORMATS: Readonly> = { + dvGetUint8: "u8", dvGetInt8: "i8", dvGetUint16: "u16", dvGetInt16: "i16", + dvGetUint32: "u32", dvGetInt32: "i32", dvGetFloat32: "f32", dvGetFloat64: "f64", + dvGetBigUint64Number: "u64", dvGetBigInt64Number: "i64", + dvSetUint8: "u8", dvSetInt8: "i8", dvSetUint16: "u16", dvSetInt16: "i16", + dvSetUint32: "u32", dvSetInt32: "i32", dvSetFloat32: "f32", dvSetFloat64: "f64", +}; + +function bufferFormat(token: string): ByteNumberFormat | null { + const kind = token.length === 2 ? token : token.slice(0, -2); + if (!Object.hasOwn(FORMATS, kind) || kind === "u64" || kind === "i64") return null; + const format = FORMATS[kind]!; + return format.width === 1 ? token === kind ? format : null + : token === kind + "le" || token === kind + "be" ? format : null; +} + +export function validByteNumberToken(method: string, token: string): boolean { + return method === "readNum" || method === "writeNum" ? bufferFormat(token) !== null + : (method === "readNumVar" || method === "writeNumVar") && ["ule", "ube", "ile", "ibe"].includes(token); +} + +export function byteNumberAccess(e: IrExpr): ByteNumberAccess | null { + if (e.kind !== "bytesIntrinsic") return null; + if (e.method === "readNum" || e.method === "writeNum") { + const token = e.args[0]; + if (token?.kind !== "strLit") return null; + const format = bufferFormat(token.value); + if (!format) return null; + const write = e.method === "writeNum"; + return { ...format, dataView: false, write, offsetArg: write ? 2 : 1, valueArg: 1, littleEndian: token.value.endsWith("le"), endianArg: -1 }; + } + if (e.method === "readNumVar" || e.method === "writeNumVar") { + const token = e.args[0], write = e.method === "writeNumVar"; + const width = e.args[write ? 3 : 2]; + if (token?.kind !== "strLit" || !validByteNumberToken(e.method, token.value) || + width?.kind !== "numLit" || !Number.isInteger(width.value) || width.value < 1 || width.value > 6) return null; + return { width: width.value, signed: token.value.startsWith("i"), floating: false, dataView: false, + write, offsetArg: write ? 2 : 1, valueArg: 1, littleEndian: token.value.endsWith("le"), endianArg: -1 }; + } + if (!Object.hasOwn(VIEW_FORMATS, e.method)) return null; + const format = FORMATS[VIEW_FORMATS[e.method]!]!, write = e.method.startsWith("dvSet"); + return { ...format, dataView: true, write, offsetArg: 0, valueArg: 1, littleEndian: false, endianArg: format.width === 1 ? -1 : write ? 2 : 1 }; +} + +export function byteNumberRange(format: ByteNumberFormat): IntegerRange | null { + if (format.floating || format.width > 6) return null; + const bits = format.width * 8; + return format.signed ? { min: -(2 ** (bits - 1)), max: 2 ** (bits - 1) - 1 } : { min: 0, max: 2 ** bits - 1 }; +} diff --git a/packages/compiler/src/ir/integer-loops.ts b/packages/compiler/src/ir/integer-loops.ts index 969f46651..9dc32e5e4 100644 --- a/packages/compiler/src/ir/integer-loops.ts +++ b/packages/compiler/src/ir/integer-loops.ts @@ -2,6 +2,7 @@ import type { IrExpr, IrLocal, IrStmt } from "./ir.js"; import type { IntegerRange, IntegerRanges } from "./integer-ranges.js"; import { everyExprChild, everyStmtChild, everyStmtList } from "./traverse.js"; +import { byteNumberAccess } from "./byte-numbers.js"; /** A canonical byte loop whose induction variable is mathematically an * unsigned integer for every body entry. Backends may keep this binding in @@ -47,7 +48,8 @@ function stableLoopValue(value: IrExpr, locals: ReadonlyMap, bo return (value.op === "+" || value.op === "-" || value.op === "&" || value.op === ">>>" || value.op === "|") && stableLoopValue(value.left, locals, body, invariant, counter) && stableLoopValue(value.right, locals, body, invariant, counter); case "bytesIntrinsic": case "arrIntrinsic": - return value.method === "length" && (value.kind === "bytesIntrinsic" || !invariant) && value.receiver.kind === "varRef" && + return (value.method === "length" || value.kind === "bytesIntrinsic" && value.method === "byteLength") && + (value.kind === "bytesIntrinsic" || !invariant) && value.receiver.kind === "varRef" && stableLoopValue(value.receiver, locals, body, invariant, counter); default: return false; } @@ -55,8 +57,9 @@ function stableLoopValue(value: IrExpr, locals: ReadonlyMap, bo /** Normalize finite loop progress into signed i64 storage. Unknown limits * are guarded before versioning small innermost loops; proven bounds need - * no duplicate body. A bounded step keeps the final update exactly - * representable, preserving the floating fallback at both 2^53 fixed points. */ + * no duplicate body. Every body entry must remain exactly representable. + * A final update may round beyond the limit: the scoped counter cannot be + * observed after that update, and both representations exit the loop. */ export function matchIntegerCountedForLoop( stmt: IrStmt & { kind: "for" }, locals: ReadonlyMap, ranges: IntegerRanges = new Map(), ): IntegerCountedForLoop | null { @@ -82,7 +85,7 @@ export function matchIntegerCountedForLoop( if (step > 0 ? op !== "<" && op !== "<=" : op !== ">" && op !== ">=") return null; if (!stableLoopValue(limit, locals, stmt.body, true, local.id)) return null; const inclusive = op === "<=" || op === ">="; - const guardLimit = step > 0 ? 2 ** 53 - step + (inclusive ? 0 : 1) : -(2 ** 53) - step - (inclusive ? 0 : 1); + const guardLimit = step > 0 ? 2 ** 53 - (inclusive ? 1 : 0) : -(2 ** 53) + (inclusive ? 1 : 0); const boundRange = limit.kind === "numLit" ? { min: limit.value, max: limit.value } : ranges.get(limit); const guarded = !boundRange || (step > 0 ? !(boundRange.max <= guardLimit) : !(boundRange.min >= guardLimit)); if (guarded && limit.kind === "numLit") return null; @@ -93,7 +96,11 @@ export function matchIntegerCountedForLoop( const expr = (e: IrExpr): boolean => { if (e.kind === "bin" && ((e.op === "&" || e.op === "|" || e.op === "^" || e.op === "<<" || e.op === ">>" || e.op === ">>>") && (counter(e.left) || counter(e.right)) || e.op === "%" && counter(e.left))) useful = true; - if (e.kind === "bytesIntrinsic" && e.method === "get" && e.args[0] && counter(e.args[0])) useful = true; + if (e.kind === "bytesIntrinsic") { + const numeric = byteNumberAccess(e); + const offset = e.args[numeric ? numeric.offsetArg : 0]; + if ((numeric || e.method === "get") && offset && counter(offset)) useful = true; + } if (e.kind === "libCall" && (e.fn === "math.imul" || e.fn === "math.clz32") && e.args.some(counter)) useful = true; return (!guarded || ++cost <= 80) && everyExprChild(e, expr, bodyStmt); }; @@ -104,8 +111,8 @@ export function matchIntegerCountedForLoop( }; if (!stmt.body.every(bodyStmt) || !useful) return null; const range = step > 0 - ? { min: startRange.min, max: Math.max(startRange.min, Math.min(2 ** 53 - step, boundRange ? Math.ceil(boundRange.max) - (inclusive ? 0 : 1) : Number.MAX_SAFE_INTEGER)) } - : { min: Math.min(startRange.max, Math.max(-(2 ** 53) - step, boundRange ? Math.floor(boundRange.min) + (inclusive ? 0 : 1) : -Number.MAX_SAFE_INTEGER)), max: startRange.max }; + ? { min: startRange.min, max: Math.max(startRange.min, Math.min(Number.MAX_SAFE_INTEGER, boundRange ? Math.ceil(boundRange.max) - (inclusive ? 0 : 1) : Number.MAX_SAFE_INTEGER)) } + : { min: Math.min(startRange.max, Math.max(-Number.MAX_SAFE_INTEGER, boundRange ? Math.floor(boundRange.min) + (inclusive ? 0 : 1) : -Number.MAX_SAFE_INTEGER)), max: startRange.max }; return { localId: local.id, start, limit, step, inclusive, guarded, guardLimit, range }; } diff --git a/packages/compiler/src/ir/integer-ranges.ts b/packages/compiler/src/ir/integer-ranges.ts index 03ed2b2af..9cb76ba3d 100644 --- a/packages/compiler/src/ir/integer-ranges.ts +++ b/packages/compiler/src/ir/integer-ranges.ts @@ -1,5 +1,6 @@ import type { IrExpr, IrFunction, IrStmt } from "./ir.js"; import { everyExprChild, everyStmtChild, everyStmtList } from "./traverse.js"; +import { byteNumberAccess, byteNumberRange } from "./byte-numbers.js"; /** Exactly representable integers, excluding negative zero. Facts describe * the evaluated value, never a later read of the same local binding. */ @@ -170,9 +171,11 @@ export function analyzeIntegerRanges(fn: IrFunction): IntegerRanges { } break; } - case "bytesIntrinsic": + case "bytesIntrinsic": { expr(e.receiver, facts); for (const arg of e.args) expr(arg, facts); - if (e.method === "length") range = { min: 0, max: Number.MAX_SAFE_INTEGER }; + const numeric = byteNumberAccess(e); + if (numeric && !numeric.write) range = byteNumberRange(numeric); + else if (e.method === "length" || e.method === "byteLength" || e.method === "byteOffset") range = { min: 0, max: Number.MAX_SAFE_INTEGER }; else if (e.method === "get" && e.receiver.type.kind === "bytes") { const elem = e.receiver.type.elem; if (elem === "u8" || elem === "u8c") range = { min: 0, max: 255 }; @@ -183,6 +186,7 @@ export function analyzeIntegerRanges(fn: IrFunction): IntegerRanges { else if (elem === "i32") range = SIGNED; } break; + } case "arrIntrinsic": // Callbacks and arguments can have writes hidden in lazy lowering. invalidate(facts, [e]); diff --git a/packages/compiler/src/ir/validate.test.ts b/packages/compiler/src/ir/validate.test.ts index 69596f144..8ea364416 100644 --- a/packages/compiler/src/ir/validate.test.ts +++ b/packages/compiler/src/ir/validate.test.ts @@ -183,6 +183,27 @@ function expressionModule(expr: IrExpr, unions: IrUnionDef[]): IrModule { }; } +test("numeric byte tokens and DataView stores validate their storage before emission", () => { + const bytes: IrExpr = { kind: "varRef", localId: "bytes", type: { kind: "bytes", elem: "u8" }, loc }; + const number: IrExpr = { kind: "numLit", value: 0, type: F64, loc }; + const kind: IrExpr = { kind: "strLit", value: "u32le", type: STRING, loc }; + const read: IrExpr = { kind: "bytesIntrinsic", method: "readNum", receiver: bytes, args: [kind, number], type: F64, loc }; + const mod = expressionModule(read, []); + mod.functions[0]!.locals.push({ id: "bytes", name: "bytes", type: bytes.type, mutable: false }); + expect(validateModule(deserializeModule(serializeModule(mod)))).toEqual([]); + for (const value of ["toString", "u64le", "u8be"]) { + kind.value = value; + expect(validateModule(mod).some((e) => e.message.includes("invalid kind token"))).toBe(true); + } + kind.value = "u32le"; + const store: IrExpr = { kind: "bytesIntrinsic", method: "dvSetUint32", receiver: bytes, args: [number, number], type: VOID, loc }; + mod.functions[0]!.body = [{ kind: "exprStmt", expr: store, loc }]; + expect(validateModule(mod)).toEqual([]); + bytes.type = { kind: "bytes", elem: "u32" }; + mod.functions[0]!.locals[0]!.type = bytes.type; + expect(validateModule(mod).some((e) => e.message.includes("u8 only"))).toBe(true); +}); + test("record declarations accept forward, mutual, and self references and refresh missing IDs", () => { const mod = expressionModule({ kind: "numLit", value: 0, type: F64, loc }, []); mod.records = [ diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 5edd8e80f..e3040a86b 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -3,6 +3,7 @@ * produce invalid IR), but it still carries the user's source location: * an ICE that points at source is a gift to whoever debugs it. */ +import { validByteNumberToken } from "./byte-numbers.js"; import type { IrClassDef, IrExpr, @@ -3326,10 +3327,10 @@ function validateFunction( break; } const recv = e.receiver.type; - // The dvGet* getters read through a DataView — always a bytes + // The DataView getters and setters use a bytes // view; dataViewNew and byteOffset take ANY elem kind (views form // over any typed array's storage, owners answer byteOffset 0). - const isDvGet = e.method.startsWith("dvGet"); + const isDv = e.method.startsWith("dvGet") || e.method.startsWith("dvSet"); const isNum = e.method === "readNum" || e.method === "writeNum" || e.method === "readNumVar" || e.method === "writeNumVar"; const U8_ONLY_EXTRA: ReadonlySet = new Set([ @@ -3337,7 +3338,7 @@ function validateFunction( "indexOfNum", "lastIndexOfNum", "includesNum", "fill", "fillNum", "fillStr", "copy", "swap16", "swap32", "swap64", "writeStr", ]); - const u8Only = U8_ONLY_EXTRA.has(e.method) || isNum || isDvGet; + const u8Only = U8_ONLY_EXTRA.has(e.method) || isNum || isDv; if (u8Only && recv.elem !== "u8") { err(`bytesIntrinsic ${e.method} on a ${recv.elem} receiver (u8 only)`, e.loc); } @@ -3346,6 +3347,8 @@ function validateFunction( // time; a runtime-valued kind has no meaning). if (isNum && e.args[0]?.kind !== "strLit") { err(`bytesIntrinsic ${e.method} args[0] must be a strLit kind token`, e.loc); + } else if (isNum && e.args[0]?.kind === "strLit" && !validByteNumberToken(e.method, e.args[0].value)) { + err(`bytesIntrinsic ${e.method} invalid kind token '${e.args[0].value}'`, e.loc); } // fillStr/writeStr carry their NORMALIZED encoding as args[1], // always a strLit (the frontend folds the aliases). diff --git a/packages/compiler/test/byte-number-emission.test.ts b/packages/compiler/test/byte-number-emission.test.ts new file mode 100644 index 000000000..bd5a0979c --- /dev/null +++ b/packages/compiler/test/byte-number-emission.test.ts @@ -0,0 +1,46 @@ +import { mkdtemp, readFile, rm } 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("numeric byte pipelines retain checked fallbacks and use field widths on both pointer sizes", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-byte-numbers-")); + try { + const entry = join(import.meta.dirname, "../../../tests/corpus/byte-number-pipeline.ts"); + const outPath = join(dir, "main.ir.json"); + 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 body = (ll: string, name: string): string => ll.match(new RegExp(`define internal [^\\n]+ @sc_(?:b)?f_${name}\\([^\\n]*\\) #0 \\{([\\s\\S]*?)\\n\\}`))![1]!; + for (const pointerBits of [32, 64] as const) { + const ll = emitLlvmModule(mod, { pointerBits, wasi: pointerBits === 32 }); + for (const name of ["bufferRecords", "viewRecords", "backwards"]) { + const code = body(ll, name); + expect(code).toContain("integer induction offset"); + expect(code).not.toContain("counted.slow"); + expect(code).not.toContain("@scr_bytes_retain"); + expect(code).toMatch(/load i32, ptr %\w+, align 1/); + expect(code).toMatch(/store i32 %\w+, ptr %\w+, align 1/); + expect(code).toContain("phi i32"); + } + // All fields lie inside the extent established by these loop heads. + for (const name of ["bufferRecords", "viewRecords"]) { + expect(body(ll, name)).not.toContain("bytes.number.index"); + expect(body(ll, name)).not.toMatch(/icmp uge i(?:32|64)/); + } + for (const name of ["shortTarget", "replacedBound"]) expect(body(ll, name)).toMatch(/icmp uge i(?:32|64)/); + const wide = body(ll, "wideFields"); + expect(wide).toMatch(/load i40, ptr %\w+, align 1/); + expect(wide).toMatch(/load i48, ptr %\w+, align 1/); + expect(wide).toContain("phi i64"); + expect(wide).toContain("@llvm.bswap.i64"); + const offsets = body(ll, "offsets"); + expect(offsets).toContain("@llvm.trunc.f64"); + expect(offsets).toContain("@scr_dataview_set"); + expect(offsets).toContain("@scr_dataview_get"); + expect(offsets).toMatch(/icmp ule i(?:32|64)/); + } + } finally { await rm(dir, { recursive: true, force: true }); } +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 0bee01328..31ee7f3fe 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -14228,6 +14228,12 @@ "/tests/corpus/collection-storage-copies.ts" ], "diags": [] + }, + "/tests/corpus/byte-number-pipeline.ts": { + "order": [ + "/tests/corpus/byte-number-pipeline.ts" + ], + "diags": [] } } } diff --git a/tests/corpus/byte-number-pipeline.ts b/tests/corpus/byte-number-pipeline.ts new file mode 100644 index 000000000..c9ee109cb --- /dev/null +++ b/tests/corpus/byte-number-pipeline.ts @@ -0,0 +1,104 @@ +function bufferRecords(bytes: Buffer): number { + let checksum = 0; + for (let offset = 0; offset <= bytes.length - 12; offset += 12) { + const id = bytes.readUInt32LE(offset); + const delta = bytes.readIntLE(offset + 4, 3); + const flags = bytes.readUInt8(offset + 7); + const value = bytes.readFloatLE(offset + 8); + bytes.writeUInt32BE((id + flags) >>> 0, offset); + bytes.writeIntBE(delta, offset + 4, 3); + bytes.writeFloatBE(value * 2, offset + 8); + checksum = Math.imul(checksum ^ id, 17) + delta; + } + return checksum; +} + +function viewRecords(view: DataView, little: boolean): number { + let checksum = 0; + for (let offset = 0; offset <= view.byteLength - 12; offset += 12) { + const id = view.getUint32(offset, little); + const delta = view.getInt16(offset + 4, little); + const flags = view.getUint16(offset + 6, little); + view.setUint32(offset, (id + flags) >>> 0, !little); + view.setInt16(offset + 4, delta, !little); + view.setFloat32(offset + 8, view.getFloat32(offset + 8, little) * 2, !little); + checksum = Math.imul(checksum ^ id, 17) + delta; + } + return checksum; +} + +function backwards(view: DataView): void { + for (let offset = view.byteLength - 4; offset >= 0; offset -= 4) { + view.setUint32(offset, view.getUint32(offset, true) ^ 0x80000000); + } +} + +const storage = Buffer.alloc(51, 0x5a); +const window = storage.subarray(1, 49); +for (let i = 0; i < 4; i++) { + const offset = i * 12; + window.writeUInt32LE(0xffffffff - i, offset); + window.writeIntLE(-8388608 + i, offset + 4, 3); + window.writeUInt8(i, offset + 7); + window.writeFloatLE(i === 0 ? -0 : i / 4, offset + 8); +} +console.log("buffer", bufferRecords(window), storage.toString("hex")); +const view = new DataView(storage.buffer, storage.byteOffset + 1, 48); +console.log("view", viewRecords(view, false), viewRecords(view, true)); +backwards(view); +console.log("reverse", storage.toString("hex")); +console.log("empty", bufferRecords(Buffer.alloc(0)), viewRecords(new DataView(new ArrayBuffer(0)), true)); + +function wideFields(bytes: Buffer): number { + bytes.writeIntBE(-549755813887, 1, 5); + const signed = bytes.readIntBE(1, 5); + bytes.writeUIntLE(281474976710655, 6, 6); + const unsigned = bytes.readUIntLE(6, 6); + bytes.writeIntLE(signed, 12, 5); + bytes.writeUIntBE(unsigned, 17, 6); + return signed + unsigned; +} +const wide = Buffer.alloc(24, 0x33); +console.log("wide", wideFields(wide), wide.toString("hex")); + +// A valid bound for one receiver does not prove a different view's extent. +function shortTarget(source: Buffer, target: Buffer): void { + for (let offset = 0; offset <= source.length - 4; offset += 4) { + target.writeUInt32BE(source.readUInt32LE(offset), offset); + } +} +try { shortTarget(Buffer.alloc(8, 1), Buffer.alloc(5)); } +catch (error) { if (error instanceof Error) console.log("short", error.name, error.message); } + +function replacedBound(input: Buffer): number { + const length = input.length; + input = Buffer.alloc(2); + let total = 0; + for (let offset = 0; offset <= length - 4; offset += 4) total += input.readUInt32LE(offset); + return total; +} +try { console.log(replacedBound(Buffer.alloc(8))); } +catch (error) { if (error instanceof Error) console.log("replaced", error.name, error.message); } + +let current = Buffer.alloc(4, 0x11); +function replaceBuffer(): number { current = Buffer.alloc(4, 0x22); return 0; } +console.log("snapshot", current.readUInt32LE(replaceBuffer()), current.readUInt32LE(0)); +let currentView = new DataView(new ArrayBuffer(4)); +const oldView = currentView; +function replaceView(): number { currentView = new DataView(new ArrayBuffer(4)); return 0x12345678; } +oldView.setUint32(0, 1); +currentView.setUint32(0, replaceView()); +console.log("view-snapshot", oldView.getUint32(0), currentView.getUint32(0)); + +function offsets(view: DataView, offset: number, value: number): void { + view.setInt16(offset, value, true); + console.log("offset", offset, view.getInt16(offset, true)); +} +for (const offset of [NaN, -0, -0.75, 1.75, 2, 2.75, -1, Infinity, 4294967296, 9007199254740992]) { + try { offsets(currentView, offset, 4294967295.75); } + catch (error) { if (error instanceof Error) console.log("offset-error", error.name, error.message); } +} + +// The value error still precedes the offset error on the checked path. +try { current.writeUInt16LE(-1, NaN); } +catch (error) { if (error instanceof Error) console.log("order", error.name, error.message); } diff --git a/tests/corpus/counted-loop-numbers.ts b/tests/corpus/counted-loop-numbers.ts index 03c4cefff..bf7fb8fa1 100644 --- a/tests/corpus/counted-loop-numbers.ts +++ b/tests/corpus/counted-loop-numbers.ts @@ -62,3 +62,24 @@ function remainders(limit: number): void { for (let i = 0; i < limit; i++) console.log("remainders", i % 0, i % -3, i % 1.5, i % Infinity, (i % 17) % 3); } remainders(4); + +// The last update may round outside the exact range, but it must leave the +// loop before that rounded counter can be observed by the body. +function stridedEdges(limit: number): void { + let visits = 0; + for (let i = 9007199254740987; i < limit; i += 3) { + console.log("stride-up", i, i >>> 0); + if (++visits === 5) break; + } + visits = 0; + for (let i = -9007199254740987; i > -limit; i -= 3) { + console.log("stride-down", i, i >>> 0); + if (++visits === 5) break; + } + visits = 0; + for (let i = 9007199254740987; i <= limit; i += 3) { + console.log("stride-inclusive", i, i >>> 0); + if (++visits === 5) break; + } +} +for (const limit of [9007199254740990, 9007199254740991, 9007199254740992, 9007199254740994, Infinity, NaN]) stridedEdges(limit); diff --git a/tests/harness/self-hosting-integer-ranges.test.ts b/tests/harness/self-hosting-integer-ranges.test.ts index aea066338..a696bd972 100644 --- a/tests/harness/self-hosting-integer-ranges.test.ts +++ b/tests/harness/self-hosting-integer-ranges.test.ts @@ -59,7 +59,7 @@ for (const backend of ["llvm"] as const) { // Real frontend IR exercises the exact representation the two emitters // supply to analyzeIntegerRanges, including lowered helper functions. - for (const source of ["1409-typedarray-integer-loops.ts", "3103-scalar-record-nested-control-flow.ts", "3109-identity-union-collections.ts"]) { + for (const source of ["1409-typedarray-integer-loops.ts", "3103-scalar-record-nested-control-flow.ts", "3109-identity-union-collections.ts", "byte-number-pipeline.ts"]) { const path = join(dir, "frontend.json"); const result = await compile(join(root, "tests/corpus", source), { outDir: dir, outPath: path, outputKind: "ir", dynamic: false }); if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n")); diff --git a/tests/harness/self-hosting-validator-cases.ts b/tests/harness/self-hosting-validator-cases.ts index 6d2cad7cb..4eedd1acd 100644 --- a/tests/harness/self-hosting-validator-cases.ts +++ b/tests/harness/self-hosting-validator-cases.ts @@ -26,6 +26,15 @@ export function validatorCases(): ValidatorCase[] { cases.push({ name, module, ...(diagnostic ? { diagnostic } : {}) }); }; add("empty module", () => {}); + for (const variant of ["valid", "token", "storage"]) { + add(`numeric byte ${variant}`, (m) => { + const bytes: IrType = { kind: "bytes", elem: variant === "storage" ? "u32" : "u8" }; + m.functions[0]!.locals = [{ id: "bytes", name: "bytes", type: bytes, mutable: false }]; + m.functions[0]!.body = [expression(variant === "storage" + ? { kind: "bytesIntrinsic", method: "dvSetUint32", receiver: varRef("bytes", bytes, loc), args: [numLit(0, loc), numLit(1, loc)], type: VOID, loc } + : { kind: "bytesIntrinsic", method: "readNum", receiver: varRef("bytes", bytes, loc), args: [strLit(variant === "token" ? "toString" : "u32le", loc), numLit(0, loc)], type: F64, loc })]; + }, variant === "token" ? "invalid kind token" : variant === "storage" ? "u8 only" : undefined); + } for (const direction of ["left", "right", "alternating"]) { for (const invalid of [false, true]) { add(`deep logical ${direction} ${invalid ? "invalid" : "valid"}`, (m) => { diff --git a/tests/harness/self-hosting-validator.test.ts b/tests/harness/self-hosting-validator.test.ts index f49af2c1f..2daffc32b 100644 --- a/tests/harness/self-hosting-validator.test.ts +++ b/tests/harness/self-hosting-validator.test.ts @@ -51,6 +51,7 @@ for (const backend of ["llvm"] as const) { "tests/corpus/3109-identity-union-collections.ts", "tests/corpus/collection-value-keys.ts", "tests/corpus/collection-storage-copies.ts", + "tests/corpus/byte-number-pipeline.ts", "tests/corpus/nullish-long-chain.ts", ]) { const irPath = join(dir, "emitted.json"); diff --git a/tests/harness/wasi-differential.test.ts b/tests/harness/wasi-differential.test.ts index 13209f8d5..53860360b 100644 --- a/tests/harness/wasi-differential.test.ts +++ b/tests/harness/wasi-differential.test.ts @@ -162,6 +162,9 @@ describe.skipIf(!zigOnPath())("wasm32-wasi differential", () => { "1475-readline-closed-stdin.ts", "1400-typedarray-basics.ts", "1409-typedarray-integer-loops.ts", + "1660-buffer-read-write-num.ts", + "byte-number-pipeline.ts", + "counted-loop-numbers.ts", "1551-dyn-receiver-methods.ts", "1558-any-joins-and-dyn-validation.ts", "1634-inspect-classes.ts",