From 142e10a0c428065a37eac0228b3a2b83fef5694f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 08:55:08 -0500 Subject: [PATCH 1/2] Run counters that step beside a counted loop's counter as integers `for (let i = 0, p = 0; i < count; i++, p += 4)` kept both counters as doubles: the counted-loop match takes a single declaration and a single update. A counted loop now also accepts further counters declared in its head and stepped by a constant in its update, when the condition does not read them and the body does not write them. On body entry k such a counter holds `start + step * k`, so its range follows from the driver's: k is at most the driver's span over its step. When that range can pass 2^53, the versioned loop tests `|start| + |step| * span / |driver step| <= 2^52` at run time, and an unproven start is tested for an exact safe integer, as the driver's is. Nested update blocks (`i++, j += 2, k--`) are flattened. Byte windows and dense loops still take only single-counter loops. --- .../src/backend/llvm/counted-loops.ts | 45 ++++- .../backend/llvm/dense-array-loops.test.ts | 1 + .../src/backend/llvm/emit-statements.ts | 36 +++- .../compiler/src/ir/integer-loops.test.ts | 100 ++++++++++ packages/compiler/src/ir/integer-loops.ts | 188 +++++++++++++----- .../7141-comoving-loop-counters.ts.json | 6 + tests/corpus/7141-comoving-loop-counters.ts | 144 ++++++++++++++ .../7141-comoving-loop-counters.ts.json | 1 + 8 files changed, 467 insertions(+), 54 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7141-comoving-loop-counters.ts.json create mode 100644 tests/corpus/7141-comoving-loop-counters.ts create mode 100644 tests/harness/corpus-costs/7141-comoving-loop-counters.ts.json diff --git a/packages/compiler/src/backend/llvm/counted-loops.ts b/packages/compiler/src/backend/llvm/counted-loops.ts index 02943b852..ca2dd6b92 100644 --- a/packages/compiler/src/backend/llvm/counted-loops.ts +++ b/packages/compiler/src/backend/llvm/counted-loops.ts @@ -1,4 +1,9 @@ -import type { IntegerCountedForLoop, IntegerPairedForLoop } from "../../ir/integer-loops.js"; +import type { + IntegerCountedForLoop, + IntegerLoopFollower, + IntegerPairedForLoop, +} from "../../ir/integer-loops.js"; +import { FOLLOWER_GUARD_LIMIT } from "../../ir/integer-loops.js"; import type { IrExpr } from "../../ir/ir.js"; import type { LlvmEmitterContext } from "./expr-context.js"; import { f64Lit } from "./common.js"; @@ -61,9 +66,47 @@ export function emitCountedLoopSafety( ); } if (loop.startGuarded) safe = andSafe(host, safe, emitExactStart(host, loop.start)); + for (const follower of loop.followers) { + if (follower.startGuarded) safe = andSafe(host, safe, emitExactStart(host, follower.start)); + if (follower.tripGuarded) safe = andSafe(host, safe, emitFollowerReach(host, loop, follower)); + } return safe; } +/** `|start| + |step| * span / |driver step| <= 2^52` for a follower, where + * `span` is the distance from the driver's start to the limit. The body runs + * at most `span / |driver step| + 1` times, so every value the follower holds + * on a body entry is within the bound. NaN and infinities fail the ordered + * test. */ +function emitFollowerReach( + host: LlvmEmitterContext, + loop: IntegerCountedForLoop, + follower: IntegerLoopFollower, +): string { + const B = host.B; + const start = host.emitExpr(loop.start), + limit = host.emitExpr(loop.limit), + from = host.emitExpr(follower.start); + const span = B.tmp(), + reach = B.tmp(), + magnitude = B.tmp(), + total = B.tmp(), + ok = B.tmp(); + B.line( + loop.step > 0 + ? `${span} = fsub double ${limit.name}, ${start.name}` + : `${span} = fsub double ${start.name}, ${limit.name}`, + ); + B.line( + `${reach} = fmul double ${span}, ${f64Lit(Math.abs(follower.step) / Math.abs(loop.step))}`, + ); + host.declare(`declare double @llvm.fabs.f64(double)`); + B.line(`${magnitude} = call double @llvm.fabs.f64(double ${from.name})`); + B.line(`${total} = fadd double ${magnitude}, ${reach}`); + B.line(`${ok} = fcmp ole double ${total}, ${f64Lit(FOLLOWER_GUARD_LIMIT)}`); + return ok; +} + /** The run-time test of a guarded paired loop: each unproven start is an * exact safe integer. */ export function emitPairedLoopSafety(host: LlvmEmitterContext, loop: IntegerPairedForLoop): string { diff --git a/packages/compiler/src/backend/llvm/dense-array-loops.test.ts b/packages/compiler/src/backend/llvm/dense-array-loops.test.ts index 32c9cccab..f7a4bc892 100644 --- a/packages/compiler/src/backend/llvm/dense-array-loops.test.ts +++ b/packages/compiler/src/backend/llvm/dense-array-loops.test.ts @@ -120,6 +120,7 @@ const counted = ( limitGuarded: false, startGuarded: false, guardLimit: 0, + followers: [], ...fields, }); diff --git a/packages/compiler/src/backend/llvm/emit-statements.ts b/packages/compiler/src/backend/llvm/emit-statements.ts index 7239db7d8..c493f6c9c 100644 --- a/packages/compiler/src/backend/llvm/emit-statements.ts +++ b/packages/compiler/src/backend/llvm/emit-statements.ts @@ -879,8 +879,9 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { !integerLoop && !countedLoop && emitter.countedLoopsEnabled ? matchIntegerPairedForLoop(s, emitter.numericLocals, emitter.integerRanges) : null; + // Byte windows and dense loops reason about one counter. const window = - countedLoop && emitter.byteWindowsEnabled + countedLoop && countedLoop.followers.length === 0 && emitter.byteWindowsEnabled ? matchByteWindow( s, countedLoop, @@ -898,7 +899,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { length: integerArrayLoop.limitReceiver as IrExpr & { kind: "varRef" }, range: { min: 0, max: 4294967294 }, } - : countedLoop && !countedLoop.guarded + : countedLoop && !countedLoop.guarded && countedLoop.followers.length === 0 ? countedDenseShape(s, countedLoop) : null; const dense = @@ -1030,6 +1031,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { let integerSlot: string | null = null; let countedLimit: string | null = null; const pairedSlots: string[] = []; + const followerSlots: string[] = []; if (integerLoop) { integerSlot = B.slot(); B.entryAllocas.push( @@ -1073,6 +1075,23 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { ? emitter.denseLoop.range : countedLoop.range, }); + for (const follower of countedLoop.followers) { + const slot = B.slot(); + B.entryAllocas.push( + `${slot} = alloca i64 ; integer induction ${emitter.currentLocals.get(follower.localId)!.name}`, + ); + const value = emitter.emitExpr(follower.start); + const followerStart = B.tmp(); + B.line(`${followerStart} = fptosi double ${value.name} to i64`); + B.line(`store i64 ${followerStart}, ptr ${slot}`); + followerSlots.push(slot); + emitter.integerLoopBindings.set(follower.localId, { + slot, + type: "i64", + signed: true, + range: follower.range, + }); + } countedLimit = emitCountedLoopLimit(emitter, countedLoop); } else if (pairedLoop) { for (const counter of [pairedLoop.low, pairedLoop.high]) { @@ -1212,6 +1231,13 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { B.line(`${old} = load i64, ptr ${integerSlot}`); B.line(`${next} = add nsw i64 ${old}, ${countedLoop.step}`); B.line(`store i64 ${next}, ptr ${integerSlot}`); + countedLoop.followers.forEach((follower, i) => { + const was = B.tmp(); + const now = B.tmp(); + B.line(`${was} = load i64, ptr ${followerSlots[i]}`); + B.line(`${now} = add nsw i64 ${was}, ${follower.step}`); + B.line(`store i64 ${now}, ptr ${followerSlots[i]}`); + }); } else if (pairedLoop) { [pairedLoop.low, pairedLoop.high].forEach((counter, i) => { const old = B.tmp(); @@ -1227,7 +1253,11 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { B.startBlock(le); emitter.releaseScope(emitter.scopes.pop()!); if (integerLoop) emitter.integerLoopBindings.delete(integerLoop.localId); - if (countedLoop) emitter.integerLoopBindings.delete(countedLoop.localId); + if (countedLoop) { + emitter.integerLoopBindings.delete(countedLoop.localId); + for (const follower of countedLoop.followers) + emitter.integerLoopBindings.delete(follower.localId); + } if (pairedLoop) { emitter.integerLoopBindings.delete(pairedLoop.low.localId); emitter.integerLoopBindings.delete(pairedLoop.high.localId); diff --git a/packages/compiler/src/ir/integer-loops.test.ts b/packages/compiler/src/ir/integer-loops.test.ts index 24bbf5be0..e1453abad 100644 --- a/packages/compiler/src/ir/integer-loops.test.ts +++ b/packages/compiler/src/ir/integer-loops.test.ts @@ -426,3 +426,103 @@ test("paired counters closing in on each other run as integers", () => { unused.body = []; expect(matchIntegerPairedForLoop(unused, vars)).toBeNull(); }); + +test("counters stepped beside the tested one run as integers", () => { + const vars = new Map(locals); + for (const id of ["j", "k", "limit", "from"]) + vars.set(id, { id, name: id, type: F64, mutable: true }); + vars.set("s", { id: "s", name: "s", type: STRING, mutable: false }); + const step = (id: string, op: "+" | "-", value = 1): IrStmt => ({ + kind: "assign", + localId: id, + value: { kind: "bin", op, left: ref(id), right: number(value), type: F64, loc }, + loc, + }); + const read = (index: IrExpr): IrStmt => ({ + kind: "exprStmt", + expr: { + kind: "strIntrinsic", + method: "charCodeAt", + receiver: { kind: "varRef", localId: "s", type: STRING, loc }, + args: [index], + type: F64, + loc, + }, + loc, + }); + const comoving = ( + limit: IrExpr, + followerStart: IrExpr = number(0), + update: IrStmt = { kind: "block", body: [step("i", "+"), step("j", "+", 4)], loc }, + ) => + ({ + kind: "for", + loc, + init: { + kind: "block", + body: [ + { kind: "varDecl", localId: "i", init: number(0), loc }, + { kind: "varDecl", localId: "j", init: followerStart, loc }, + ], + loc, + }, + cond: { kind: "bin", op: "<", left: ref("i"), right: limit, type: BOOL, loc }, + update, + body: [read(ref("j"))], + }) as IrStmt & { kind: "for" }; + // A literal limit bounds the follower: at most 9 updates of 4 before the + // last body entry. + expect(matchIntegerCountedForLoop(comoving(number(10)), vars)).toMatchObject({ + localId: "i", + guarded: false, + range: { min: 0, max: 9 }, + followers: [ + { + localId: "j", + step: 4, + startGuarded: false, + tripGuarded: false, + range: { min: 0, max: 36 }, + }, + ], + }); + // An unknown limit or start is tested at run time. + expect(matchIntegerCountedForLoop(comoving(ref("limit")), vars)).toMatchObject({ + guarded: true, + followers: [{ tripGuarded: true, range: { min: 0, max: 2 ** 52 + 2 } }], + }); + expect(matchIntegerCountedForLoop(comoving(number(10), ref("from")), vars)).toMatchObject({ + guarded: true, + followers: [{ startGuarded: true, tripGuarded: true }], + }); + // A literal limit with known starts that can pass 2^53 is refused. + expect(matchIntegerCountedForLoop(comoving(number(10), number(2 ** 53 - 9)), vars)).toBeNull(); + // Nested update blocks (`i++, j += 4, k--`) step three counters. + const three = comoving(number(10), number(0), { + kind: "block", + body: [{ kind: "block", body: [step("i", "+"), step("j", "+", 4)], loc }, step("k", "-")], + loc, + }); + if (three.init?.kind === "block") + three.init.body.push({ kind: "varDecl", localId: "k", init: number(5), loc }); + expect(matchIntegerCountedForLoop(three, vars)?.followers).toMatchObject([ + { localId: "j", range: { min: 0, max: 36 } }, + { localId: "k", step: -1, range: { min: -4, max: 5 } }, + ]); + // A follower without a step, one the body writes, one the limit reads, a + // captured one, and a start that reads another counter are refused. + expect( + matchIntegerCountedForLoop( + comoving(number(10), number(0), { kind: "block", body: [step("i", "+")], loc }), + vars, + ), + ).toBeNull(); + const writing = comoving(number(10)); + writing.body.push(step("j", "+")); + expect(matchIntegerCountedForLoop(writing, vars)).toBeNull(); + expect(matchIntegerCountedForLoop(comoving(ref("j")), vars)).toBeNull(); + const boxed = new Map(vars); + boxed.set("j", { ...vars.get("j")!, boxed: true }); + expect(matchIntegerCountedForLoop(comoving(number(10)), boxed)).toBeNull(); + expect(matchIntegerCountedForLoop(comoving(number(10), ref("i")), vars)).toBeNull(); +}); diff --git a/packages/compiler/src/ir/integer-loops.ts b/packages/compiler/src/ir/integer-loops.ts index e80680d6e..c4cdc327c 100644 --- a/packages/compiler/src/ir/integer-loops.ts +++ b/packages/compiler/src/ir/integer-loops.ts @@ -28,8 +28,36 @@ export interface IntegerCountedForLoop { startGuarded: boolean; guardLimit: number; range: IntegerRange; + /** Counters declared and stepped beside this one that the condition does + * not test, such as `j` in `for (let i = 0, j = 0; i < n; i++, j += 4)`. */ + followers: IntegerLoopFollower[]; } +/** A counter that steps by a constant on every update of a counted loop. + * After `k` updates it holds `start + step * k`. */ +export interface IntegerLoopFollower { + localId: string; + start: IrExpr; + step: number; + /** As the driver's `startGuarded` (part of the loop's `guarded`). */ + startGuarded: boolean; + /** The follower's range depends on the run-time trip count: the versioned + * loop also tests `|start| + |step| * span / |driver step|` against + * `FOLLOWER_GUARD_LIMIT`, where `span` is the distance from the driver's + * start to the limit (part of the loop's `guarded`). */ + tripGuarded: boolean; + /** The follower's values on every body entry. */ + range: IntegerRange; +} + +/** The bound of the run-time follower test. */ +export const FOLLOWER_GUARD_LIMIT = 2 ** 52; +/** The magnitude a follower that passed the test can reach. The test's four + * f64 operations (the step ratio, the span, its product and the sum) each + * round down by at most a factor of 1 - 2^-53, so the exact sum is below + * `2^52 / (1 - 2^-53)^4`, which is less than 2^52 + 3. */ +const FOLLOWER_GUARD_RANGE = FOLLOWER_GUARD_LIMIT + 2; + function constantStep(update: IrStmt | null, id: string): number | null { if (update?.kind === "exprStmt" && update.expr.kind === "incDec" && update.expr.localId === id) return update.expr.op === "+" ? 1 : -1; @@ -115,33 +143,61 @@ export function matchIntegerCountedForLoop( ranges: IntegerRanges = new Map(), unaliased: ReadonlySet | null = null, ): IntegerCountedForLoop | null { - const init = stmt.init; - if (init?.kind !== "varDecl" || !init.init) return null; - const local = locals.get(init.localId); - if (local?.type.kind !== "f64" || !local.mutable || local.boxed || local.tdz) return null; - const start = init.init; - let startRange = - start.kind === "numLit" && Number.isSafeInteger(start.value) && !Object.is(start.value, -0) - ? { min: start.value, max: start.value } - : ranges.get(start); - // An unproven start is tested at run time, on a versioned loop. - const startGuarded = !startRange && start.kind !== "numLit" && start.type.kind === "f64"; - if (startGuarded) startRange = { min: -Number.MAX_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER }; - if ( - !startRange || - startRange.min < -Number.MAX_SAFE_INTEGER || - startRange.max > Number.MAX_SAFE_INTEGER || - !stableLoopValue(start, locals, stmt.body, false, local.id) - ) - return null; - const step = constantStep(stmt.update, local.id); - if (step === null || writesLocal(stmt.body, local.id)) return null; const cond = stmt.cond; if (cond?.kind !== "bin") return null; + const decls = stmt.init?.kind === "block" ? stmt.init.body : stmt.init ? [stmt.init] : []; + // `i++, j += 2, k--` lowers to nested blocks of steps. + const flat = (u: IrStmt | null): (IrStmt | null)[] => + u?.kind === "block" ? u.body.flatMap(flat) : [u]; + const updates = flat(stmt.update); + if (decls.length === 0 || updates.length !== decls.length) return null; + const declared = (e: IrExpr): string | null => + e.kind === "varRef" && decls.some((d) => d.kind === "varDecl" && d.localId === e.localId) + ? e.localId + : null; + // The condition tests the driving counter; every other declaration + // follows it. + const driver = declared(cond.left) ?? declared(cond.right); + const counterIds = decls.map((d) => (d.kind === "varDecl" ? d.localId : "")); + const stepOf = (id: string): number | null => { + const steps = updates.map((u) => constantStep(u, id)).filter((v) => v !== null); + return steps.length === 1 ? steps[0]! : null; + }; + // A counter's start is a stable integer that refers to no counter. An + // unproven start is tested at run time, on a versioned loop. + const counterStart = ( + id: string, + ): { start: IrExpr; range: IntegerRange; guarded: boolean; step: number } | null => { + const decl = decls.find((d) => d.kind === "varDecl" && d.localId === id); + if (decl?.kind !== "varDecl" || !decl.init) return null; + const local = locals.get(id); + if (local?.type.kind !== "f64" || !local.mutable || local.boxed || local.tdz) return null; + const start = decl.init; + const known = + start.kind === "numLit" && Number.isSafeInteger(start.value) && !Object.is(start.value, -0) + ? { min: start.value, max: start.value } + : ranges.get(start); + const guarded = !known && start.kind !== "numLit" && start.type.kind === "f64"; + const range = guarded ? { min: -Number.MAX_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER } : known; + const step = stepOf(id); + if ( + !range || + range.min < -Number.MAX_SAFE_INTEGER || + range.max > Number.MAX_SAFE_INTEGER || + !counterIds.every((counter) => stableLoopValue(start, locals, stmt.body, false, counter)) || + step === null || + writesLocal(stmt.body, id) + ) + return null; + return { start, range, guarded, step }; + }; + const head = driver === null ? null : counterStart(driver); + if (!head) return null; + const local = locals.get(driver!)!; + const { start, step, guarded: startGuarded, range: startRange } = head; let limit = cond.right, op: string = cond.op; if (cond.left.kind !== "varRef" || cond.left.localId !== local.id) { - if (cond.right.kind !== "varRef" || cond.right.localId !== local.id) return null; limit = cond.left; op = ({ "<": ">", "<=": ">=", ">": "<", ">=": "<=" } as Record)[op] ?? ""; } @@ -185,11 +241,68 @@ export function matchIntegerCountedForLoop( const limitGuarded = !boundRange || (step > 0 ? !(boundRange.max <= guardLimit) : !(boundRange.min >= guardLimit)); if (limitGuarded && limit.kind === "numLit") return null; - const guarded = limitGuarded || startGuarded; + const range = + step > 0 + ? { + 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, + }; + // On body entry k the driver holds `start + step * k`, so k is at most the + // driver's span over |step|, and a follower holds its start plus its own + // step times k. + const trips = Math.floor( + (step > 0 ? range.max - startRange.min : startRange.max - range.min) / Math.abs(step), + ); + const followers: IntegerLoopFollower[] = []; + for (const id of counterIds) { + if (id === local.id) continue; + const follower = counterStart(id); + // The limit is evaluated once; a follower changes on every update. + if (!follower || !stableLoopValue(limit, locals, stmt.body, false, id)) return null; + const reach = follower.step * trips; + const min = follower.range.min + Math.min(0, reach), + max = follower.range.max + Math.max(0, reach); + const tripGuarded = min < -Number.MAX_SAFE_INTEGER || max > Number.MAX_SAFE_INTEGER; + // Known starts and a literal limit are not tested at run time. + if (tripGuarded && !follower.guarded && !startGuarded && limit.kind === "numLit") return null; + followers.push({ + localId: id, + start: follower.start, + step: follower.step, + startGuarded: follower.guarded, + tripGuarded, + range: tripGuarded + ? { min: Math.max(-FOLLOWER_GUARD_RANGE, min), max: Math.min(FOLLOWER_GUARD_RANGE, max) } + : { min, max }, + }); + } + const guarded = + limitGuarded || startGuarded || followers.some((f) => f.startGuarded || f.tripGuarded); let useful = false; let cost = 0; const counter = (e: IrExpr): boolean => - (e.kind === "varRef" && e.localId === local.id) || + (e.kind === "varRef" && + (e.localId === local.id || followers.some((f) => f.localId === e.localId))) || (e.kind === "bin" && (e.op === "+" || e.op === "-" || e.op === "*") && (counter(e.left) || counter(e.right))); @@ -241,32 +354,6 @@ export function matchIntegerCountedForLoop( return (!guarded || ++cost <= 80) && everyStmtChild(s, expr, bodyStmt); }; if (!stmt.body.every(bodyStmt) || !useful) return null; - const range = - step > 0 - ? { - 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, @@ -278,6 +365,7 @@ export function matchIntegerCountedForLoop( startGuarded, guardLimit, range, + followers, }; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7141-comoving-loop-counters.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7141-comoving-loop-counters.ts.json new file mode 100644 index 000000000..392c9f10d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7141-comoving-loop-counters.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7141-comoving-loop-counters.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7141-comoving-loop-counters.ts b/tests/corpus/7141-comoving-loop-counters.ts new file mode 100644 index 000000000..a756b7a46 --- /dev/null +++ b/tests/corpus/7141-comoving-loop-counters.ts @@ -0,0 +1,144 @@ +// For loops with a second counter that steps beside the tested one +// (`i++, j += 4`): pixel strides over typed arrays, interleaving into number +// arrays, descending and inclusive loops, three counters, early exits, +// starts and limits that are fractions, negative zero, NaN, infinite or +// near 2^53, counters that live across a yield or an await, and counters a +// closure captures, the body writes or the condition reads. +function luminance(px: Uint8Array, count: number): number[] { + const out: number[] = []; + for (let i = 0, p = 0; i < count; i++, p += 4) { + out.push((px[p]! * 77 + px[p + 1]! * 150 + px[p + 2]! * 29) >> 8); + } + return out; +} +const px = new Uint8Array(32); +for (let k = 0; k < px.length; k++) px[k] = (k * 37 + 11) & 255; +console.log(luminance(px, 8).join(",")); +// Past the end, reads are undefined and the sums NaN, which `>> 8` makes 0. +console.log(luminance(px, 10).join(",")); +console.log(luminance(px, 0).length, luminance(px, -3).length, luminance(px, 2.5).join(",")); +console.log(luminance(px, NaN).length); + +function interleave(a: number[], b: number[]): number[] { + const out: number[] = new Array(a.length * 2).fill(-1); + const n = a.length; + for (let i = 0, j = 0; i < n; i++, j += 2) { + out[j] = a[i]!; + out[j + 1] = b[i]!; + } + return out; +} +console.log(interleave([1, 2, 3, 4], [10, 20, 30, 40]).join(",")); +console.log(interleave([], []).length); + +// The second counter's start is not proven: charCodeAt truncates its index, +// and the counter's own values keep JS semantics. +function codes(s: string, count: number, from: number, step: number): string { + const out: (number | string)[] = []; + if (step === 3) { + for (let i = 0, j = from; i < count; i++, j += 3) out.push(s.charCodeAt(j), j); + } else { + for (let i = 0, j = from; i < count; i++, j -= 2) out.push(s.charCodeAt(j), j); + } + return out.join(","); +} +const word = "abcdefghijklmnop"; +console.log(codes(word, 4, 0, 3), "|", codes(word, 4, 15, -2)); +console.log(codes(word, 4, 0.5, 3), "|", codes(word, 3, 9.75, -2)); +const negativeZero = -0; +console.log(codes(word, 2, negativeZero, 3), Object.is(negativeZero, -0)); +console.log(codes(word, 2, NaN, 3), "|", codes(word, 2, Infinity, -2), "|", codes(word, 2, -Infinity, 3)); +// Near 2^52 the run-time test passes or fails on the trip count; near 2^53 +// the double counter rounds (odd values above 2^53 are not representable). +console.log(codes(word, 4, 2 ** 52 - 16, 3)); +console.log(codes(word, 6, 2 ** 52 - 4, 3)); +console.log(codes(word, 6, 2 ** 53 - 10, 3)); +console.log(codes(word, 4, -(2 ** 53) + 3, -2)); +// A limit that is not finite or not an integer. +function capped(s: string, limit: number): string { + const out: number[] = []; + for (let i = 0, j = 1; i < limit; i++, j += 2) { + if (j >= s.length) break; + out.push(s.charCodeAt(j)); + } + return out.join(","); +} +console.log(capped(word, Infinity), "|", capped(word, 3.5), "|", capped(word, NaN), "|", capped(word, -Infinity)); + +// A descending driver with an ascending follower, inclusive bounds and +// larger steps. +function mirror(s: string): string { + const out: string[] = []; + for (let i = s.length - 1, j = 0; i >= 0; i--, j += 2) out.push(`${s.charCodeAt(i)}@${j}`); + return out.join(" "); +} +console.log(mirror("scriptc")); +function stepped(bytes: Uint8Array, last: number): string { + const out: number[] = []; + for (let i = 0, j = bytes.length - 1; i <= last; i += 3, j -= 5) + out.push(bytes[i]! | 0, bytes[j]! | 0, j); + return out.join(","); +} +console.log(stepped(px, 9), "|", stepped(px, 30)); + +// Three counters, continue and break. +function triple(a: Uint8Array, n: number): string { + const out: number[] = []; + for (let i = 0, j = 10, k = 100; i < n; i++, j -= 1, k += 7) { + if (i % 3 === 1) continue; + if (k > 150) break; + out.push(a[i]!, a[j]!, k); + } + return out.join(","); +} +console.log(triple(px, 12), "|", triple(px, 4)); + +// A counted limit with a known range and a follower used by a remainder. +let rotated = 0; +for (let i = 0, j = 5; i < 1000; i++, j += 3) rotated = (rotated * 31 + px[j % 32]!) % 1000003; +console.log(rotated); + +// The follower starting from a driver's start of unproven exactness. +function window(bytes: Uint8Array, from: number, to: number): string { + const out: number[] = []; + for (let i = from, j = 0; i < to; i++, j += 2) out.push(bytes[i]! | 0, bytes[j]! | 0, i, j); + return out.join(","); +} +console.log(window(px, 2, 5), "|", window(px, 1.5, 4), "|", window(px, -0, 2)); + +// Counters a closure captures keep one binding per iteration; a follower the +// body writes, one the condition reads, and a start that reads another +// counter keep their JS values. +const reads: (() => number)[] = []; +for (let i = 0, j = 0; i < 3; i++, j += 4) { + reads.push(() => j); + void px[j]; +} +console.log(reads.map((read) => read()).join(",")); +const written: number[] = []; +for (let i = 0, j = 0; i < 5; i++, j += 2) { + if (i === 2) j += 7; + written.push(px[j]!, j); +} +console.log(written.join(",")); +const chased: number[] = []; +for (let i = 0, j = 5; i < j; i += 2, j++) chased.push(px[i]!, i, j); +console.log(chased.join(",")); +const offset: number[] = []; +for (let i = 1, j = i + 1; i < 4; i++, j += 2) offset.push(px[j]!, j); +console.log(offset.join(",")); + +// Counters that live across a yield or an await. +function* strided(bytes: Uint8Array, n: number): Generator { + for (let i = 0, j = 1; i < n; i++, j += 3) yield bytes[j]! + i; +} +console.log([...strided(px, 6)].join(",")); +async function weighted(bytes: Uint8Array, n: number): Promise { + let sum = 0; + for (let i = 0, j = 2; i < n; i++, j += 5) { + await null; + sum += bytes[j]! * (i + 1); + } + return sum; +} +void (async () => console.log(await weighted(px, 6), await weighted(px, 3)))(); diff --git a/tests/harness/corpus-costs/7141-comoving-loop-counters.ts.json b/tests/harness/corpus-costs/7141-comoving-loop-counters.ts.json new file mode 100644 index 000000000..7c0606885 --- /dev/null +++ b/tests/harness/corpus-costs/7141-comoving-loop-counters.ts.json @@ -0,0 +1 @@ +{ "plain": 4260, "san": 6600 } From b82c70d5de2853a368f13edb96ced5f14061fe2e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 09:56:19 -0500 Subject: [PATCH 2/2] Bound a follower's trip count only by a safe-integer span The trip count that bounds a follower's range was the driver's span over its step, computed in doubles. A span above 2^53 - 1 can round down: from -(2^53 - 1) to below 2^52 - 1 the span 3 * 2^52 - 3 becomes 3 * 2^52 - 4, so a follower that reaches 2^53 got the range limit 2^53 - 1 and ran as an unchecked integer. Such a span now bounds no trip count, and the follower is tested at run time (or the loop is refused when every value is known). Below it the quotient rounds to at least its floor, and the follower's products and sums are exact within the safe range. --- packages/compiler/src/ir/integer-loops.test.ts | 16 ++++++++++++++++ packages/compiler/src/ir/integer-loops.ts | 10 ++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/ir/integer-loops.test.ts b/packages/compiler/src/ir/integer-loops.test.ts index e1453abad..b6ac27e3a 100644 --- a/packages/compiler/src/ir/integer-loops.test.ts +++ b/packages/compiler/src/ir/integer-loops.test.ts @@ -525,4 +525,20 @@ test("counters stepped beside the tested one run as integers", () => { boxed.set("j", { ...vars.get("j")!, boxed: true }); expect(matchIntegerCountedForLoop(comoving(number(10)), boxed)).toBeNull(); expect(matchIntegerCountedForLoop(comoving(number(10), ref("i")), vars)).toBeNull(); + // A driver span above 2^53 - 1 bounds no trip count. From -(2^53 - 1) to + // below 2^52 - 1 the span 3 * 2^52 - 3 rounds down to 3 * 2^52 - 4, which + // would put this follower's last value at 2^53 - 1 instead of 2^53. + const wide = comoving(number(2 ** 52 - 1), number(-(2 ** 52) + 3), { + kind: "block", + body: [step("i", "+"), step("j", "+")], + loc, + }); + if (wide.init?.kind === "block" && wide.init.body[0]?.kind === "varDecl") + wide.init.body[0].init = number(-Number.MAX_SAFE_INTEGER); + expect(matchIntegerCountedForLoop(wide, vars)).toBeNull(); + if (wide.init?.kind === "block" && wide.init.body[1]?.kind === "varDecl") + wide.init.body[1].init = ref("from"); + expect(matchIntegerCountedForLoop(wide, vars)?.followers).toMatchObject([ + { startGuarded: true, tripGuarded: true }, + ]); }); diff --git a/packages/compiler/src/ir/integer-loops.ts b/packages/compiler/src/ir/integer-loops.ts index c4cdc327c..c451c0852 100644 --- a/packages/compiler/src/ir/integer-loops.ts +++ b/packages/compiler/src/ir/integer-loops.ts @@ -269,10 +269,12 @@ export function matchIntegerCountedForLoop( }; // On body entry k the driver holds `start + step * k`, so k is at most the // driver's span over |step|, and a follower holds its start plus its own - // step times k. - const trips = Math.floor( - (step > 0 ? range.max - startRange.min : startRange.max - range.min) / Math.abs(step), - ); + // step times k. A span above 2^53 - 1 can round down, so it bounds no + // trip count. Below it the quotient rounds to at least its floor, and the + // follower's products and sums below are exact while they stay within + // ±(2^53 - 1) and round outside it otherwise. + const span = step > 0 ? range.max - startRange.min : startRange.max - range.min; + const trips = span <= Number.MAX_SAFE_INTEGER ? Math.floor(span / Math.abs(step)) : Infinity; const followers: IntegerLoopFollower[] = []; for (const id of counterIds) { if (id === local.id) continue;