Skip to content

Commit 8c87851

Browse files
authored
jit, interp: inline bound methods and defaults, specialize builtin calls, and fold the per-opcode polls into the eval breaker word (#878)
* build: enable thin LTO and codegen-units=1 for the release profile `cargo run --release` gets the same cross-crate optimization the `dist` profile already inherits. Assisted-by: Claude * interp: enter apply_all_thread_hooks only when a hook generation changed `all_thread_hooks_current` compares the execution context's `trace_all_generation` / `profile_all_generation` against the process counters, and `bytecode_trace` calls the updater only when they differ. `bytecode_trace` and `bytecode_only_trace` become `#[inline(always)]`. Assisted-by: Claude * interp: unwrap Method in the CALL Function valuestack fast path baseobjspace.py:1254-1259 unwraps `_Method` before the Function valuestack path. The CALL handler now writes the receiver into the `null_or_self` stack slot, continues with `w_function`, and passes `methodcall` to `funccall_valuestack` with the receiver counted as one extra argument (callmethod.py:85-94) while the popped width stays the physical `[callable, null_or_self, args...]`. Assisted-by: Claude * jit: require PYRE_JD1=1 for the jd1 unpackiterable driver jd1 shares the `MetaInterp.tracing` slot with the bytecode portal, so while a residual `next()` runs a generator body the jd1 trace holds only the opaque call and every jd0 merge point that body reaches is suppressed. Restore the opt-in gate; the master JIT off-switches still apply. Assisted-by: Claude * jit: read gc_interp::enabled once per eval_loop_jit activation The flag is process-stable after its first env read, so hoist it out of the per-bytecode safepoint poll instead of crossing the `dont_look_inside` gate and reloading its atomic on every iteration. Assisted-by: Claude * jit: cache the unsupported_jit_shape classification per code object `CallControl.graph_jit_shapes` holds the `UnsupportedJitShape` discriminant keyed by code-object address, alongside the graph-keyed `jitcodes`. `eval_with_jit_inner` classifies through the cache, and `maybe_compile_and_run` and `try_function_entry_jit` drop their repeated whole-frame scans, which ran on every back-edge and every Python call. Assisted-by: Claude * jit: specialize type(), dict.get(), int(), math.frexp and math.ldexp `register_jit_builtin_wrappers` records the `py_checked_arity_fn!` wrapper pointer each BuiltinCode actually stores for `math.sqrt`, `math.frexp` and `math.ldexp`; `is_math_sqrt_function` moves onto that comparison and `is_math_frexp_function` / `is_math_ldexp_function` join it. `jit_math_frexp_mantissa`, `jit_math_frexp_exponent` and `jit_math_ldexp_raw` are the raw entry points behind the emitted pure calls. `try_walker_specialize_builtin_type` lowers `space.type(w_obj)`. `try_walker_specialize_builtin_dict_get` guards the new `W_DictObject.keys_version` descriptor and reads the resolved entry value through `jit_dict_nth_value`. `try_walker_specialize_int_call`, `try_walker_specialize_math_frexp` and `try_walker_specialize_math_ldexp` join them in `dispatch_residual_call_iRd_kind`. Assisted-by: Claude * jit: inline bound-method calls and positional defaults in the walker `try_walker_inline_user_call` unwraps a `_Method` callable (baseobjspace.py:1254-1259), guards the Method class and its `w_function`, and passes the live `w_self` field as the receiver argument instead of baking one anchor's receiver. `try_walker_inline_resolved_user_call` fills a missing positional tail from `Function.defs_w` (function.py:188-193,217-231) behind a `GuardValue` on the new live `defs_w` descriptor, then reads each default out of the pinned tuple's `wrappeditems`. Method-form keyword calls prepend the receiver before the `kwnames` permutation. The blanket `POP_JUMP_IF_{NOT_}NONE` callee rejection is removed; `requires_seeded_callee_frame` declines instead when the callee frame is not actually seeded. `fbw_callee_body_replay_safety` takes per-parameter `ExactNumericArg` provenance and tracks the exact-numeric and exact-int facts separately per register and per frame slot, so `residual_call_is_specialized_plain_numeric_binop` judges the actual operands of each binop; `residual_call_is_specialized_plain_int_binop` supplies the bitwise-result fact. Assisted-by: Claude * Optimize translated JIT builtin call paths * majit: restrict the niche Option pointer fold to mutable references `tyref_is_niche_option_ptr` folded `Discriminant` to a pointer null test for `Option<&T>` as well as `Option<&mut T>` and `Option<NonNull<T>>`. `Option<&T>` is the `Iterator::next` result shape, and `front::iter_next` recognizes that call by its `__discriminant` match diamond; with the discriminant folded there is no diamond left to rewrite, so the residual `Iterator::next()` survives as the unregistered callee the rewrite removes. `type_node_is_ref` becomes `type_node_is_mut_ref` and reads the kind field of the Charon `{"Ref": [region, ty, kind]}` node instead of accepting any `Ref`. Fixes tests/test_iter_next.rs branch_loop_sum_lifts_next_to_iter_next_op and tests/test_desugar_mir.rs lowers_desugar_mix_with_aggregate_and_question_mark. Assisted-by: Claude * jit: restore two walker inline-call declines `try_walker_inline_resolved_user_call` declines again on: - a method-form callee whose body `method_form_callee_body_supported` rejects. The `requires_seeded_callee_frame` exemption is removed (synth/float_subclass_binop_dispatch, synth/polymorphic_binary_receiver). - a `Dirty` replay-safety body. Its residual can raise, and the local `except` that catches it is a callee-owned catch edge the inline path does not compile, so the exception escapes the caller instead of being handled where the source handles it (synth/bool_dunder_error_no_leak, synth/pickle_ctor_args). With `Dirty` rejected, `requires_seeded_callee_frame` is always false; it and the three branches it guarded are removed. Assisted-by: Claude * jit: roll the builtin-wrapper descent back instead of aborting the trace `try_walker_inline_builtin_call` propagated `OrthodoxSubWalkTraceUnsupported` out of its sub-walk with `?`. `try_execute_residual_call_via_executor` raises that error before running the call, and this walk is the authoritative executor, so the aborted trace resumed past a Python CALL whose effect never happened: `collections.deque.popleft()` returned its value and left the element in the deque (synth/gc_deque_backing_list grow_each_round 2966 -> 987234, one `popleft` dropped per trace transition). The descent now cuts the trace back to the position recorded before its first emitted op, resets the heap cache and returns `Ok(None)` so the ordinary residual call runs — the rollback `orthodox_list_append_commit`'s callers already use. It is gated on the descent having executed no journaled or unjournaled effect, since a descent that applied one cannot be rewound this way and keeps the abort. Assisted-by: Claude * fix(translator): decode current Charon const generics * fix(jit): preserve generated call frame semantics * fix(jit): rebuild materialized inline frames in bridges * perf(jit): cache bytecode dump configuration * fix(jit): preserve frontend guard source indices * fix(jit): bound nested-break hazard to inner loop
1 parent 6efa1e2 commit 8c87851

41 files changed

Lines changed: 3547 additions & 327 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,14 @@ tempfile = "3"
180180
walkdir = "2"
181181
insta = "1"
182182

183+
# `cargo run --release` is the benchmark/developer performance surface too.
184+
# Give it the same cross-crate optimization used by distributable binaries;
185+
# codegen-units=1 additionally lets LLVM optimize the interpreter's large
186+
# dispatch/call graph as one unit instead of preserving parallel-CGU barriers.
187+
[profile.release]
188+
lto = "thin"
189+
codegen-units = 1
190+
183191
# The profile that 'dist' will build with
184192
[profile.dist]
185193
inherits = "release"

majit/majit-gc/src/collector.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,19 @@ fn get_darwin_sysctl_signed(name: &[u8]) -> i64 {
113113
}
114114
}
115115

