Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0036f76
majit: attach source_lines context to a mergeinputargs UnionError
youknowone Jul 28, 2026
3c72c90
majit: quote the block's operations when a mergeinputargs union fails
youknowone Jul 28, 2026
efa25c3
pyre: residualize the SYS_MODULES registry read behind a marked accessor
youknowone Jul 28, 2026
f8f2e66
pyre: residualize the fd-write and thread-identity host seams
youknowone Jul 28, 2026
b680f11
majit: name the Vec index/index_mut acceptance test and pin both inte…
youknowone Jul 28, 2026
b46c9d8
pyre: split try_eq_w into has_eq_w_hook + eq_w_hooked leaves
youknowone Jul 28, 2026
f79a0bc
pyre: give IdentityDictStrategy::getitem a named residual lookup leaf
youknowone Jul 28, 2026
339f2a8
pyre: residualize the module-dict entries probe, not its router
youknowone Jul 28, 2026
6a4189f
pyre: spell the receiver-discounted argument count without saturating…
youknowone Jul 28, 2026
f76a814
pyre: index the optional generator-throw arguments instead of slice::get
youknowone Jul 28, 2026
6c312ff
pyre: take the hash_str pointer/length inside the residual boundary
youknowone Jul 28, 2026
e5edfc2
pyre: residualize both entry-table probes and the _warnings state nam…
youknowone Jul 28, 2026
0817cc3
pyre: residualize the five typed-storage promotions
youknowone Jul 28, 2026
cb10a54
pyre: residualize the identity delete and index the extended-slice walks
youknowone Jul 28, 2026
298b3e9
majit(front): fold wrapping_{add,sub,mul} on the unsigned word bank too
youknowone Jul 28, 2026
1d8cd33
pyre: residualize the three leaves on the module-dict delete path
youknowone Jul 28, 2026
af28070
pyre: residualize the checked module-dict probe and store
youknowone Jul 28, 2026
c438ec9
majit(front): make dyn-indirect routing the default
youknowone Jul 28, 2026
88dc263
pyre: spell the callable dispatch and the callback-free dict probe wi…
youknowone Jul 29, 2026
81d45ce
pyre, majit: clear eight census walls — the underflow report, the str…
youknowone Jul 29, 2026
2d87f58
majit: register vtable_method_ptr in the production blackhole's insns…
youknowone Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 27 additions & 6 deletions majit/majit-metainterp/src/blackhole.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7520,6 +7520,20 @@ pub fn build_inline_call_only_bh_builder() -> BlackholeInterpBuilder {
"abort_permanent/".to_string(),
majit_translate::insns::BC_ABORT_PERMANENT,
);
// `vtable_method_ptr/rd>i` — the dyn-trait method-pointer reification.
// Routing `dyn Trait` calls through `CallTarget::Indirect` is the
// default, so the codewriter now emits this byte into real jitcodes and
// this builder's dispatch table has to span it: `setup_insns(asm.insns)`
// (`blackhole.py:58-59`) resolves every opname the assembler emitted, and
// a byte outside the curated set reaches the unwired-opcode placeholder
// instead of the handler. The handler it binds is deliberately
// `handler_vtable_method_ptr_unimplemented` — no layer executes this op
// yet, so the point of the entry is that a resume landing here says which
// op is missing rather than reporting an unwired byte.
insns.insert(
"vtable_method_ptr/rd>i".to_string(),
majit_translate::insns::BC_VTABLE_METHOD_PTR,
);
// blackhole.py:954-960 bhimpl_switch. `handler_switch` is wired in
// `wire_bhimpl_handlers` but the byte was absent from this builder's
// insns map, so a deopt through a `switch/id` op landed on the
Expand Down Expand Up @@ -8628,12 +8642,19 @@ fn handler_guard_class(
Ok(p + 2)
}
/// `vtable_method_ptr` reaches the blackhole only when a `dyn Trait`
/// indirect call survives unfrozen into a metainterp resume. pyre's hot
/// path does not currently emit this pattern; intentionally panic so any
/// future regression is loud rather than silent. The codewriter still
/// emits the op + descriptor (TODO of
/// `rpython/rtyper/rclass.py:371-377 getclsfield()`) so the IR survives
/// serialization for the next integration step.
/// indirect call survives unfrozen into a metainterp resume.
///
/// The codewriter does emit the op + descriptor (TODO of
/// `rpython/rtyper/rclass.py:371-377 getclsfield()`) now that indirect
/// routing is the default, but no layer consumes it: there is no backend
/// lowering (`codewriter/assembler.rs`, "backend lowering of the actual
/// vtable slot read is not yet implemented"), the walker answers
/// `DispatchError::UnsupportedOpname`, and `PyreVtableMethodDescr` carries
/// the `(trait_root, method_name)` pair as strings with nothing that
/// resolves them to an address. The walker's abort is what keeps this
/// unreachable in practice — a trace meeting the op never compiles, so no
/// resume can land past it. Panic intentionally so that if one ever does,
/// it is loud rather than a silent miscompile.
fn handler_vtable_method_ptr_unimplemented(
_bh: &mut BlackholeInterpreter,
_code: &[u8],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,11 @@ Registration is driven two ways:
- **config** `PipelineConfig.register_trait_families: Vec<String>` (pipeline.rs:58) — a list of
trait qualified paths; harvested into `TraitFamilyRegistration` at lib.rs:1225, applied at
codewriter.rs:158. **Empty in production** (test_support.rs:51). NOT gated.
- **auto** every `>=2`-impl trait, gated behind `PYRE_DYN_INDIRECT` (lib.rs:1264). The gate
comment (lib.rs:1257) warns: minting base/impl subclass classdefs perturbs
`pyre_struct_root_names` → `ensure_session` inheritance-id numbering even off-path, so the
gate keeps prod byte-identical.
- **auto** every `>=2`-impl trait, behind `dyn_indirect_enabled()` — **on by default** since
the `__dyn_call` flip; `PYRE_DYN_INDIRECT=0` is the kill switch. The gate comment warns:
minting base/impl subclass classdefs perturbs `pyre_struct_root_names` → `ensure_session`
inheritance-id numbering even off-path, which is why the kill switch has to restore this
registration and the `__dyn_call` emit together.

The machinery is wired only to the **parameter-seeding** path (`derive_subject_inputcells`
trait-family arm, flowspace_adapter.rs:2823) — a `&dyn Trait` function argument. It is NOT
Expand All @@ -70,8 +71,8 @@ register_trait_families: vec!["pyre_object::dictmultiobject::DictStrategy".to_st

`trait_impl_owners` (lib.rs:1158, harvested from `concrete_trait_methods`) already maps this
qualified path to its 6 impl owners, so `make_registration` (lib.rs:1200) builds the family
with no further work. This runs through the NON-gated config path, so `PYRE_DYN_INDIRECT` is
untouched.
with no further work. This runs through the NON-gated config path, so `dyn_indirect_enabled()`
is untouched.

**RISK (measure first, in isolation):** the gate comment says config registration also mints
classdefs that shift `ensure_session` inheritance-id numbering. Whether one family shifts prod
Expand Down
46 changes: 41 additions & 5 deletions majit/majit-translate/src/annotator/annrpython.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2227,7 +2227,7 @@ impl RPythonAnnotator {
// reaching here means a call-site interface (recursivecall /
// addpendinggraph) leaked an unannotated arg through —
// fail-loud matches the upstream AttributeError.
let unions: Result<Vec<SomeValue>, UnionError> = oldcells
let unions: Result<Vec<SomeValue>, (usize, UnionError)> = oldcells
.iter()
.zip(inputcells.iter())
.enumerate()
Expand All @@ -2239,23 +2239,59 @@ impl RPythonAnnotator {
raised AttributeError)"
)
});
unionof([c1, s2])
unionof([c1, s2]).map_err(|e| (i, e))
})
.collect();

