Skip to content

build: cut the prepass peak RSS 7.71 GB → 4.25 GB; make on-demand body lowering possible - #835

Merged
youknowone merged 4 commits into
mainfrom
gc-decouple
Jul 27, 2026
Merged

build: cut the prepass peak RSS 7.71 GB → 4.25 GB; make on-demand body lowering possible#835
youknowone merged 4 commits into
mainfrom
gc-decouple

Conversation

@youknowone

Copy link
Copy Markdown
Owner

What this does

Four commits on the pyre-jit-trace build prepass. The headline is the last
one: pipeline peak RSS 7.71 GB → 4.25 GB, total prepass time unchanged
(63.63s → 63.68s).

front/mir: store the per-block local rows by bound slot

Lowering kept three tables holding one slot-indexed row per MIR block —
block_entry_local_var, and the RPO walk's entry_state / exit_state, whose
FrameState carries entries and locals_w sized by the body's local count.
Each costs blocks * locals cells no matter how many slots are bound.

pyre_jit::jit::codewriter::<Impl>::transform_graph_to_jitcode has 15335 MIR
blocks and 14215 locals, and lowering it accounted for 4.05 GB of the prepass
RSS on its own
. The next-largest body,
pyre_interpreter::module::_pickle::unpickler::dispatch (3061 blocks, 2121
locals), accounted for 0.15 GB — a fifth the size, a twenty-seventh the memory.

Its lowered graph carries 509162 block inputargs across 15403 blocks: ~33 bound
slots per 14215-slot row, so the dense rows are 0.2% occupied.

PackedLocalRow / PackedFrameState store the bound slots plus the row length,
and a row is rebuilt dense at each use, one at a time. Lowering::local_var and
every FrameState reaching union / getvariables / getoutputargs keep the
shape they have today, and crate::model::FrameState is untouched — same
values, same algorithm, different storage.

RPython's flowspace also gives every block its inputargs from the frame state
(framestate.py:19 locals_w), so the algorithm is unchanged here. What does not
carry over is the input scale: a Python frame has tens of co_varnames, this
Rust MIR body has 14215 locals.

front: add a GraphBodyProvider that lowers a FunDecl on demand

translator.py:55 buildflowgraph(func) builds a function's flow graph when a
consumer first asks; pyre's funcobjs are Charon FunDecls, so the analogue is
lowering the decl from its LLBC. That needs the LLBC set to outlive the
whole-program build, which nothing kept alive.

The provider owns the Vec<Llbc>, an owned copy of the three HostStaticAddrs
tables, and a per-LLBC struct_field_attrs map recovered lazily through the new
mir::struct_field_attrs_of. A GraphBodySource is (llbc index, Charon def_id), resolved via Llbc::fn_by_id.

The test lowers every body in charon-corpus/corpus.ullbc through both the
whole-program entry point and the provider and compares graph shapes, with
variable ids renumbered in first-seen order — NEXT_VAR_ID is process-global,
so two lowerings of one funcobj never carry equal ids, while the aliasing the
renumbering preserves is what the codewriter reads. No production call site yet.

front/mir: lower the whole-program loop from the body it already projected

FunDecl::body is raw JSON and unstructured() re-parses it per call. The loop
called it twice per decl: once as a "has a lowerable body" gate, discarding the
projection, then again inside the lowering. One full-corpus unstructured()
pass over the three artefacts (23137 bodies, 406388 blocks) measures 4.20s at
the build script's opt-level = 1.

majit-translate: keep the funcobj Signature on the GraphStore slot

GraphStore now stores GraphSlot { graph, signature }, the signature derived
from the startblock inputargs when the key is first inserted.
signature_for_graph is removed and its four callers read the slot, so
registration no longer walks a graph body for parameter names.

Verification

Every commit holds 819 all_jitcodes (555669 bytes bincode), generated 25254 bytes and passes check.py --backend dynasm,cranelift,wasm (333/333, 333/333,
330/330 at the tip).

majit-translate's suite is green except
test_rbigint_mir::dependent_crate_rbigint_identity_retargets_opaque_llbc_declaration,
which is red on the base too: it asserts harvest_hints_from_llbcs reports
elidable_or_memerror for jit_bigint_div / jit_bigint_rem, but
build/llbc/pyre-interpreter.ullbc was extracted 2026-07-25 21:43 and the
attribute landed in 7b70e1e at 22:02 — a stale fixture, not a regression from
this branch.

Note on scope