116-
/// env.py:413-433 `get_L2cache_darwin`. Returns the L2+L3 cache size in
117-
/// bytes via `sysctl`, or -1 when it cannot be determined.
116+
/// env.py:413-455 `get_L2cache_darwin`. Returns the performance-cluster
117+
/// L2 plus the legacy L3 cache size via `sysctl`, or -1 when it cannot be
118+
/// determined. Apple documents lower performance-level indices as faster
119+
/// cores, so `hw.perflevel0.l2cachesize` is the cache relevant to the cores
120+
/// running the mutator. Intel Macs do not expose that key and retain the
121+
/// legacy `hw.l2cachesize` fallback.
118122
#[cfg(target_os = "macos")]
119123
fn get_l2cache() -> i64 {
120-
let mangled = get_darwin_sysctl_signed(b"hw.l2cachesize\0")
121-
+ get_darwin_sysctl_signed(b"hw.l3cachesize\0");
124+
let mut l2cache = get_darwin_sysctl_signed(b"hw.perflevel0.l2cachesize\0");
125+
if l2cache <= 0 {
126+
l2cache = get_darwin_sysctl_signed(b"hw.l2cachesize\0");
127+
}
128+
let mangled = l2cache + get_darwin_sysctl_signed(b"hw.l3cachesize\0");
122129
if mangled > 0 { mangled } else { -1 }
123130
}
124131