let unions = match unions {
Ok(u) => u,
Err(e) => {
Err((slot, e)) => {
// `annrpython.py:437-438` attaches the offending source to the
// UnionError before it is recorded or re-raised. `UnionError`
// renders only the two annotations, which on its own does not
// say which merge produced them — and the merging block is
// routinely an inlined callee, so the graph the caller was
// annotating is not the graph that failed. Carry the same
// `source_lines` context here, plus the input slot the two
// annotations belong to.
let source = crate::tool::error::source_lines(
graph,
Some(block),
None,
None,
true,
crate::tool::error::SHOW_DEFAULT_LINES_OF_CODE,
)
.join("\n");
// `source_lines1` answers `no source!` for every graph lowered
// from LLBC rather than from Python (`graph.source` is absent),
// which is all of them here. Quote the block's operations
// instead — the same "show the offending block" role upstream's
// source-line range plays, rendered the way `gather_error`
// already renders an operation (`tool/error.rs:498`).
let ops = {
let b = block.borrow();
if b.operations.is_empty() {
" <no operations>".to_string()
} else {
b.operations
.iter()
.map(|op| format!(" {op}"))
.collect::<Vec<_>>()
.join("\n")
}
};
let e = format!("{e}\n\n[mergeinputargs slot={slot}]\n{source}\n{ops}");
// Upstream keeps going when `self.keepgoing` is set;
// otherwise re-raises.
if self.keepgoing {
self.errors.borrow_mut().push(format!("{e}"));
self.errors.borrow_mut().push(e);
self.failed_blocks
.borrow_mut()
.insert(BlockKey::of(block), Rc::clone(block));
return;
}
panic!("UnionError in mergeinputargs: {}", e);
panic!("UnionError in mergeinputargs: {e}");
}
};

Expand Down
150 changes: 123 additions & 27 deletions majit/majit-translate/src/front/mir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8694,10 +8694,15 @@ impl<'a> Lowering<'a> {

/// `<String as AsRef<str>>::as_ref(&self) -> &str`,
/// `String::as_str(&self) -> &str`, `Wtf8::as_str(&self) -> &str`, and
/// `<String as Borrow<str>>::borrow(&self) -> &str` — every one
/// `<String as Borrow<str>>::borrow(&self) -> &str`, and pyre's
/// `as_str_unchecked(&Wtf8) -> &str` — every one
/// returns a `&str` view of the same string, an identity in the
/// lifted value model (Rust `String`/`&str`/`str`/`Wtf8`/`Wtf8Buf`
/// all lower to the immutable rpy_string). Without the intercept the
/// all lower to the immutable rpy_string). `as_str_unchecked` names
/// the view `Wtf8::as_str` gives once its validity arm has been taken
/// separately: `as_str` itself returns `Result<&str, Utf8Error>`,
/// whose destination does not strip to `str`, so it fails the gate
/// below and stays a wall. Without the intercept the
/// call keeps a `CallTarget::Method` `as_ref` getattr the rtyper
/// cannot route on the classdef-less string receiver (the `Cannot
/// find attribute "as_ref" on UnicodeString` wall). Bind the
Expand All @@ -8724,7 +8729,7 @@ impl<'a> Lowering<'a> {
let Some(leaf) = np.rsplit("::").next() else {
return false;
};
if !matches!(leaf, "as_ref" | "as_str" | "borrow") {
if !matches!(leaf, "as_ref" | "as_str" | "as_str_unchecked" | "borrow") {
return false;
}
if !first_arg_ty.is_some_and(|ty| tyref_is_string_value(ty, self.llbc)) {
Expand Down Expand Up @@ -10144,14 +10149,31 @@ impl<'a> Lowering<'a> {
/// and keeps the generic `Call` form (none arise today; the live
/// callers are `neg`'s `int_value` and `functional`'s `step`,
/// both `i64`).
/// Lower `i64::wrapping_{add,sub,mul}` (`core::num::<Impl>::wrapping_*`,
/// Opaque in the LLBC like every core fn) to the native
/// `BinOp("add"/"sub"/"mul")`. `Signed` arithmetic is modular
/// machine arithmetic — `rint.py rtype_add` emits `int_add` with no
/// overflow check, and `int_add` wraps (`rarithmetic.py intmask`
/// semantics) — so the wrapping method IS the plain llop. Restricted
/// to word-sized signed receivers; a narrower `wrapping_add` (which
/// wraps at its own width) keeps the `Call` form.
/// Lower `{i64,u64}::wrapping_{add,sub,mul}`
/// (`core::num::<Impl>::wrapping_*`, Opaque in the LLBC like every
/// core fn) to the native `BinOp("add"/"sub"/"mul")`. `Signed`
/// arithmetic is modular machine arithmetic — `rint.py:217
/// rtype_add` emits `int_add` with no overflow check, and `int_add`
/// wraps (`rarithmetic.py intmask` semantics) — so the wrapping
/// method IS the plain llop. Restricted to word-sized receivers; a
/// narrower `wrapping_add` (which wraps at its own width) keeps the
/// `Call` form.
///
/// Both integer banks count, the lesson [`vec_index_type_is_scalar`]
/// already carries: `usize` serializes as `{"UInt": "Usize"}`, which
/// `tyref_literal_int_atom` does not see at all, so an `Int`-only
/// test silently declined every unsigned counter (`keys_version`,
/// `clear_gen`) and left the enclosing graph blocked on the call.
/// Unsigned is the same machine op under a different rtyper
/// dispatch: `rint.py`'s `opprefix` makes it `uint_add`, and
/// `jtransform.py:1608-1610` renames `uint_{add,sub,mul}` straight
/// back to `int_{add,sub,mul}` for the JIT.
///
/// The result carries the receiver's signedness rather than a flat
/// `Int`. `union_type` widens `Int ∪ Unsigned` to `Unknown`
/// (`binaryop.py:191` UnionError), so annotating a `usize` sum as
/// `Int` would poison the merge where it meets the unsigned field
/// read it came from.
fn try_lower_wrapping_binop(
&mut self,
mir_bb: usize,
Expand Down Expand Up @@ -10187,7 +10209,9 @@ impl<'a> Lowering<'a> {
let Some(src) = fd.signature.inputs.first() else {
return Ok(false);
};
if !matches!(self.tyref_literal_int_atom(src), Some("I64" | "Isize")) {
let signed_word = matches!(self.tyref_literal_int_atom(src), Some("I64" | "Isize"));
let unsigned_word = matches!(self.tyref_literal_uint_atom(src), Some("U64" | "Usize"));
if !signed_word && !unsigned_word {
return Ok(false);
}
let bb_id = self.block_id[mir_bb];
Expand All @@ -10200,7 +10224,11 @@ impl<'a> Lowering<'a> {
op: op.to_string(),
lhs: lhs.clone(),
rhs: rhs.clone(),
result_ty: ValueType::Int,
result_ty: if unsigned_word {
ValueType::Unsigned
} else {
ValueType::Int
},
},
});
self.local_var[dest_local] = Some(res);
Expand Down Expand Up @@ -11597,10 +11625,11 @@ fn constants_call_leaf(reg: &RegularCall, llbc: &Llbc) -> Option<&'static str> {
/// so the index type must be an integer. Charon runs `monomorphize:false`,
/// so the signature keeps the generic index param `I` (a `TypeVar` typing
/// as `Ref`); the concrete index type is the callsite substitution
/// `types[1]` of the impl generics `[T, I, A]`. `Index<usize>` types as
/// `Unsigned` and signed integer indices as `Int`; both occupy RPython's
/// integer register bank. `Index<Range<…>>` resolves to a `Range*` Adt
/// (`Ref`) and returns `None`. Owner/leaf are derived the same way
/// `types[1]` of the impl generics `[T, I, A]`, which
/// [`vec_index_type_is_scalar`] classifies: `Index<usize>` types as
/// `Unsigned` and signed integer indices as `Int`, and both occupy RPython's
/// integer register bank, while `Index<Range<…>>` resolves to a `Range*` Adt
/// (`Ref`) and returns `None`. Owner/leaf are derived the same way
/// [`call_target_segments`]
/// derives the call key (`impl_method_owner_for_fundecl`). Free so the
/// same gate is shared by the call-lowering intercept and the deferred-write
Expand All @@ -11627,15 +11656,27 @@ fn vec_index_regular_leaf(reg: &RegularCall, llbc: &Llbc) -> Option<&'static str
.and_then(serde_json::Value::as_array)
.and_then(|tys| tys.get(1))
.and_then(|t| serde_json::from_value::<TyRef>(t.clone()).ok())
.is_some_and(|t| {
matches!(
tyref_to_value_type(&t, llbc),
ValueType::Int | ValueType::Unsigned
)
});
.is_some_and(|t| vec_index_type_is_scalar(&t, llbc));
int_indexed.then_some(leaf)
}

/// Whether `ty` is an index type [`vec_index_regular_leaf`] may lower to a
/// scalar `ArrayRead` / `ArrayWrite`.
///
/// Both integer banks count. `usize` — what essentially every real callsite
/// indexes with — serializes as `{"UInt": "Usize"}` and types as
/// [`ValueType::Unsigned`], so an `Int`-only test silently rejects it and
/// leaves the whole fold dead: the `#[ignore]`d real-LLBC anchor
/// `vec_index_mut_fill_user_function_args_real` failed with two residual
/// `index_mut` calls. A `Range` index types as `Ref` and is still rejected,
/// which is what the gate exists for.
fn vec_index_type_is_scalar(ty: &TyRef, llbc: &Llbc) -> bool {
matches!(
tyref_to_value_type(ty, llbc),
ValueType::Int | ValueType::Unsigned
)
}

/// Whether a [`RegularCall`] is a `Vec<T>` `index` **or** `index_mut` on an
/// integer index (see [`vec_index_regular_leaf`]).
fn is_vec_index_regular(reg: &RegularCall, llbc: &Llbc) -> bool {
Expand Down Expand Up @@ -14588,12 +14629,25 @@ fn tuple_per_shape_enabled() -> bool {
/// Route inline-Field `dyn Trait` virtual calls through the faithful
/// `CallTarget::Indirect` vtable pipeline instead of the synthetic
/// `__dyn_call` residual (see the `(CallClass::Dynamic, ..)` arm).
/// Default-OFF — `PYRE_DYN_INDIRECT=1` opts in; every other value (unset
/// included) keeps the inert `__dyn_call` emit.
///
/// `__dyn_call` is not a lowering, it is a placeholder: an unregistered
/// synthetic path that stops whatever graph reaches it. Routing through
/// `Indirect` is what `jtransform.py`'s `indirect_call` does, so this is the
/// faithful side. It was gated while the widening it forces — the annotator
/// now annotates every family member's body — outweighed what it bought;
/// census at the flip point, both states, same corpus:
///
/// | gate | records | biggest wall |
/// |---|---:|---|
/// | off | 243 | `__dyn_call` 48 |
/// | on | **224** | `Wtf8::as_str` 37 |
///
/// On by default; `PYRE_DYN_INDIRECT=0` is the kill switch that restores the
/// inert `__dyn_call` emit.
pub(crate) fn dyn_indirect_enabled() -> bool {
matches!(
!matches!(
std::env::var("PYRE_DYN_INDIRECT").as_deref(),
Ok("1") | Ok("true")
Ok("0") | Ok("false")
)
}

Expand Down Expand Up @@ -20353,6 +20407,48 @@ mod tests {
Llbc::from_slice(file.to_string().as_bytes()).expect("fixture Llbc parses")
}

/// The `Vec` index fold must accept a `usize` index.
///
/// `usize` types as `Unsigned`, not `Int`, so gating on `Int` alone left
/// both the read and write lifts dead for every real callsite — caught
/// only by the `#[ignore]`d real-LLBC anchor
/// (`vec_index_mut_fill_user_function_args_real`), which does not run in
/// CI. This pins the acceptance set without the 440MB corpus.
#[test]
fn vec_index_gate_accepts_both_integer_banks_and_rejects_range() {
let llbc = llbc_with_trait_impls(serde_json::json!([]));
let ty = |v: serde_json::Value| {
serde_json::from_value::<super::TyRef>(serde_json::json!({
"HashConsedValue": [0, v]
}))
.expect("fixture TyRef parses")
};
let usize_ty = ty(serde_json::json!({ "Literal": { "UInt": "Usize" } }));
assert_eq!(
super::tyref_to_value_type(&usize_ty, &llbc),
crate::model::ValueType::Unsigned,
"usize must type as Unsigned — the trap this gate fell into"
);
assert!(
super::vec_index_type_is_scalar(&usize_ty, &llbc),
"Vec index fold must accept a usize index"
);
assert!(
super::vec_index_type_is_scalar(
&ty(serde_json::json!({ "Literal": { "Int": "I64" } })),
&llbc
),
"Vec index fold must accept a signed index"
);
assert!(
!super::vec_index_type_is_scalar(
&ty(serde_json::json!({ "Adt": { "id": { "Adt": 7 } } })),
&llbc
),
"a Range index types as Ref and must stay rejected"
);
}

#[test]
fn trait_assoc_type_resolves_via_unique_impl() {
// `C::Name` with `impl Trait#1 for X { type Name = bool }` as
Expand Down
Loading
Loading