perf: drop the P6 copies of a frozen member that gained nothing #281

Closed
opened 2026-10-09 20:33:13 +00:00 by orion · 5 comments
Collaborator

Context

Second ledger of F12, split from #57: round P6 copies a frozen member for the direct call sites that can peel it, and 334 of the 1 021 copies in the compiler's own program gained nothing (the second run of the devirtualization read exactly the signature it started with): 0.5 MB of C for no peeling. The copies are appended after every other function and only a Call the devirtualization wrote points at one, so dropping the useless ones is a remap of a contiguous tail of FunctionIds, not a new analysis.

Source

Acceptance criteria

  • A P6 copy whose second run left its signature unchanged is not kept: its call sites point at the original again and its FunctionId is remapped away
  • The compiler's own program.c before and after is recorded (bytes, number of copies kept)
  • sh tools/gates.sh ab green; --emit-c twice byte identical; the conformance suite output unchanged in both back ends

Dependencies

Independent of #57 (different pass). Needs refinement before it is ready.

## Context Second ledger of F12, split from #57: round P6 copies a frozen member for the direct call sites that can peel it, and 334 of the 1 021 copies in the compiler's own program gained nothing (the second run of the devirtualization read exactly the signature it started with): 0.5 MB of C for no peeling. The copies are appended after every other function and only a `Call` the devirtualization wrote points at one, so dropping the useless ones is a remap of a contiguous tail of `FunctionId`s, not a new analysis. ## Source - [PERFORMANCE.md F12, "A second ledger belongs to the same round"](https://git.torb.dev/torbscript/language/src/branch/main/docs/PERFORMANCE.md#L797) and finding F5 (the copies) - Files named by the design record: `compiler/src/ir/devirtualize.trb` ## Acceptance criteria - [ ] A P6 copy whose second run left its signature unchanged is not kept: its call sites point at the original again and its `FunctionId` is remapped away - [ ] The compiler's own `program.c` before and after is recorded (bytes, number of copies kept) - [ ] `sh tools/gates.sh ab` green; `--emit-c` twice byte identical; the conformance suite output unchanged in both back ends ## Dependencies Independent of #57 (different pass). Needs refinement before it is ready.
Author
Collaborator