This branch started out building toward demand-driven body lowering (the first
three commits are that plumbing). Measuring the premise first would have been
the better order: an env-gated probe that drops every lowered body immediately
moves the phase from 7.67 GB to 6.34 GB, so retaining all 14998 graphs is worth
only 1.33 GB
— the whole ceiling of that axis. Skipping lowering entirely lands
at 2.10 GB, and mi_collect(true) before each reading changes nothing, which is
what pointed at the dense tables above.

🤖 Generated with Claude Code

`GraphStore` stored `HashMap<GraphKey, FunctionGraph>`; it now stores
`GraphSlot { graph, signature }`, where `signature` is derived from the
graph's startblock inputargs when the key is first inserted.  An alias
registration of an already-stored key folds effects / hints / return
type onto the shared graph as before and leaves the signature alone,
which is the same value its consumers read off that shared graph today.

`signature_for_graph` in `translator/rtyper/cutover.rs` is removed.  Its
four callers — the stale-graph re-lift at `cutover.rs:447`, Pass 1's
`get_or_register`, and Pass 2's residualize-stub and full-lift arms —
read `GraphStore::signature` instead; Pass 2's `pending` rows carry the
borrowed signature from Pass 1.

`819 all_jitcodes (555669 bytes bincode), generated 25254 bytes`
unchanged; `check.py --backend dynasm,cranelift,wasm` 331/331, 331/331,
328/328.

Assisted-by: Claude
…ected

`FunDecl::body` is retained as raw JSON and `FunDecl::unstructured`
re-parses it on each call.  The whole-program loop called it twice per
decl: once at the top as a "has a lowerable body" gate, discarding the
projection, and again inside
`lower_fun_decl_with_static_addrs_and_attrs`.

The gate now binds the projection and passes it to a new
`lower_unstructured_with_static_addrs_and_attrs`, which holds the rest of
the lowering; `lower_fun_decl_with_static_addrs_and_attrs` keeps its
signature and delegates after parsing, so the reader/test callers are
unchanged.

One full-corpus `unstructured()` pass over the three extracted artefacts
(23137 bodies, 406388 blocks) measures 4.20s at the build script's
`opt-level = 1`.

`819 all_jitcodes (555669 bytes bincode), generated 25254 bytes`
unchanged; `check.py --backend dynasm,cranelift,wasm` 331/331, 331/331,
328/328.

Assisted-by: Claude
`translator.py:55 buildflowgraph(func)` builds a function's flow graph
from the function object when a consumer first asks for it; pyre's
funcobjs are Charon `FunDecl`s, so the analogue is lowering the decl from
the LLBC it came from.  That needs the LLBC set to outlive the
whole-program build, which nothing currently keeps alive.

`front::graph_body::GraphBodyProvider` owns the `Vec<Llbc>`, an owned
copy of the three `HostStaticAddrs` tables (the borrowed view is rebuilt
per call, which only a demanded body pays for), and a per-LLBC
`struct_field_attrs` map recovered through the new
`mir::struct_field_attrs_of` — `derive_program_metadata` is a pure
function of the LLBC, and the map is computed on the first body demanded
from each LLBC rather than for every LLBC up front.
`mir::lower_fun_decl_with_static_addrs_and_attrs` becomes `pub(crate)`.

A `GraphBodySource` is `(llbc index, Charon def_id)`, resolved through
`Llbc::fn_by_id`.

The test lowers every body in `charon-corpus/corpus.ullbc` through both
the whole-program entry point and the provider and compares their graph
shapes.  Comparison is on a rendering whose variable ids are renumbered
in first-seen order: `NEXT_VAR_ID` is process-global, so two lowerings of
one funcobj never carry equal ids, while the aliasing the renumbering
preserves is what the codewriter reads.

No production call site yet.  `819 all_jitcodes (555669 bytes bincode),
generated 25254 bytes` unchanged; `check.py --backend
dynasm,cranelift,wasm` 331/331, 331/331, 328/328.

Assisted-by: Claude
`Lowering` kept three tables holding one slot-indexed row per MIR block:
`block_entry_local_var`, and the RPO walk's `entry_state` / `exit_state`,
whose `FrameState` carries `entries` and `locals_w` sized by the body's
local count.  Each is `blocks * locals` cells regardless of how many
slots are bound.