majit/majit-ir/src/eval_breaker_word.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
//! OR'd in by the OS signal handler and the action dispatcher.
66
//! bit1 EB_STW — mirrors `GC_SYNC.stw_requested`; OR'd in by the collector
77
//! while it drains mutators to safepoints.
8+
//! bit2 EB_FINALIZING — mirrors interpreter finalization; once armed,
9+
//! non-owner mutators park before their next opcode.
10+
//! bit3 EB_GC_INTERP — process-stable `PYRE_GC_INTERP` dispatch gate. This
11+
//! is masked out of compiled back-edge polls: it avoids
12+
//! a second per-opcode atomic load in the interpreter,
13+
//! but is not itself a reason to leave machine code.
814
//! A compiled loop loads the whole word at the back-edge and deopts to the
915
//! interpreter when it is non-zero. The interpreter/warm-up loop and the STW
1016
//! park gate remain authoritative; this word is only the JIT's deopt trigger.
@@ -24,6 +30,12 @@ use std::sync::atomic::{AtomicUsize, Ordering};
2430
pub const EB_ASYNC: usize = 1;
2531
/// bit1 — GC stop-the-world requested (mirrors `GC_SYNC.stw_requested`).
2632
pub const EB_STW: usize = 2;
33+
/// bit2 — interpreter finalization has begun (terminal, never cleared).
34+
pub const EB_FINALIZING: usize = 4;
35+
/// bit3 — interpreter-path allocation/collection integration is enabled.
36+
pub const EB_GC_INTERP: usize = 8;
37+
/// Bits that require a compiled loop to deopt to the interpreter.
38+
pub const JIT_BREAKER_MASK: usize = EB_ASYNC | EB_STW | EB_FINALIZING;
2739

2840
/// The shared eval-breaker word (see module docs).
2941
static EVAL_BREAKER_WORD: AtomicUsize = AtomicUsize::new(0);
@@ -75,7 +87,17 @@ pub fn clear_stw() {
7587
EVAL_BREAKER_WORD.fetch_and(!EB_STW, Ordering::Release);
7688
}
7789

90+
pub fn set_finalizing() {
91+
EVAL_BREAKER_WORD.fetch_or(EB_FINALIZING, Ordering::Release);
92+
}
93+
94+
pub fn set_gc_interp() {
95+
EVAL_BREAKER_WORD.fetch_or(EB_GC_INTERP, Ordering::Release);
96+
}
97+
7898
/// Every flag must fit in the word the poll actually loads. Checked per target,
7999
/// so a flag too wide for a 32-bit `usize` fails the wasm32 build rather than
80100
/// silently reading as unarmed there.
81-
const _: () = assert!((EB_ASYNC | EB_STW) < (1 << (EVAL_BREAKER_WORD_SIZE * 8 - 1)));
101+
const _: () = assert!(
102+
(EB_ASYNC | EB_STW | EB_FINALIZING | EB_GC_INTERP) < (1 << (EVAL_BREAKER_WORD_SIZE * 8 - 1))
103+
);

majit/majit-metainterp/src/compile.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,15 @@ pub(crate) fn build_guard_metadata<T: AsRef<majit_ir::Op>>(
434434
.map_or(false, |d| d.is_resume_guard() || d.is_resume_guard_copied())
435435
{
436436
fd.set_fail_index_per_trace(fail_index);
437+
// `ops` is the optimized frontend trace retained by
438+
// `CompiledTrace`. The backend GC rewriter inserts
439+
// operations before code generation, so the assembler's
440+
// prepared-op index is not a valid index into this slice.
441+
// Re-stamp the canonical descr from the frontend op,
442+
// matching RPython where the ResumeGuardDescr remains
443+
// attached to the live ResOperation rather than carrying
444+
// an index into a separate rewritten array.
445+
fd.set_source_op_index(op_idx);
437446
}
438447
}
439448
}
@@ -2712,6 +2721,33 @@ mod tests {
27122721
);
27132722
}
27142723

2724+
#[test]
2725+
fn test_build_guard_metadata_restamps_frontend_source_op_index() {
2726+
let inputargs = vec![InputArg::new_int(0)];
2727+
let value = rooted_inputarg_operand(Type::Int, 0);
2728+
let prefix = Op::new(OpCode::SameAsI, std::slice::from_ref(&value));
2729+
let descr = make_fail_descr_with_index(0, 1);
2730+
let fd = descr.as_fail_descr().unwrap();
2731+
// The GC-rewritten backend trace can insert operations before a
2732+
// guard and temporarily stamp its prepared-op index on the shared
2733+
// descr. That index is not valid in the retained frontend trace.
2734+
fd.set_source_op_index(99);
2735+
let mut guard = Op::with_descr(
2736+
OpCode::GuardTrue,
2737+
std::slice::from_ref(&value),
2738+
descr.clone(),
2739+
);
2740+
guard.setfailargs(smallvec::smallvec![value]);
2741+
guard.set_fail_arg_types(vec![Type::Int]);
2742+
2743+
let (_resume_data, exit_layouts) =
2744+
build_guard_metadata(&inputargs, &[prefix, guard], 0, None);
2745+
let exit = exit_layouts.get(&0).expect("guard exit layout");
2746+
2747+
assert_eq!(exit.source_op_index, Some(1));
2748+
assert_eq!(fd.source_op_index(), Some(1));
2749+
}
2750+
27152751
#[test]
27162752
fn test_patch_new_loop_reemits_ops_through_forwarded_results() {
27172753
let mut vinfo = crate::virtualizable::VirtualizableInfo::new(0);

majit/majit-translate/src/codegen.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ mod tests {
172172
}],
173173
jitcodes: vec![main_jitcode],
174174
jitcodes_by_path: indexmap::IndexMap::new(),
175+
indirectcalltarget_indices: Vec::new(),
175176
insns: indexmap::IndexMap::new(),
176177
descrs: Vec::new(),
177178
ei_descr_mints: Vec::new(),