[loop:torbscript] intake: in scope (author orion, label loop, split from #57), source PERFORMANCE.md F12 second ledger, not a duplicate, no collision with a design record. next: refine

[loop:torbscript] intake: in scope (author orion, label loop, split from #57), source PERFORMANCE.md F12 second ledger, not a duplicate, no collision with a design record. next: refine
Author
Collaborator

[loop:torbscript] claim refine by session:loop-torbscript-20261009 until 2026-10-10T01:30Z

[loop:torbscript] claim refine by session:loop-torbscript-20261009 until 2026-10-10T01:30Z
Author
Collaborator

[loop:torbscript] refine: ready.

Goal. A copy that round P6 made of a frozen member and whose second devirtualization run left the signature exactly as it went in is not kept: its direct call sites point at the original again and the copy's FunctionId is remapped away. Program output is unchanged; only the C of the compiler's own program (and any program with such copies) shrinks.

Implements. PERFORMANCE.md finding F12, "A second ledger belongs to the same round", and the cost note of finding F5 (334 of 1 021 copies gained nothing, 0.5 MB of C). Not a ROADMAP milestone item of its own; the F12 ledger is the design record entry, and no design record reserves a different answer.

Seed. No impact: no syntax change, no new native, no new driver flag, no std name looked up by string. Plain compiler change.

Size. One PR, two Conventional Commits:

  1. perf(ir): drop the P6 copies whose signature did not change
  2. docs(performance): record the P6 copy ledger

Design (decided by the loop, no owner question).

  • Everything stays inside devirtualizeProgram, right after the second peelProgram. At that moment the copies are exactly the contiguous tail of program.functions that specializeFrozenCallees appended, nothing else has been appended since, and the only things that name a copy are the Call instructions redirectSpecializedCalls wrote and program.specializedFunctions. No other FunctionId-keyed state exists yet at that point (the element, caller-site and dropped-member sets are all filled by later passes), so there is no new analysis and no pass in finishLowering to add or reorder.
  • specializeFrozenCallees today answers Bool; it returns the clone map instead (original index to copy index, empty when nothing was copied), and devirtualizeProgram runs the second peelProgram when the map is not empty, as now.
  • A copy is idle when, after the second run, every parameter type and the result type of the copy equal those of its original (IrTypeId comparison; resultMode follows from the result, ownership is decided later by insertOwnership). Argument for soundness: the copy started as the original's signature, the original's signature is poisoned and does not move, so an unchanged copy has the original's signature, and the original is a valid callee for every call site (a call site's argument class is joined with the copy's parameter location, so an unpeeled parameter means an unpeeled argument). Dropping is therefore always safe; at worst a body that differs only inside a local loses a peel, which the benchmark check below covers.
  • Remap: a new id per function. Non-copies and the kept copies keep their relative order (kept copies shift down); an idle copy maps to its original's id. Rewrite the callee of every Call through that map, truncate program.functions to the kept ones, and rebuild program.specializedFunctions with the new indices of the kept copies. Names of kept copies do not change, so specializedName's collision counter needs no rework.
  • verifyProgram (already run after lowering) rejects a dangling FunctionId, which is the net for any other reference to a dropped copy; the PR's test cases cover the remap with an idle and a kept copy side by side.

Acceptance criteria (testable).

  1. A program with a frozen table member whose result class is poisoned elsewhere gets no ..._x24_direct function for it: compiler/tests/devirtualize.test.trb pins that the IR text has no such function and that the call names the original.
  2. Remap case: a program with one idle and one productive copy keeps only the productive one; its call sites point at it, the idle one's call sites point at the original, and verifyProgram passes.
  3. The existing tests at devirtualize.test.trb 307 to 337 (Three_iterate_x24_direct, cursor on the frame, the original keeps its box) stay green unchanged.
  4. sh tools/gates.sh ab green (tier B: conformance suite in both back ends with the leak gate on), --emit-c twice byte identical, bootstrap fixpoint holds.
  5. For the compiler's own program, before and after are recorded in the PR description and in PERFORMANCE.md: number of _x24_direct definitions in the stage-2 program.c (1 021 on record, expected about 687 kept), total bytes of program.c, and bytes attributable to the dropped copies (record states about 0.5 MB). If the idle count is not close to 334, the PR records the real number; that is a result, not a blocker.

Exact files. compiler/src/ir/devirtualize.trb (module doc, devirtualizeProgram, specializeFrozenCallees return value, new dropIdleCopies), compiler/tests/devirtualize.test.trb (new tests), docs/PERFORMANCE.md (F5 "What it costs in code size", F12 second ledger and what was measured, P6 row). No ir.trb, lower.trb, body.trb, std/ or runtime/ change.

Overlap with PR #284 (P10, #57). No file overlap in compiler/src: #284 touches backend/c/body.trb, backend/c/emit.trb, backend/bytecode/emit.trb, ir/called-members.trb, ir/ir.trb, ir/kept.trb, ir/lower/lower.trb, ir/print.trb, ir/sites.trb, ir/verify.trb. Semantically independent: #281 compacts function ids inside devirtualizeProgram, before finishLowering runs the #284 pass, and keptFunctions keeps reading specializedFunctions by index, which stays valid. The only shared files are docs/PERFORMANCE.md (#284's docs commit edits the F12 and P10 text next to the second-ledger paragraph; a trivial rebase conflict, whichever lands second rebases) and, if #284 pins a size there, compiler/tests/emit-c.test.trb, which #281 does not need. Not a blocker: no ordering dependency. Byte numbers for program.c shift by whichever lands first, so the PR measures before/after on one base inside the same CI run and states that base commit.

Measurement plan (forge CI, decided here).

  • Before (main) and after (branch) in the PR's tier A/B jobs, one base commit, recorded in the PR description: (i) count of _x24_direct definitions and wc -c of the stage-2 program.c of tools/bootstrap.sh; (ii) wall time of the compiler's C compile step (87 s on record) and of sh tools/bootstrap.sh; (iii) the thunk-free three-line list program of F2: torb build --emit-c bytes before/after (expected unchanged, it is a guard that no productive copy was lost). Taken with a one-off command in the job; no permanent tool change.
  • No run-time regression: an idle copy has the original's signature, so a call to it and a call to the original are the same code; the check is the first nightly benchmarks.yml table after merge (list-iterate, pipeline, accumulate, call-depth) against the previous one, expected delta noise. A slowdown over noise reopens the issue.

Implementer. opus, effort high (compiler/src IR pass, id remap whose mistake is a wrong callee at run time).

next: implement

[loop:torbscript] refine: ready. **Goal.** A copy that round P6 made of a frozen member and whose second devirtualization run left the signature exactly as it went in is not kept: its direct call sites point at the original again and the copy's `FunctionId` is remapped away. Program output is unchanged; only the C of the compiler's own program (and any program with such copies) shrinks. **Implements.** [PERFORMANCE.md](https://git.torb.dev/torbscript/language/src/branch/main/docs/PERFORMANCE.md) finding F12, "A second ledger belongs to the same round", and the cost note of finding F5 (334 of 1 021 copies gained nothing, 0.5 MB of C). Not a ROADMAP milestone item of its own; the F12 ledger is the design record entry, and no design record reserves a different answer. **Seed.** No impact: no syntax change, no new native, no new driver flag, no std name looked up by string. Plain compiler change. **Size.** One PR, two Conventional Commits: 1. `perf(ir): drop the P6 copies whose signature did not change` 2. `docs(performance): record the P6 copy ledger` **Design (decided by the loop, no owner question).** - Everything stays inside `devirtualizeProgram`, right after the second `peelProgram`. At that moment the copies are exactly the contiguous tail of `program.functions` that `specializeFrozenCallees` appended, nothing else has been appended since, and the only things that name a copy are the `Call` instructions `redirectSpecializedCalls` wrote and `program.specializedFunctions`. No other `FunctionId`-keyed state exists yet at that point (the element, caller-site and dropped-member sets are all filled by later passes), so there is no new analysis and no pass in `finishLowering` to add or reorder. - `specializeFrozenCallees` today answers `Bool`; it returns the clone map instead (original index to copy index, empty when nothing was copied), and `devirtualizeProgram` runs the second `peelProgram` when the map is not empty, as now. - A copy is idle when, after the second run, every parameter type and the result type of the copy equal those of its original (`IrTypeId` comparison; `resultMode` follows from the result, ownership is decided later by `insertOwnership`). Argument for soundness: the copy started as the original's signature, the original's signature is poisoned and does not move, so an unchanged copy has the original's signature, and the original is a valid callee for every call site (a call site's argument class is joined with the copy's parameter location, so an unpeeled parameter means an unpeeled argument). Dropping is therefore always safe; at worst a body that differs only inside a local loses a peel, which the benchmark check below covers. - Remap: a new id per function. Non-copies and the kept copies keep their relative order (kept copies shift down); an idle copy maps to its original's id. Rewrite the callee of every `Call` through that map, truncate `program.functions` to the kept ones, and rebuild `program.specializedFunctions` with the new indices of the kept copies. Names of kept copies do not change, so `specializedName`'s collision counter needs no rework. - `verifyProgram` (already run after lowering) rejects a dangling `FunctionId`, which is the net for any other reference to a dropped copy; the PR's test cases cover the remap with an idle and a kept copy side by side. **Acceptance criteria (testable).** 1. A program with a frozen table member whose result class is poisoned elsewhere gets no `..._x24_direct` function for it: `compiler/tests/devirtualize.test.trb` pins that the IR text has no such function and that the call names the original. 2. Remap case: a program with one idle and one productive copy keeps only the productive one; its call sites point at it, the idle one's call sites point at the original, and `verifyProgram` passes. 3. The existing tests at `devirtualize.test.trb` 307 to 337 (`Three_iterate_x24_direct`, cursor on the frame, the original keeps its box) stay green unchanged. 4. `sh tools/gates.sh ab` green (tier B: conformance suite in both back ends with the leak gate on), `--emit-c` twice byte identical, bootstrap fixpoint holds. 5. For the compiler's own program, before and after are recorded in the PR description and in PERFORMANCE.md: number of `_x24_direct` definitions in the stage-2 `program.c` (1 021 on record, expected about 687 kept), total bytes of `program.c`, and bytes attributable to the dropped copies (record states about 0.5 MB). If the idle count is not close to 334, the PR records the real number; that is a result, not a blocker. **Exact files.** `compiler/src/ir/devirtualize.trb` (module doc, `devirtualizeProgram`, `specializeFrozenCallees` return value, new `dropIdleCopies`), `compiler/tests/devirtualize.test.trb` (new tests), `docs/PERFORMANCE.md` (F5 "What it costs in code size", F12 second ledger and what was measured, P6 row). No `ir.trb`, `lower.trb`, `body.trb`, `std/` or `runtime/` change. **Overlap with PR #284 (P10, #57).** No file overlap in `compiler/src`: #284 touches `backend/c/body.trb`, `backend/c/emit.trb`, `backend/bytecode/emit.trb`, `ir/called-members.trb`, `ir/ir.trb`, `ir/kept.trb`, `ir/lower/lower.trb`, `ir/print.trb`, `ir/sites.trb`, `ir/verify.trb`. Semantically independent: #281 compacts function ids inside `devirtualizeProgram`, before `finishLowering` runs the #284 pass, and `keptFunctions` keeps reading `specializedFunctions` by index, which stays valid. The only shared files are `docs/PERFORMANCE.md` (#284's docs commit edits the F12 and P10 text next to the second-ledger paragraph; a trivial rebase conflict, whichever lands second rebases) and, if #284 pins a size there, `compiler/tests/emit-c.test.trb`, which #281 does not need. Not a blocker: no ordering dependency. Byte numbers for `program.c` shift by whichever lands first, so the PR measures before/after on one base inside the same CI run and states that base commit. **Measurement plan (forge CI, decided here).** - Before (main) and after (branch) in the PR's tier A/B jobs, one base commit, recorded in the PR description: (i) count of `_x24_direct` definitions and `wc -c` of the stage-2 `program.c` of `tools/bootstrap.sh`; (ii) wall time of the compiler's C compile step (87 s on record) and of `sh tools/bootstrap.sh`; (iii) the thunk-free three-line list program of F2: `torb build --emit-c` bytes before/after (expected unchanged, it is a guard that no productive copy was lost). Taken with a one-off command in the job; no permanent tool change. - No run-time regression: an idle copy has the original's signature, so a call to it and a call to the original are the same code; the check is the first nightly `benchmarks.yml` table after merge (`list-iterate`, `pipeline`, `accumulate`, `call-depth`) against the previous one, expected delta noise. A slowdown over noise reopens the issue. **Implementer.** opus, effort high (compiler/src IR pass, id remap whose mistake is a wrong callee at run time). next: implement
Author
Collaborator

[loop:torbscript] claim implement by session:loop-torbscript-20261009 until 2026-10-10T05:45Z

[loop:torbscript] claim implement by session:loop-torbscript-20261009 until 2026-10-10T05:45Z
Author
Collaborator

[loop:torbscript] implement: PR #286 open, CI green

  • Commits: eb14a70a perf(ir): drop the P6 copies whose signature did not change; dcf9cae7 docs(performance): record the P6 copy ledger. Base: main 6f9d340b.
  • Ledger (stage-2 program.c):
    • Copies: 200 before, 158 after. 42 were idle (not 334); their definitions held 60 155 bytes.
    • program.c is 43 605 bytes smaller, net of the pass's own 28 305 bytes.
    • Bootstrap 194.3 s before, 197.7 s after: unchanged within noise.
    • F2 emit-c: 39 904 bytes, byte-identical.
    • The fixpoint holds.
  • Token note: one implement session; the CI measurement came from artifacts and job logs, with no local bootstrap.

next: waiting (merge gate review of PR #286)

[loop:torbscript] implement: PR #286 open, CI green - Commits: `eb14a70a` perf(ir): drop the P6 copies whose signature did not change; `dcf9cae7` docs(performance): record the P6 copy ledger. Base: main `6f9d340b`. - Ledger (stage-2 program.c): - Copies: 200 before, 158 after. 42 were idle (not 334); their definitions held 60 155 bytes. - program.c is 43 605 bytes smaller, net of the pass's own 28 305 bytes. - Bootstrap 194.3 s before, 197.7 s after: unchanged within noise. - F2 emit-c: 39 904 bytes, byte-identical. - The fixpoint holds. - Token note: one implement session; the CI measurement came from artifacts and job logs, with no local bootstrap. next: waiting (merge gate review of PR #286)
argus closed this issue 2026-10-09 23:01:49 +00:00
Sign in to join this conversation.
No labels
loop
ready
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
torbscript/language#281
No description provided.