From bff00d8976904292eea979ea209d8e3c551aa9ec Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" Date: Fri, 7 Aug 2026 11:50:22 +0900 Subject: [PATCH] jit: fold Cls.__name__, and inline a method body that reads an attribute `type.__name__` is a GetSetProperty on the metatype, so `descr_getattribute` selects it ahead of the class's own MRO and its getter returns the class's `w_name` slot. A `LOAD_ATTR` of `__name__` on a class whose metaclass is exactly `type` now folds to that slot read -- guard_class for the W_TypeObject layout, guard_value pinning `w_class` to `type`, then getfield(w_name) + guard_nonnull -- instead of the opaque `getattr` CALL_MAY_FORCE residual. The class is not pinned, so several classes share one trace, and the slot is read live rather than baked because `descr_set__name__` replaces it without calling `mutated()`. Supporting pieces: `w_type_peek_name_obj` reads the slot without the lazy materialisation `w_type_get_name_obj` performs (a null slot declines rather than allocating inside the walker), `type_name_obj_fast_path` is the safety oracle, and `type_name_obj_descr` the field descr. The FOR_ITER deferred-admit gate declined an unbound method-form callee whose body carried a LoadAttr residual, because admitting one cost an abort before the callee-deny took effect. Those attribute reads fold now -- the mapdict one for an instance, this one for `cls.__name__` -- so the gate admits them; a body whose read does not fold still takes the one abort and is denied. `method_form_callee_body_supported` and `InlineBodyFacts::method_form_supported` had no other caller and are removed. Marginal cost per iteration, dynasm, differencing 200k against 2M: def at(self, i): return self.v + i 1091 ns -> 5.9 classmethod reading len(cls.__name__) 1082 ns -> 1.6 bench/synth/classmethod_protocol_hot 1508 ns -> 96.2 `synth/type_name_attr_fold` covers the fold's boundary: a rename seen through an inlined read, two classes on one trace, a class dict entry of the same name losing to the metatype descriptor, and the three metaclass shapes that must decline. `parity_tests/type_name_attr_identity` carries the name-object identity, which CPython preserves and PyPy does not. `classmethod_protocol_hot` and `type_metatype_method_call` go `loops_compiled` 2 -> 1 on all three backends: their callee is inlined instead of compiled as a separate function-entry trace. --- ...lassmethod_protocol_hot.cranelift.jitstats | 5 +- .../classmethod_protocol_hot.dynasm.jitstats | 5 +- .../classmethod_protocol_hot.wasm.jitstats | 5 +- ...pe_metatype_method_call.cranelift.jitstats | 5 +- .../type_metatype_method_call.dynasm.jitstats | 5 +- .../type_metatype_method_call.wasm.jitstats | 5 +- .../type_name_attr_fold.cranelift.jitstats | 14 ++ .../synth/type_name_attr_fold.dynasm.jitstats | 14 ++ pyre/bench/synth/type_name_attr_fold.py | 125 ++++++++++++++++++ .../synth/type_name_attr_fold.wasm.jitstats | 14 ++ .../parity_tests/type_name_attr_identity.py | 82 ++++++++++++ pyre/pyre-interpreter/src/baseobjspace.rs | 34 +++++ pyre/pyre-jit-trace/src/descr.rs | 28 ++++ .../src/jitcode_dispatch/inline_call.rs | 62 ++------- .../src/jitcode_dispatch/residual_call.rs | 16 +++ .../src/jitcode_dispatch/specialize.rs | 91 +++++++++++++ pyre/pyre-jit-trace/src/pyjitcode.rs | 2 - pyre/pyre-object/src/typeobject.rs | 11 ++ 18 files changed, 466 insertions(+), 57 deletions(-) create mode 100644 pyre/bench/synth/type_name_attr_fold.cranelift.jitstats create mode 100644 pyre/bench/synth/type_name_attr_fold.dynasm.jitstats create mode 100644 pyre/bench/synth/type_name_attr_fold.py create mode 100644 pyre/bench/synth/type_name_attr_fold.wasm.jitstats create mode 100644 pyre/extra_tests/parity_tests/type_name_attr_identity.py diff --git a/pyre/bench/synth/classmethod_protocol_hot.cranelift.jitstats b/pyre/bench/synth/classmethod_protocol_hot.cranelift.jitstats index a0796ff2cd2..59f22855e15 100644 --- a/pyre/bench/synth/classmethod_protocol_hot.cranelift.jitstats +++ b/pyre/bench/synth/classmethod_protocol_hot.cranelift.jitstats @@ -2,10 +2,13 @@ bridges_compiled=0 descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 +fbw_blackhole_adopted_multi_frame=0 +fbw_blackhole_adopted_single_frame=0 fbw_rolled_back_with_effects=0 +fbw_store_journal_rollback_failed=0 field_pos_attached_misplaced=0 field_pos_spec_misplaced=0 guard_failures=1 internal_compile_panics=0 loops_aborted=0 -loops_compiled=2 +loops_compiled=1 diff --git a/pyre/bench/synth/classmethod_protocol_hot.dynasm.jitstats b/pyre/bench/synth/classmethod_protocol_hot.dynasm.jitstats index a0796ff2cd2..59f22855e15 100644 --- a/pyre/bench/synth/classmethod_protocol_hot.dynasm.jitstats +++ b/pyre/bench/synth/classmethod_protocol_hot.dynasm.jitstats @@ -2,10 +2,13 @@ bridges_compiled=0 descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 +fbw_blackhole_adopted_multi_frame=0 +fbw_blackhole_adopted_single_frame=0 fbw_rolled_back_with_effects=0 +fbw_store_journal_rollback_failed=0 field_pos_attached_misplaced=0 field_pos_spec_misplaced=0 guard_failures=1 internal_compile_panics=0 loops_aborted=0 -loops_compiled=2 +loops_compiled=1 diff --git a/pyre/bench/synth/classmethod_protocol_hot.wasm.jitstats b/pyre/bench/synth/classmethod_protocol_hot.wasm.jitstats index a0796ff2cd2..59f22855e15 100644 --- a/pyre/bench/synth/classmethod_protocol_hot.wasm.jitstats +++ b/pyre/bench/synth/classmethod_protocol_hot.wasm.jitstats @@ -2,10 +2,13 @@ bridges_compiled=0 descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 +fbw_blackhole_adopted_multi_frame=0 +fbw_blackhole_adopted_single_frame=0 fbw_rolled_back_with_effects=0 +fbw_store_journal_rollback_failed=0 field_pos_attached_misplaced=0 field_pos_spec_misplaced=0 guard_failures=1 internal_compile_panics=0 loops_aborted=0 -loops_compiled=2 +loops_compiled=1 diff --git a/pyre/bench/synth/type_metatype_method_call.cranelift.jitstats b/pyre/bench/synth/type_metatype_method_call.cranelift.jitstats index a0796ff2cd2..59f22855e15 100644 --- a/pyre/bench/synth/type_metatype_method_call.cranelift.jitstats +++ b/pyre/bench/synth/type_metatype_method_call.cranelift.jitstats @@ -2,10 +2,13 @@ bridges_compiled=0 descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 +fbw_blackhole_adopted_multi_frame=0 +fbw_blackhole_adopted_single_frame=0 fbw_rolled_back_with_effects=0 +fbw_store_journal_rollback_failed=0 field_pos_attached_misplaced=0 field_pos_spec_misplaced=0 guard_failures=1 internal_compile_panics=0 loops_aborted=0 -loops_compiled=2 +loops_compiled=1 diff --git a/pyre/bench/synth/type_metatype_method_call.dynasm.jitstats b/pyre/bench/synth/type_metatype_method_call.dynasm.jitstats index a0796ff2cd2..59f22855e15 100644 --- a/pyre/bench/synth/type_metatype_method_call.dynasm.jitstats +++ b/pyre/bench/synth/type_metatype_method_call.dynasm.jitstats @@ -2,10 +2,13 @@ bridges_compiled=0 descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 +fbw_blackhole_adopted_multi_frame=0 +fbw_blackhole_adopted_single_frame=0 fbw_rolled_back_with_effects=0 +fbw_store_journal_rollback_failed=0 field_pos_attached_misplaced=0 field_pos_spec_misplaced=0 guard_failures=1 internal_compile_panics=0 loops_aborted=0 -loops_compiled=2 +loops_compiled=1 diff --git a/pyre/bench/synth/type_metatype_method_call.wasm.jitstats b/pyre/bench/synth/type_metatype_method_call.wasm.jitstats index a0796ff2cd2..59f22855e15 100644 --- a/pyre/bench/synth/type_metatype_method_call.wasm.jitstats +++ b/pyre/bench/synth/type_metatype_method_call.wasm.jitstats @@ -2,10 +2,13 @@ bridges_compiled=0 descr_set_absent=0 descr_set_ambiguous=0 descr_set_stale_absent=0 +fbw_blackhole_adopted_multi_frame=0 +fbw_blackhole_adopted_single_frame=0 fbw_rolled_back_with_effects=0 +fbw_store_journal_rollback_failed=0 field_pos_attached_misplaced=0 field_pos_spec_misplaced=0 guard_failures=1 internal_compile_panics=0 loops_aborted=0 -loops_compiled=2 +loops_compiled=1 diff --git a/pyre/bench/synth/type_name_attr_fold.cranelift.jitstats b/pyre/bench/synth/type_name_attr_fold.cranelift.jitstats new file mode 100644 index 00000000000..0e89d848618 --- /dev/null +++ b/pyre/bench/synth/type_name_attr_fold.cranelift.jitstats @@ -0,0 +1,14 @@ +bridges_compiled=0 +descr_set_absent=0 +descr_set_ambiguous=0 +descr_set_stale_absent=0 +fbw_blackhole_adopted_multi_frame=0 +fbw_blackhole_adopted_single_frame=0 +fbw_rolled_back_with_effects=0 +fbw_store_journal_rollback_failed=0 +field_pos_attached_misplaced=0 +field_pos_spec_misplaced=0 +guard_failures=4 +internal_compile_panics=0 +loops_aborted=1 +loops_compiled=5 diff --git a/pyre/bench/synth/type_name_attr_fold.dynasm.jitstats b/pyre/bench/synth/type_name_attr_fold.dynasm.jitstats new file mode 100644 index 00000000000..0e89d848618 --- /dev/null +++ b/pyre/bench/synth/type_name_attr_fold.dynasm.jitstats @@ -0,0 +1,14 @@ +bridges_compiled=0 +descr_set_absent=0 +descr_set_ambiguous=0 +descr_set_stale_absent=0 +fbw_blackhole_adopted_multi_frame=0 +fbw_blackhole_adopted_single_frame=0 +fbw_rolled_back_with_effects=0 +fbw_store_journal_rollback_failed=0 +field_pos_attached_misplaced=0 +field_pos_spec_misplaced=0 +guard_failures=4 +internal_compile_panics=0 +loops_aborted=1 +loops_compiled=5 diff --git a/pyre/bench/synth/type_name_attr_fold.py b/pyre/bench/synth/type_name_attr_fold.py new file mode 100644 index 00000000000..204965317f1 --- /dev/null +++ b/pyre/bench/synth/type_name_attr_fold.py @@ -0,0 +1,125 @@ +# pyre-check: max-pypy-ratio=18 +N = 60000 +M = 4000 + +# `cls.__name__` read from inside a method the enclosing `for` loop inlines. +# The read folds to the class's name slot under two guards -- the receiver is a +# type, and its metaclass is `type` -- so what this pins is the ways that can +# be wrong: a rename must be seen, because the slot is read live rather than +# baked, and its object identity must survive; two classes sharing the trace +# must each report their own name, since the fold deliberately does not pin +# which class arrived; a class dict entry of the same name must lose, because +# the metatype descriptor is consulted first; and any metaclass other than +# `type` must not be folded past at all. + + +class Base: + @classmethod + def tag(cls, i): + return i + len(cls.__name__) + + +class Derived(Base): + pass + + +class Longer(Base): + pass + + +class Shadowed(Base): + # `type.__name__` is a data descriptor on the metatype, so this entry is + # what an INSTANCE of the class reads and never what the class does. + __name__ = 'shadow-entry' + + +class GetattrMeta(type): + def __getattribute__(cls, name): + # 8 characters, which no class here is named, so the total says whether + # this ran. + if name == '__name__': + return 'via-meta' + return type.__getattribute__(cls, name) + + +class NameMeta(type): + # A metaclass may define `__name__` itself, and then it is the one that + # answers -- also 8 characters. + @property + def __name__(cls): + return 'metaname' + + +class PlainMeta(type): + # Answers exactly as `type` would, and still must not be folded past: the + # fold's precondition is the metaclass, not what it happens to do. + pass + + +class ByGetattr(metaclass=GetattrMeta): + @classmethod + def tag(cls, i): + return i + len(cls.__name__) + + +class ByNameProp(metaclass=NameMeta): + pass + + +class ByPlain(metaclass=PlainMeta): + pass + + +class Box: + def __init__(self, v): + self.v = v + + # The instance-attribute twin: the same inline the type-name fold reaches, + # over the mapdict fold instead. + def at(self, i): + return self.v + i + + +def folded(): + box = Box(4) + total = 0 + for i in range(N): + # Three classes down one trace; each must answer with its own name. + total = total + Derived.tag(i) - i + total = total + Longer.tag(i) - i + total = total + Shadowed.tag(i) - i + total = total + box.at(i) - i + # Renaming mid-loop, after the loop is compiled: the fold reads the + # name slot rather than a constant, so the shorter name is seen from + # the next iteration on. + if i == N // 2: + Derived.__name__ = 'D' + return total + + +def declined(): + # Every receiver here has a metaclass that is not `type`, so the fold must + # not run. Long enough to compile the loop and reach that decision, and no + # longer -- what this pins is the decision, not a speed. + total = 0 + for i in range(M): + total = total + ByGetattr.tag(i) - i + total = total + len(ByNameProp.__name__) + total = total + len(ByPlain.__name__) + return total + + +print(folded()) +print(declined()) +print(Derived.__name__, Longer.__name__, Shadowed.__name__) +print(Shadowed.__dict__['__name__'], Shadowed().__name__) +print(ByGetattr.__name__, ByNameProp.__name__, ByPlain.__name__) + +# A rename between two compiled loops, rather than inside one: the second loop +# reads the slot the first one's trace was built against, so a baked name would +# survive here too. +Box.__name__ = 'Renamed' +seen = None +for _ in range(M): + seen = Box.__name__ +print(seen) diff --git a/pyre/bench/synth/type_name_attr_fold.wasm.jitstats b/pyre/bench/synth/type_name_attr_fold.wasm.jitstats new file mode 100644 index 00000000000..0e89d848618 --- /dev/null +++ b/pyre/bench/synth/type_name_attr_fold.wasm.jitstats @@ -0,0 +1,14 @@ +bridges_compiled=0 +descr_set_absent=0 +descr_set_ambiguous=0 +descr_set_stale_absent=0 +fbw_blackhole_adopted_multi_frame=0 +fbw_blackhole_adopted_single_frame=0 +fbw_rolled_back_with_effects=0 +fbw_store_journal_rollback_failed=0 +field_pos_attached_misplaced=0 +field_pos_spec_misplaced=0 +guard_failures=4 +internal_compile_panics=0 +loops_aborted=1 +loops_compiled=5 diff --git a/pyre/extra_tests/parity_tests/type_name_attr_identity.py b/pyre/extra_tests/parity_tests/type_name_attr_identity.py new file mode 100644 index 00000000000..3f28ccb8aa4 --- /dev/null +++ b/pyre/extra_tests/parity_tests/type_name_attr_identity.py @@ -0,0 +1,82 @@ +"""Reading `Cls.__name__` hands back the object the rename was given. + +`type.__name__` is a getset on the metatype, and its setter stores the value +it was handed rather than a copy of the text: a `str` subclass assigned to a +heap type's `__name__` comes back as that same object, so a caller that put +an annotated string there can read its annotation off again. PyPy answers +with an exact `str` here; this is the CPython behaviour. + +The reads run hot enough to compile, because the JIT folds this attribute to +the name slot and a fold that baked the name instead of reading it would keep +answering with the name the trace was built against. +""" + +N = 30000 + + +class Name(str): + def __new__(cls, value, note): + self = str.__new__(cls, value) + self.note = note + return self + + +class Heap: + pass + + +original = Heap.__name__ +assert original == "Heap", original +assert type(original) is str, type(original) +# The object is stable across reads, not rebuilt per access. +assert Heap.__name__ is Heap.__name__ + +tagged = Name("Renamed", "from-parity-test") +Heap.__name__ = tagged +assert Heap.__name__ is tagged, Heap.__name__ +assert type(Heap.__name__) is Name, type(Heap.__name__) +assert Heap.__name__.note == "from-parity-test" + +# Hot enough to compile, and the identity has to survive that. +seen = None +for _ in range(N): + seen = Heap.__name__ +assert seen is tagged, seen + +# A rename after the loop is compiled is seen by it. +again = Name("Third", "second-rename") +Heap.__name__ = again +for _ in range(N): + seen = Heap.__name__ +assert seen is again, seen +assert seen.note == "second-rename" + +# `__qualname__` is a separate slot and the renames did not touch it. +assert Heap.__qualname__ == "Heap", Heap.__qualname__ + +# The class dict never gains a `__name__` entry from any of this -- the getset +# on the metatype owns the name, so the class's own mapping stays untouched. +assert "__name__" not in Heap.__dict__ + +# An instance reads its class's dict, not the metatype getset, so a class-level +# `__name__` entry is what an instance sees while the class keeps its own name. +class Shadowed: + __name__ = "entry" + + +for _ in range(N): + pair = (Shadowed.__name__, Shadowed().__name__) +assert pair == ("Shadowed", "entry"), pair + +# Immutable types refuse the rename and keep answering with their own name. +for immutable in (int, str, type): + before = immutable.__name__ + try: + immutable.__name__ = "nope" + except TypeError: + pass + else: + raise AssertionError("renamed the immutable type %r" % (immutable,)) + assert immutable.__name__ == before, immutable.__name__ + +print("OK") diff --git a/pyre/pyre-interpreter/src/baseobjspace.rs b/pyre/pyre-interpreter/src/baseobjspace.rs index 80c35df2931..c2edc5b9daf 100644 --- a/pyre/pyre-interpreter/src/baseobjspace.rs +++ b/pyre/pyre-interpreter/src/baseobjspace.rs @@ -9573,6 +9573,40 @@ pub unsafe fn classmethod_on_type_fast_path( Some((w_type, version_tag, w_func)) } +/// `Cls.__name__` read fast path: when `w_obj` is a class whose metaclass is +/// exactly `type`, return that metaclass and the class's name object. +/// +/// `__name__` is a `GetSetProperty` on `type`, so it is a DATA descriptor on +/// the metatype, and `descr_getattribute` (typeobject.py:814-819) hands the +/// read to it before consulting the class's own MRO — no `__name__` entry in +/// any base's dict can shadow it, and the MRO does not have to be walked at +/// all. Pinning the metatype to `type` itself is therefore the whole +/// precondition: `type` is immutable, so the getter cannot be replaced, and +/// what it returns is the `w_name` slot. +/// +/// The slot is reported rather than its contents because the walker must read +/// it live: `descr_set__name__` (typeobject.py:1046) replaces the name without +/// going through `mutated()`, so the version tag does not move when a class is +/// renamed and a baked name would outlive the rename. A slot that has not +/// been materialised yet (`PY_NULL`) declines rather than filling it in, since +/// filling it in means allocating. +/// +/// # Safety +/// `w_obj` must be a valid object pointer (null tolerated). +pub unsafe fn type_name_obj_fast_path(w_obj: PyObjectRef) -> Option<(PyObjectRef, PyObjectRef)> { + if w_obj.is_null() || !pyre_object::typeobject::is_type(w_obj) { + return None; + } + // `is_type` answers for the physical layout, which every type object + // shares; the metaclass is the `w_class` header read `getclass()` performs. + let metatype = crate::typedef::r#type(w_obj)?.as_ptr(); + if !std::ptr::eq(metatype, crate::typedef::w_type()) { + return None; + } + let w_name = pyre_object::typeobject::w_type_peek_name_obj(w_obj); + (!w_name.is_null()).then_some((metatype, w_name)) +} + /// `callmethod.py`'s `w_obj.getdictvalue(space, name)` shadowing check, /// restricted to a probe that neither allocates nor runs Python. /// diff --git a/pyre/pyre-jit-trace/src/descr.rs b/pyre/pyre-jit-trace/src/descr.rs index 8115811327e..dd580a63c03 100644 --- a/pyre/pyre-jit-trace/src/descr.rs +++ b/pyre/pyre-jit-trace/src/descr.rs @@ -2664,6 +2664,34 @@ pub fn type_version_tag_descr() -> DescrRef { TYPE_VERSION_TAG_FIELD_DESCR.clone() } +/// `typeobject.py:213 W_TypeObject.w_name` — the app-level `type.__name__` +/// object, which the `Cls.__name__` fold reads instead of the opaque `getattr` +/// residual. +/// +/// Mutable, unlike the version tag beside it: `descr_set__name__` +/// (typeobject.py:1046) replaces the slot without calling `mutated()`, so a +/// rename moves this field and nothing else. Reading it live is what makes +/// the fold survive one. +/// +/// One object per run for the identity reason [`W_CLASS_FIELD_DESCR`] +/// documents — `heap.rs` keys its field cache on the `Arc` pointer, so a +/// per-call descriptor would miss its own cache on every read. The size +/// follows the same descriptor's: 8 for a `PyObjectRef` on every target, not +/// the 4 bytes a wasm32 pointer occupies. `synth/type_name_attr_fold` reads +/// the same name under wasm as under both native backends. +static TYPE_NAME_OBJ_FIELD_DESCR: LazyLock = LazyLock::new(|| { + make_field_descr( + core::mem::offset_of!(pyre_object::typeobject::W_TypeObject, w_name), + 8, + Type::Ref, + false, + ) +}); + +pub fn type_name_obj_descr() -> DescrRef { + TYPE_NAME_OBJ_FIELD_DESCR.clone() +} + /// `celldict.py:32 ModuleDictStrategy.version` — the module-namespace version /// tag (`u64`, 8 bytes, unsigned) on the strategy box. /// diff --git a/pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs b/pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs index 032db441fb6..d121aec1e0d 100644 --- a/pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs +++ b/pyre/pyre-jit-trace/src/jitcode_dispatch/inline_call.rs @@ -466,32 +466,6 @@ pub(crate) fn callee_body_owns_loop_header(body_code: &[u8]) -> bool { crate::jitcode_runtime::decoded_ops(body_code).any(|op| op.opname == "jit_merge_point") } -/// Whether a method-form callee body is free of `LoadAttr` residuals. -/// -/// A `self.attr` read in the body is what makes it answer `false`, and that is -/// the common shape (`def at(self, i): return self.v + i`). No entry declines -/// on it any more; it names the bodies that reach the inline only through the -/// widened surface, which the two declines in -/// `try_walker_inline_resolved_user_call` are scoped to. -pub(crate) fn method_form_callee_body_supported( - body_code: &[u8], - callee_descr_refs: &[DescrRef], -) -> bool { - let mut pc = 0usize; - while pc < body_code.len() { - let Some(d) = crate::jitcode_runtime::decode_op_at(body_code, pc) else { - return false; - }; - if residual_call_helper_kind_in_body(body_code, &d, callee_descr_refs) - == Some(majit_ir::PyreHelperKind::LoadAttr) - { - return false; - } - pc = d.next_pc; - } - true -} - /// Whether sampling an exception string override before recording can have no /// app-visible effect. Portal-frame vable traffic and constant/int boxing are /// local; branches, other calls, and live-heap writes decline the sample. @@ -1603,7 +1577,6 @@ fn sub_jitcode_body_facts_for_code(code: *const ()) -> Option( if !bridge_rec_root_selfrec && fbw_hazardous_inline_denied(callee_code_key) { return Ok(None); } - // An unbound method-form callee whose body reads `self.attr`. FOR_ITER - // admits this widened surface only when the receiver is known not to be a - // type object: type-attribute reads can run through metatype descriptor - // dispatch and still reach the deferred abort path below. - let widened_method_form = - method_form && bound_method.is_none() && !body_facts.method_form_supported; - let widened_method_foriter_admissible = !widened_method_form - || callee_arg_concretes.first().is_some_and(|concrete| { - matches!( - concrete, - ConcreteValue::Ref(receiver) - if !receiver.is_null() && !unsafe { pyre_object::is_type(*receiver) } - ) - }); // A legacy, unseeded inline sub-walk inside a FOR_ITER body resumes a guard // at the caller's CALL boundary, so deopt re-executes the whole callee. // Replaying a live-heap mutation would double it, so a Dirty body stays on @@ -3012,13 +2971,19 @@ pub(crate) fn try_walker_inline_resolved_user_call( // body is still admitted from there — it has nothing that can // abort. // - // A widened method-form body is admitted only after proving the - // receiver is not a type object. The type receiver shape - // (`cls.__name__` through `type.__getattribute__`) reaches the - // deferred abort path, and that first abort retires the - // enclosing loop before the deny helps the next attempt. - // Declining it here reaches the same residual call with the - // loop intact. + // The widened method-form surface — an unbound callee whose + // body reads `self.attr` — was admitted here only once the + // receiver was proven not to be a type object, because a type + // receiver's read went through `type.__getattribute__` and + // reached the deferred abort path. That read folds now + // ([`try_walker_specialize_load_type_name_attr`]), so a type + // receiver reaches no residual to abort on either and the proof + // is no longer what admits it: a classmethod body reading + // `cls.__name__` measured 1082 ns/iter on the decline against + // 1.6 once admitted. A body whose attribute read does NOT fold + // — any metaclass other than `type` — still aborts once and is + // denied, which is what this arm's promise has always rested + // on. // // A callee with its own exception handler has protected-region // state that must be restored at the callee's precise resume @@ -3028,7 +2993,6 @@ pub(crate) fn try_walker_inline_resolved_user_call( // bodies on the residual path unless this scan proves them // clean. foriter_deferred_admit = arg_class_guard.is_none() - && widened_method_foriter_admissible && !body_facts.owns_loop_header && !pyre_interpreter::code_has_for_iter(callee_code) && !body_facts.has_exception_table diff --git a/pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs b/pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs index fd9b58bf7d1..71f66bd1c36 100644 --- a/pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs +++ b/pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs @@ -6007,6 +6007,22 @@ pub(crate) fn dispatch_residual_call_iIRd_kind( { return Ok((DispatchOutcome::Continue, op.next_pc)); } + // The plain-slot fold wants a mapdict instance and declines a + // class; `Cls.__name__` reads the slot the metatype getset + // returns instead. + if try_walker_specialize_load_type_name_attr( + ctx, + op.pc, + obj_opref, + w_code_ptr, + namei as usize, + dst, + dst_bank, + )? + .is_some() + { + return Ok((DispatchOutcome::Continue, op.next_pc)); + } // The plain-slot fold declines a `property` (data descriptor); // inline its Python getter instead of the opaque residual. if let Some(inlined) = try_walker_inline_property_get( diff --git a/pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs b/pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs index 9fc4ed38d7c..6e50bf5c53e 100644 --- a/pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs +++ b/pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs @@ -3235,6 +3235,97 @@ pub(crate) fn try_walker_specialize_load_classmethod_attr( Ok(Some(())) } +/// `Cls.__name__` `LOAD_ATTR` fold, on the safety oracle +/// [`pyre_interpreter::baseobjspace::type_name_obj_fast_path`]: a class whose +/// metaclass is exactly `type` resolves `__name__` through the metatype data +/// descriptor `type.__name__`, whose getter returns the class's `w_name` slot. +/// The fold is that slot read, under the guards that prove the receiver is +/// such a class: +/// +/// guard_class(obj, W_TypeObject layout) — `is_type` +/// guard_value(getfield(obj, w_class), type) — the metaclass is `type` +/// getfield(obj, w_name) + guard_nonnull — what the getter returns +/// +/// The class itself is NOT pinned, unlike the classmethod fold beside this +/// one: nothing here depends on which class arrived, so a loop reading +/// `cls.__name__` over several classes keeps one trace. Nor is the version +/// tag pinned — it is not what a rename moves (`descr_set__name__` skips +/// `mutated()`), and the live slot read already reports one. +/// +/// `w_name` is filled in on first read and never cleared, so the null the +/// guard covers is a class this trace has not served before; its side exit +/// runs the residual, which materialises the slot, and re-entry folds. +/// +/// Attempted inside an inlined callee sub-walk as well, on the same terms as +/// [`try_walker_specialize_load_attr`]: the guards prove the read, so the fold +/// cannot raise where the residual would not, and `cls.__name__` in a method +/// body is precisely where the opaque residual costs the most — it is what +/// makes the enclosing `FOR_ITER` decline to inline the callee at all. +#[allow(clippy::too_many_arguments)] +pub(crate) fn try_walker_specialize_load_type_name_attr( + ctx: &mut WalkContext<'_, '_, Sym>, + op_pc: usize, + obj: OpRef, + w_code_ptr: usize, + name_idx: usize, + dst: usize, + dst_bank: char, +) -> Result, DispatchError> { + if !ctx.is_authoritative_executor || dst_bank != 'r' { + return Ok(None); + } + let Some(concrete_obj) = walker_concrete_ref_object(ctx, obj) else { + return Ok(None); + }; + let Some(name) = walker_load_name_from_code(w_code_ptr, name_idx) else { + return Ok(None); + }; + if name != "__name__" { + return Ok(None); + } + let Some((metatype, w_name)) = + (unsafe { pyre_interpreter::baseobjspace::type_name_obj_fast_path(concrete_obj) }) + else { + return Ok(None); + }; + + // guard_class(obj, ob_type): the `W_TypeObject` layout both field reads + // below index into. `is_type` is this check. + let phys_type = unsafe { (*concrete_obj).ob_type } as i64; + if !ctx.trace_ctx.heap_cache().is_class_known(obj) { + let type_const = ctx.trace_ctx.const_int(phys_type); + walker_emit_fold_guard_with_snapshot(ctx, op_pc, OpCode::GuardClass, &[obj, type_const])?; + ctx.trace_ctx + .heap_cache_mut() + .class_now_known(obj, phys_type); + } + + // The metaclass, pinned to `type`: only then is `type.__name__` the + // descriptor `descr_getattribute` selects, and only then is it fixed — + // `type` is immutable, a user metaclass is not. + let w_class_op = walker_record_getfield_gc_r_uncached(ctx, obj, crate::descr::w_class_descr()); + let metatype_const = ctx.trace_ctx.const_ref(metatype as i64); + walker_emit_fold_guard_with_snapshot( + ctx, + op_pc, + OpCode::GuardValue, + &[w_class_op, metatype_const], + )?; + ctx.trace_ctx + .heap_cache_mut() + .replace_box(w_class_op, metatype_const); + + let name_op = + crate::state::opimpl_getfield_gc_r(ctx.trace_ctx, obj, crate::descr::type_name_obj_descr()); + walker_emit_fold_guard_with_snapshot(ctx, op_pc, OpCode::GuardNonnull, &[name_op])?; + ctx.trace_ctx.set_opref_concrete( + name_op, + majit_ir::Value::Ref(majit_ir::GcRef(w_name as usize)), + ); + write_residual_call_result_to_dst(ctx, op_pc, dst, dst_bank, name_op)?; + Ok(Some(())) +} + /// Fold the `LOAD_ATTR`-method `getattr` residual for a receiver whose name /// resolves to a plain builtin-code function on its type — the `lst.append` /// shape [`try_walker_specialize_load_method_attr`] declines because upstream diff --git a/pyre/pyre-jit-trace/src/pyjitcode.rs b/pyre/pyre-jit-trace/src/pyjitcode.rs index 5e2ec2e39de..742530cfe16 100644 --- a/pyre/pyre-jit-trace/src/pyjitcode.rs +++ b/pyre/pyre-jit-trace/src/pyjitcode.rs @@ -305,8 +305,6 @@ pub(crate) struct InlineBodyFacts { /// [`PyJitCodePayload::has_abort`], which the codewriter keeps narrowly /// scoped to `abort()` emissions. pub(crate) has_abort_permanent: bool, - /// Body shape the method-form inline route supports. - pub(crate) method_form_supported: bool, /// Straight-line body, the exception-override / property routes' entry test. pub(crate) exc_override_straight_line: bool, /// Effect-free enough to speculatively sample before emitting IR. diff --git a/pyre/pyre-object/src/typeobject.rs b/pyre/pyre-object/src/typeobject.rs index 5588679efba..1a664659ead 100644 --- a/pyre/pyre-object/src/typeobject.rs +++ b/pyre/pyre-object/src/typeobject.rs @@ -1371,6 +1371,17 @@ pub unsafe fn w_type_get_name_obj(obj: PyObjectRef) -> PyObjectRef { t.w_name } +/// The `type.__name__` slot as it stands, without the lazy materialisation +/// [`w_type_get_name_obj`] performs — `PY_NULL` until the first read of the +/// name has been served. +/// +/// The tracer reads it this way: materialising here would allocate a string +/// while the walker holds raw pointers into the heap, and a class whose name +/// has never been asked for is not one a hot loop is reading it from. +pub unsafe fn w_type_peek_name_obj(obj: PyObjectRef) -> PyObjectRef { + (*(obj as *const W_TypeObject)).w_name +} + /// Replace the class name (`descr_set__name__`, typeobject.py:1058 /// `w_type.name = name`). `name` is an owned `String` behind a raw /// pointer (`malloc_raw` = boxed); assigning through it drops the old