`pyre_jit::jit::codewriter::<Impl>::transform_graph_to_jitcode` has 15335
MIR blocks and 14215 locals, and lowering it accounted for 4.05 GB of the
prepass RSS on its own — the next-largest body,
`pyre_interpreter::module::_pickle::unpickler::dispatch` (3061 blocks,
2121 locals), accounted for 0.15 GB.  Its lowered graph carries 509162
block inputargs across 15403 blocks, i.e. ~33 bound slots per 14215-slot
row: the dense rows are 0.2% occupied.

`PackedLocalRow` and `PackedFrameState` hold the bound slots and the row
length; a row is rebuilt dense at each use, one at a time, so
`Lowering::local_var` and every `FrameState` reaching `union` /
`getvariables` / `getoutputargs` keep the shape they have today.
`crate::model::FrameState` is unchanged.

Pipeline peak RSS 7.71 GB -> 4.25 GB; total prepass 63.63s -> 63.68s.
`819 all_jitcodes (555669 bytes bincode), generated 25254 bytes`
unchanged; `check.py --backend dynasm,cranelift,wasm` 333/333, 333/333,
330/330.

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@youknowone, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 31 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 9a980b01-ebd0-464e-b92c-8bf5c4a56f68

📥 Commits

Reviewing files that changed from the base of the PR and between 5566783 and 2c6dfa2.

📒 Files selected for processing (6)
  • majit/majit-translate/src/codewriter/call.rs
  • majit/majit-translate/src/front/graph_body.rs
  • majit/majit-translate/src/front/mir.rs
  • majit/majit-translate/src/front/mod.rs
  • majit/majit-translate/src/model.rs
  • majit/majit-translate/src/translator/rtyper/cutover.rs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gc-decouple

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 2c6dfa2).
Updated: 2026-07-27T11:33:36.899Z

Files in the reviewed diff
majit/majit-translate/src/codewriter/call.rs
majit/majit-translate/src/front/graph_body.rs
majit/majit-translate/src/front/mir.rs
majit/majit-translate/src/front/mod.rs
majit/majit-translate/src/model.rs
majit/majit-translate/src/translator/rtyper/cutover.rs

1. Regressions to PyPy parity introduced by this patch

None.

2. Other mismatches introduced by this patch

  • majit/majit-translate/src/front/graph_body.rs:52 ↔ rpython/annotator/description.py:228: GraphBodyProvider is never constructed or used outside its test module, so it cannot implement upstream FunctionDesc.cachedgraph()’s demand-time build-and-cache behavior. The new “on-demand” path is dead code; production still eagerly lowers bodies.

  • majit/majit-translate/src/front/graph_body.rs:72 ↔ rpython/annotator/bookkeeper.py:361: source_for_name_path() selects the first matching textual name_path; upstream keys descriptions by Constant(pyobj) identity. If connected, two distinct extracted function objects with the same name path could select the wrong LLBC body.

3. Pre-existing mismatches (already present before this patch)

  • majit/majit-translate/src/front/mir.rs:844 ↔ rpython/annotator/description.py:228: production iterates and lowers every local function while building the semantic program; upstream builds a function graph only on a cachedgraph() cache miss. The added provider does not change this pre-existing eager behavior.

  • majit/majit-translate/src/front/mir.rs:2674 ↔ rpython/flowspace/framestate.py:18: MIR snapshots populate only the Variable-only entries projection and leave locals_w empty; upstream FrameState.locals_w is the authoritative Variable | Constant | None locals row. This predates the packing change.

4. Structural adaptations

  • majit/majit-translate/src/codewriter/call.rs:528 ↔ rpython/flowspace/pygraph.py:14: the new stored signature is recovered from named start-block inputs, whereas PyPy obtains it from the Python code object. Rust LLBC functions have no PyPy PyCode/PyGraph wrapper; for non-variadic Rust functions this is an equivalent representation.

  • majit/majit-translate/src/front/mir.rs:638 ↔ rpython/flowspace/framestate.py:19: PackedLocalRow and PackedFrameState sparsely store slot/value pairs, reconstructing dense positional rows at use sites. Upstream directly retains dense Python lists; the Rust representation preserves the same slot semantics while avoiding impractical blocks × locals allocation.

  • majit/majit-translate/src/front/graph_body.rs:39 ↔ rpython/translator/translator.py:68: retaining LLBC files and rebuilding borrowed static-address views is the Rust/Charon analogue of retaining Python function/code objects for buildflowgraph().

@youknowone
youknowone merged commit 0f2775f into main Jul 27, 2026
19 checks passed
@youknowone
youknowone deleted the gc-decouple branch July 27, 2026 12:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant