-
Notifications
You must be signed in to change notification settings - Fork 19
IMPORT_NAME through the __import__ gateway, and a storage box's payload as memory pressure #1404
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f4edbac
19662ff
43a4676
032c0a8
d0d98a5
5a6d5b5
ec68b58
de20424
763d8c7
5e02677
b2625ae
b7eeedb
29513db
34cb643
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -980,6 +980,12 @@ fn rangeto_static_length_bound_matches( | |
| /// required wraparound semantics. `ArrayLen` and plain `sub` are the measured | ||
| /// post-lowering forms (`front/mir.rs`). | ||
| fn minus_one_end_matches(graph: &FunctionGraph, end: &Variable, slice: &Variable) -> bool { | ||
| let Some(end) = resolve_block_alias(graph, end) else { | ||
| return false; | ||
| }; | ||
| let Some(slice) = resolve_block_alias(graph, slice) else { | ||
| return false; | ||
| }; | ||
| let Some((lhs, rhs)) = graph | ||
| .blocks | ||
| .iter() | ||
|
|
@@ -993,15 +999,18 @@ fn minus_one_end_matches(graph: &FunctionGraph, end: &Variable, slice: &Variable | |
| rhs, | ||
| result_ty: ValueType::Unsigned, | ||
| }, | ||
| ) if result == end && op == "sub" => Some((lhs.clone(), rhs.clone())), | ||
| ) if result == &end && op == "sub" => Some((lhs.clone(), rhs.clone())), | ||
| _ => None, | ||
| }) | ||
| else { | ||
| return false; | ||
| }; | ||
| let lhs = resolve_block_alias(graph, &lhs).unwrap_or(lhs); | ||
| let rhs = resolve_block_alias(graph, &rhs).unwrap_or(rhs); | ||
| let has_len = graph.blocks.iter().flat_map(|b| &b.operations).any(|op| { | ||
| op.result.as_ref() == Some(&lhs) | ||
| && matches!(&op.kind, OpKind::ArrayLen { base, .. } if base == slice) | ||
| && matches!(&op.kind, OpKind::ArrayLen { base, .. } | ||
| if resolve_block_alias(graph, base).as_ref() == Some(&slice)) | ||
|
Comment on lines
+1002
to
+1013
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win Measure repeated alias resolution before merge.
Benchmark this path against the reported JIT-enabled slowdown. If the cost is material, cache resolved roots per 🤖 Prompt for AI Agents |
||
| }); | ||
| let has_one = graph | ||
| .blocks | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,7 +32,8 @@ Rust, and a PyPy-equivalent (pyre) on top of that.** | |
| |---|---|---| | ||
| | RPython the language | **Rust** | The host language is no longer a Python subset; it is a real language with a real type system. See §3.1. | | ||
| | RPython translator (flowspace → annotator → rtyper) | **majit-translate** (`front/ast` → `flowspace/` → `annotator/` → `rtyper/`) over **Charon LLBC** artifacts | Same pipeline, same module names, run at `cargo build` time over extracted `.ullbc` instead of live bytecode. | | ||
| | `jtransform`/codewriter → JitCode | **codewriter/** → JitCode | Identical role. | | ||
| | `jtransform`/codewriter → JitCode | **majit-translate `codewriter/`** → JitCode | Same role, at `cargo build` time. pyre additionally runs a *second*, hand-written codewriter over user `CodeObject`s at runtime; see §3.7. | | ||
| | `warmspot` — translation-time portal generator | **split**: build-time derivation in majit-translate, hand-written warm entry in pyre-jit | Not a port. `apply_jit` is unwired and `warmspot.rs` is a `pub use` namespace; see §3.7. | | ||
| | metainterp, optimizer, resume, blackhole | **majit-metainterp / majit-trace** | Line-by-line port of the *tracing* JIT (pyjitpl5 lineage), not the 2007 PE JIT. | | ||
| | x86/ARM/… hand-written backends (~300k LOC) | **majit-backend-dynasm / -cranelift / -wasm** | Three thin backends behind one trait, current primary dynasm; see §3.4. | | ||
| | incminimark GC | **majit-gc** (nursery + oldgen + incremental + card marking) | Port of the winner, not of Boehm/refcount/mark-sweep. | | ||
|
|
@@ -300,6 +301,97 @@ configuration. pyre keeps: | |
|
|
||
| --- | ||
|
|
||
| ### 3.7 The portal boundary: warmspot split in two | ||
|
|
||
| Upstream mints the portal at translation time. `warmspot.apply_jit` — the body | ||
| of the translator task literally named "JIT compiler generation" | ||
| (`task_pyjitpl_lltype`) — derives each driver's green/red specification | ||
| (`make_args_specification`), rewrites the `jit_merge_point` and `can_enter_jit` | ||
| markers into calls (`rewrite_jit_merge_point`, `rewrite_can_enter_jits`), and | ||
| fills the fields `JitDriverStaticData` declares but never computes. Upstream's | ||
| `jitdriver.py` is an attribute container with two executable statements | ||
| precisely because warmspot writes the rest. pyre splits that pipeline across two | ||
| layers, unevenly: | ||
|
|
||
| - **The derivation and the marker erasure do run at build time**, over Charon | ||
| LLBC, in majit-translate's `jtransform` and `CallControl::setup_jitdriver`, | ||
| driven from `pyre-jit-trace/build.rs`. The derived green/red layout is | ||
| asserted against the real MIR operands and a mismatch fails the build. This | ||
| half is at the right layer and is not debt. | ||
| - **`apply_jit` itself is unported.** `task_pyjitpl_lltype` assembles every | ||
| upstream-shaped argument and then returns `TaskError`, because majit-translate | ||
| does not depend on majit-metainterp; `warmspot.rs` is a `pub use` namespace, | ||
| not an implementation. Seven `missing_task_leaf` sites exist across that | ||
| driver, so the stub is not unique — it is named here because the fields it | ||
| would fill are instead written by hand from consumer source. | ||
| - **A second codewriter runs at runtime.** majit-translate's | ||
| `transform_graph_to_jitcode` consumes a `FunctionGraph` once per build; | ||
| pyre-jit's `transform_graph_to_jitcode` consumes a user `CodeObject`, is | ||
| fallible, and runs unboundedly. Upstream has one, over the interpreter's own | ||
| graphs. This is the A1 debt in this area — it is written, it carries Python | ||
| opcode semantics, and it has already produced a wrong answer of exactly the | ||
| class N3 names: in a chained blackhole resume `portal_frame_reg` aliased the | ||
| caller frame, so an inlined callee's `LOAD_GLOBAL` indexed the caller's | ||
| `names` table. A1 is **not** weakened to accommodate it; it stands as a | ||
| tracked generation defect whose convergence target is majit-translate's | ||
| codewriter. | ||
|
|
||
| **Measured cost, 2026-08-22.** Installing `sys.setprofile`, `sys.settrace` or | ||
| `cProfile` costs **1168–2836×** on a hot loop where PyPy 7.3.20 pays | ||
| **1.1–4.6×** and stays compiled. It is a total outage, not a reuse failure: | ||
| warming *under* the profiler never compiles at all. Event counts match CPython | ||
| exactly, so this is a cliff and not a wrong answer. Stated plainly: **a profile | ||
| taken on pyre measures the interpreter, not the JIT**, and pdb and coverage.py | ||
| are in the same position. | ||
|
Comment on lines
+339
to
+345
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win Make the measurements reproducible. The section gives exact overhead ranges and a falsification condition, but it does not identify the benchmark command, build mode, interpreter revisions, profiler configuration, or event-count assertion. Add these details or link a checked-in reproduction so future JIT changes can verify the reported result. Also applies to: 387-391 🤖 Prompt for AI Agents |
||
|
|
||
| **The root is a missing bracket, not the folded green.** Upstream brackets the | ||
| portal itself: `PyFrame.execute_frame` wraps `dispatch` — the function that | ||
| carries the merge point and nothing else — in `ExecutionContext.enter`, | ||
| `call_trace`, then `return_trace` and `leave` in `finally` clauses. pyre put | ||
| that bracket *inside* the plain dispatch body (`eval_frame_plain_with_resume`) | ||
| and left the JIT dispatch body bare: `eval_with_jit_inner` substitutes | ||
| `install_current_frame`, which performs only `enter`'s topframeref/f_backref | ||
| half, and `CurrentFrameGuard`'s drop, which performs only `leave`'s | ||
| topframeref half. Neither emits an event, and `pyre-jit` contains no | ||
| `call_trace` or `return_trace` call at all. | ||
|
|
||
| A JIT-activated frame therefore emits no `call` and no `return` event, and the | ||
| only thing hiding that is the refusal itself — `frame_tracing_active` sends | ||
| every traced or profiled frame down `execute_frame_plain`, which is the | ||
| bracketed path. `run_with_jit` states the dependency in the affirmative: it | ||
| routes non-JIT-eligible frames through `execute_frame` "so `call_trace` / | ||
| `return_trace` frame events still fire". **The gate is not a performance | ||
| concession; it is the whole implementation of frame events for JIT-eligible | ||
| frames**, and the measured event parity above is produced by it. Restoring the | ||
| bracket above the portal is a prerequisite for touching the gate, and it needs | ||
| no green. | ||
|
|
||
| **What upstream does not do.** It does not fold the tracing state away. | ||
| `ExecutionContext` declares `_immutable_fields_` with `profilefunc?` and | ||
| `w_tracefunc?`, yet the recorded traces read both as ordinary fields and guard | ||
| them, and the comment directly above that declaration says so: the fields | ||
| "should be known to a constant … but they're not". They are cheap because | ||
| they sit on the entry bridge, once per frame activation — not because they | ||
| disappear. Nor would the declaration help here: `quasi_immut_descr` requires a | ||
| constant struct operand, and pyre's `ec` is a portal red (`PYPYJIT_RED_VARS`), | ||
| so it is never one. The per-opcode half is a different mechanism again — | ||
| `dispatch_bytecode`'s explicit `we_are_jitted()` arm tests the *per-frame* | ||
| `w_f_trace` through the virtualizable `debugdata`, not the global tracefunc. | ||
|
|
||
| Where the green does pay is the profiled-call dispatch: `call_valuestack` and | ||
| its keyword/ex siblings branch on `get_is_being_profiled()` before | ||
| `call_args_and_c_profile`, and a real green folds those branches to nothing in | ||
| the unprofiled trace while giving the profiled state its own cell, counter and | ||
| procedure token. That is the last step of the repair, not the first. | ||
|
|
||
| **Falsification.** Restoring the activation bracket should leave event counts | ||
| unchanged with the gate still in place, and should let the gate's | ||
| `profilefunc`/global-tracefunc disjuncts be dropped without losing events. If | ||
| events go missing once the bracket is above the portal, the bracket is not what | ||
| the gate was standing in for and this entry is wrong. | ||
|
|
||
| --- | ||
|
|
||
| ## 4. Norms (operating rules) | ||
|
|
||
| **N1 — Layering.** majit never depends on pyre. pyre-interpreter stays | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| # CPython-suite gap: import-hook tests do not combine co_names identity with a | ||
| # hot loop that rebinds builtins.__import__ after IMPORT_NAME has been traced. | ||
| # parity-tests reason: IMPORT_NAME must keep PyPy's co_names_w object and live | ||
| # builtin lookup when its call is exposed to the meta-tracer. | ||
|
|
||
| import builtins | ||
|
|
||
|
|
||
| old_import = builtins.__import__ | ||
| os_module = old_import("os") | ||
| calls = [0, 0] | ||
| N = 40000 | ||
| SWITCH = N // 2 | ||
|
|
||
|
|
||
| def run(): | ||
| names = run.__code__.co_names | ||
| expected_name = names[names.index("os")] | ||
| expected_globals = globals() | ||
|
|
||
| def first(name, globals_arg, locals_arg, fromlist, level): | ||
| assert name is expected_name | ||
| assert globals_arg is expected_globals | ||
| assert locals_arg is None | ||
| # `import os` compiles to IMPORT_NAME with no fromlist and an absolute | ||
| # level. Both hooks return os_module regardless, so a wrong value | ||
| # reaches nothing that would fail unless it is asserted here. | ||
| assert fromlist is None | ||
| assert level == 0 | ||
| calls[0] += 1 | ||
| return os_module | ||
|
|
||
| def second(name, globals_arg, locals_arg, fromlist, level): | ||
| assert name is expected_name | ||
| assert globals_arg is expected_globals | ||
| assert locals_arg is None | ||
| # `import os` compiles to IMPORT_NAME with no fromlist and an absolute | ||
| # level. Both hooks return os_module regardless, so a wrong value | ||
| # reaches nothing that would fail unless it is asserted here. | ||
| assert fromlist is None | ||
| assert level == 0 | ||
| calls[1] += 1 | ||
| return os_module | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| builtins.__import__ = first | ||
| try: | ||
| i = 0 | ||
| while i < N: | ||
| import os | ||
|
|
||
| assert os is os_module | ||
| if i == SWITCH: | ||
| builtins.__import__ = second | ||
| i += 1 | ||
| finally: | ||
| builtins.__import__ = old_import | ||
|
|
||
|
|
||
| run() | ||
| assert calls == [SWITCH + 1, N - SWITCH - 1], calls | ||
| print("OK") | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| # CPython-suite gap: the import-hook tests never put IMPORT_NAME inside a class | ||
| # body whose loop runs hot enough to compile, so nothing covers the one frame | ||
| # kind whose locals mapping is a real dict rather than None. | ||
| # parity-tests reason: `pyopcode.py:1119-1125` reads the frame's debug locals | ||
| # and substitutes None only when the frame has none. A class body has one, so | ||
| # a traced IMPORT_NAME that bakes None is visible to any custom __import__. | ||
|
|
||
| import builtins | ||
|
|
||
| old_import = builtins.__import__ | ||
| os_module = old_import("os") | ||
| seen = [] | ||
|
|
||
|
|
||
| def hook(name, globals_arg, locals_arg, fromlist, level): | ||
| seen.append(locals_arg is None) | ||
| return os_module | ||
|
|
||
|
|
||
| N = 40000 | ||
| builtins.__import__ = hook | ||
| try: | ||
|
|
||
| class C: | ||
| i = 0 | ||
| while i < N: | ||
| import os | ||
|
|
||
| i += 1 | ||
|
|
||
| finally: | ||
| builtins.__import__ = old_import | ||
|
|
||
| assert len(seen) == N, len(seen) | ||
| # A baked None shows up only once the loop compiles, so report the iteration | ||
| # it starts at rather than the whole list. | ||
| assert not any(seen), seen.index(True) | ||
| print("OK") |
Uh oh!
There was an error while loading. Please reload this page.