majit/majit-translate/src/codewriter/call.rs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -570,7 +570,13 @@ impl GraphStore {
570570
/// signature stays that of the shared graph, which the aliases resolve
571571
/// to anyway.
572572
pub(crate) fn insert(&mut self, path: CallPath, graph: FunctionGraph) {
573-
let key = (graph.owner_root.clone(), graph.name.clone());
573+
let key = (
574+
graph
575+
.source_identity
576+
.clone()
577+
.or_else(|| graph.owner_root.clone()),
578+
graph.name.clone(),
579+
);
574580
match self.graphs.get_mut(&key) {
575581
Some(existing) => {
576582
existing.graph.func.merge_from(&graph.func);
@@ -3191,6 +3197,17 @@ impl CallControl {
31913197
for path in &todo {
31923198
self.candidate_graphs.insert(path.clone());
31933199
}
3200+
// PyPy's portal reaches `BuiltinCode.funcrun`, whose `self.func` PBC
3201+
// contributes every gateway body to the indirect-call candidate set.
3202+
// Pyre's opcode walker lowers the equivalent Python CALL directly to
3203+
// `bh_call_fn`, bypassing that source-level dispatch graph, so seed the
3204+
// same generated-wrapper PBC family explicitly. This is the builtin
3205+
// gateway analogue of call.py:59-64's oopspec helper seeds below.
3206+
for path in self.builtin_wrapper_indirect_graphs() {
3207+
if self.candidate_graphs.insert(path.clone()) {
3208+
todo.push(path);
3209+
}
3210+
}
31943211
// call.py:59-64 — seed the BFS with builtin oopspec helpers so
31953212
// `int_abs` / `int_floordiv` / `int_mod` / `ll_math.ll_math_sqrt`
31963213
// are reachable even when the portal does not call them
@@ -3332,6 +3349,9 @@ impl CallControl {
33323349
// `c_graphs` family, `None` meaning "unknown
33333350
// family" and classifying the site as residual.
33343351
OpKind::IndirectCall { graphs, .. } => match graphs {
3352+
Some(graphs) if graphs.is_empty() => {
3353+
self.builtin_wrapper_indirect_graphs()
3354+
}
33353355
Some(graphs) => graphs.clone(),
33363356
None => continue,
33373357
},
@@ -3754,6 +3774,16 @@ impl CallControl {
37543774
let arc = self.get_jitcode(&portal);
37553775
self.jitdrivers_sd[jd_index].mainjitcode = Some(arc);
37563776
}
3777+
// RPython reaches `BuiltinCode.func` as an indirect SomePBC call
3778+
// while transforming the portal closure; handling that call invokes
3779+
// `get_jitcode()` for each candidate graph. Pyre's opcode walker
3780+
// emits `bh_call_fn` directly and therefore has no source-level
3781+
// indirect op to perform the allocation. Materialise the same PBC
3782+
// family here so runtime fnaddr dispatch can resolve each generated
3783+
// gateway body to its JitCode.
3784+
for wrapper in self.builtin_wrapper_indirect_graphs() {
3785+
self.get_jitcode(&wrapper);
3786+
}
37573787
}
37583788

37593789
/// RPython: `CallControl.enum_pending_graphs()` (call.py:150-153).
@@ -4585,6 +4615,35 @@ impl CallControl {
45854615
.collect()
45864616
}
45874617

4618+
/// Candidate PBC family for the generated `BuiltinCode.func`
4619+
/// function-pointer field.
4620+
///
4621+
/// RPython obtains this list from the annotator's `SomePBC`
4622+
/// descriptions. Pyre's generated wrappers publish real fnaddrs through
4623+
/// `jit_trace_fnaddrs`; pair those addresses with their registered source
4624+
/// graphs here. Aliases sharing an address name the same wrapper; select
4625+
/// the most-qualified source identity for the one graph object entered
4626+
/// into the PBC family.
4627+
pub fn builtin_wrapper_indirect_graphs(&self) -> Vec<CallPath> {
4628+
let mut by_address: std::collections::BTreeMap<i64, Vec<CallPath>> =
4629+
std::collections::BTreeMap::new();
4630+
for (path, &fnaddr) in &self.function_fnaddrs {
4631+
let Some(leaf) = path.last_segment() else {
4632+
continue;
4633+
};
4634+
if !leaf.starts_with("__pyre_wrap_") || !self.function_graphs.contains_key(path) {
4635+
continue;
4636+
}
4637+
by_address.entry(fnaddr).or_default().push(path.clone());
4638+
}
4639+
let mut result = Vec::new();
4640+
for mut aliases in by_address.into_values() {
4641+
aliases.sort_by_key(|path| std::cmp::Reverse(path.segments.len()));
4642+
result.push(aliases.remove(0));
4643+
}
4644+
result
4645+
}
4646+
45884647
/// RPython `call.py:259-280` — family-wide validation for indirect_call.
45894648
///
45904649
/// Rejects a family if any member is marked `_elidable_function_` /

majit/majit-translate/src/codewriter/jtransform.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2826,6 +2826,31 @@ impl<'a> Transformer<'a> {
28262826
kind: OpKind::ConstRefNull,
28272827
}]);
28282828
}
2829+
// `rbuiltin.py:412-418 rtype_const_result` /
2830+
// `translator/rtyper/rbuiltin.rs::rtype_ptr_null`: by the time
2831+
// jtransform runs, `ptr::null[_mut]()` is a typed null pointer
2832+
// constant, not a residual host call. Pyre's rtyper currently types an
2833+
// ephemeral oracle rather than rewriting the surviving model graph,
2834+
// so apply that literal rewrite here. This is the null half of the
2835+
// niche `Option<NonNull<T>>` / `Option<&T>` representation emitted by
2836+
// `front::mir`; leaving it as a call would bake an unregistered
2837+
// symbolic fnaddr into every generated nullity test.
2838+
if let CallTarget::FunctionPath { segments } = target
2839+
&& args.is_empty()
2840+
&& matches!(result_ty, ValueType::Ref(_))
2841+
&& matches!(
2842+
segments.as_slice(),
2843+
[owner, ptr, leaf]
2844+
if matches!(owner.as_str(), "core" | "std")
2845+
&& ptr == "ptr"
2846+
&& matches!(leaf.as_str(), "null" | "null_mut")
2847+
)
2848+
{
2849+
return RewriteResult::Replace(vec![SpaceOperation {
2850+
result: op.result.clone(),
2851+
kind: OpKind::ConstRefNull,
2852+
}]);
2853+
}
28292854
// `rewrite_op_cast_pointer` → `rewrite_op_same_as`
28302855
// (jtransform.py:254-257): the JIT does not distinguish a
28312856
// down-cast pointer from its source, so the
@@ -8671,6 +8696,36 @@ mod tests {
86718696
));
86728697
}
86738698

8699+
#[test]
8700+
fn ptr_null_builtin_rewrites_to_null_ref_constant() {
8701+
let config = GraphTransformConfig::default();
8702+
let mut graph = FunctionGraph::new("ptr_null_constant");
8703+
let entry = graph.startblock;
8704+
let result_var = graph
8705+
.push_op_var(
8706+
entry,
8707+
OpKind::Call {
8708+
target: CallTarget::function_path(["core", "ptr", "null_mut"]),
8709+
args: vec![],
8710+
result_ty: ValueType::Ref(None),
8711+
},
8712+
true,
8713+
)
8714+
.unwrap();
8715+
FunctionGraph::set_concretetype_of_inline(&result_var, ConcreteType::GcRef);
8716+
graph.set_return(entry, Some(result_var.clone()));
8717+
8718+
let result = transform_graph(&graph, &config);
8719+
let folded = result
8720+
.graph
8721+
.blocks
8722+
.iter()
8723+
.flat_map(|block| &block.operations)
8724+
.find(|op| op.result.as_ref() == Some(&result_var))
8725+
.expect("null result must survive as a constant definition");
8726+
assert!(matches!(folded.kind, OpKind::ConstRefNull));
8727+
}
8728+
86748729
/// PyPy parity regression guard: the qualified spellings
86758730
/// `Result::Ok`, `Option::Some`, `std::result::Result::Err` etc.
86768731
/// must elide identically to the bare `Ok` / `Some` / `Err`

0 commit comments

Comments
 (0)