From 5fc6b0c305edca17a232354eb9ea18d4952ace86 Mon Sep 17 00:00:00 2001 From: kyokuping Date: Mon, 10 Aug 2026 23:33:17 +0900 Subject: [PATCH 1/2] objspace: NaN and complex take Python 3.14 pointer identity Assisted-by: codex-5.6-sol --- .../nan_unboxed_storage_identity.py | 106 ++++++++++++++++++ pyre/pyre-interpreter/src/baseobjspace.rs | 34 ++---- pyre/pyre-interpreter/src/function.rs | 24 ++-- .../src/objspace/std/mapdict.rs | 13 ++- .../src/jitcode_dispatch/mod.rs | 10 ++ .../src/jitcode_dispatch/specialize.rs | 35 +++--- .../src/trace_helpers/typed_trace.rs | 11 +- pyre/pyre-object/src/listobject.rs | 18 ++- pyre/pyre-object/src/tupleobject.rs | 45 +++++++- 9 files changed, 225 insertions(+), 71 deletions(-) create mode 100644 pyre/extra_tests/parity_tests/nan_unboxed_storage_identity.py diff --git a/pyre/extra_tests/parity_tests/nan_unboxed_storage_identity.py b/pyre/extra_tests/parity_tests/nan_unboxed_storage_identity.py new file mode 100644 index 00000000000..1bfeee1d5c0 --- /dev/null +++ b/pyre/extra_tests/parity_tests/nan_unboxed_storage_identity.py @@ -0,0 +1,106 @@ +# CPython-suite gap: the suite checks NaN value semantics, not identity through containers. +# parity-tests reason: pyre's raw-f64 storage reboxes on read, which only `is` / `id()` sees. + +# Python 3.14 gives NaNs pointer identity. Raw-f64 list, tuple, and mapdict +# storage must therefore reject them instead of reboxing on read. These checks +# cover the interpreter and JIT paths; finite floats must remain eligible. + +n = float("nan") +m = float("nan") +f = 1.5 + +assert n is not m +assert id(n) != id(m) +assert n is n and n != n + +# --- arity-2 tuple: `makespecialisedtuple2` / Cls_ff ----------------------- +t = (n, n) +assert t[0] is n and t[1] is n + +# One NaN makes the whole pair ineligible for `Cls_ff`. +mixed = (f, n) +assert mixed[0] is f and mixed[1] is n + +# --- instance attribute: mapdict `UnboxedPlainAttribute` ------------------- +class C: + pass + + +c = C() +c.x = n +assert c.x is n and c.__dict__["x"] is n + +# An existing float slot must convert back to boxed storage. +c2 = C() +c2.y = f +c2.y = n +assert c2.y is n + +# --- list: FloatListStrategy / IntOrFloatListStrategy ---------------------- +lst = [n] +assert lst[0] is n + +lst2 = [1.0, 2.0] +lst2.append(n) +assert lst2[2] is n + +lst3 = [1, 2.0] +lst3.append(n) +assert lst3[2] is n + +# `list.index`/`count` compare by value; a NaN is found only via the identity +# shortcut `==` gets before the value compare. +assert lst.index(n) == 0 +assert lst.count(n) == 1 +assert n in lst + + +# Hand each finite-float trace a NaN on its final iteration. A guard failure +# exits the whole loop iteration, so each direct store needs its own loop. +def warm_attr(rounds): + obj = C() + obj.x = 0.5 + for i in range(rounds): + v = n if i == rounds - 1 else i * 0.5 + obj.x = v + return obj.x is n + + +def warm_setitem(rounds): + lst = [0.5] + for i in range(rounds): + v = n if i == rounds - 1 else i * 0.5 + lst[0] = v + return lst[0] is n + + +def warm_newlist(rounds): + for i in range(rounds): + v = n if i == rounds - 1 else i * 0.5 + got = [v] + return got[0] is n + + +def warm_append_empty(rounds): + for i in range(rounds): + v = n if i == rounds - 1 else i * 0.5 + got = [] + got.append(v) + return got[0] is n + + +def warm_append_float(rounds): + for i in range(rounds): + v = n if i == rounds - 1 else i * 0.5 + got = [0.5] + got.append(v) + return got[1] is n + + +assert warm_attr(3000) +assert warm_setitem(3000) +assert warm_newlist(3000) +assert warm_append_empty(3000) +assert warm_append_float(3000) + +print("OK") diff --git a/pyre/pyre-interpreter/src/baseobjspace.rs b/pyre/pyre-interpreter/src/baseobjspace.rs index 786285856e9..3fb464fec1f 100644 --- a/pyre/pyre-interpreter/src/baseobjspace.rs +++ b/pyre/pyre-interpreter/src/baseobjspace.rs @@ -4121,33 +4121,23 @@ pub fn is_w(w_one: PyObjectRef, w_two: PyObjectRef) -> bool { } // `W_FloatObject.is_w` (floatobject.py:196-204): two plain // `float`s are identical when their bit patterns are equal - // (`float2longlong`), so `0.0 is -0.0` is false and a NaN is its - // own identity. `float` subclasses (`user_overridden_class`) keep - // pointer identity — the exact-type gate excludes them. + // (`float2longlong`), so `0.0 is -0.0` is false. `float` subclasses + // (`user_overridden_class`) keep pointer identity — the exact-type + // gate excludes them. // - // This has to stay in step with `function::immutable_unique_id`, which - // derives `id()` from the same bits, and with `FloatListStrategy`, - // which unboxes and reboxes list elements: pointer identity here would - // make `a is b` false while `id(a) == id(b)` stayed true, and would - // make `[a][0] is a` false. + // CPython 3.14 gives NaNs pointer identity; unlike finite floats they + // stay boxed (`cpython_differences.rst:290-301`). if pyre_object::pyobject::is_exact_type(w_one, &pyre_object::pyobject::FLOAT_TYPE) && pyre_object::pyobject::is_exact_type(w_two, &pyre_object::pyobject::FLOAT_TYPE) { - return pyre_object::floatobject::w_float_get_value(w_one).to_bits() - == pyre_object::floatobject::w_float_get_value(w_two).to_bits(); - } - // `W_ComplexObject.is_w` (complexobject.py:287-301): two plain - // `complex`es are identical when both component bit patterns are - // equal (`float2longlong`). `complex` subclasses - // (`user_overridden_class`) keep pointer identity. - if pyre_object::pyobject::is_exact_type(w_one, &pyre_object::pyobject::COMPLEX_TYPE) - && pyre_object::pyobject::is_exact_type(w_two, &pyre_object::pyobject::COMPLEX_TYPE) - { - return pyre_object::complexobject::w_complex_get_real(w_one).to_bits() - == pyre_object::complexobject::w_complex_get_real(w_two).to_bits() - && pyre_object::complexobject::w_complex_get_imag(w_one).to_bits() - == pyre_object::complexobject::w_complex_get_imag(w_two).to_bits(); + let one = pyre_object::floatobject::w_float_get_value(w_one); + let two = pyre_object::floatobject::w_float_get_value(w_two); + if one.is_nan() || two.is_nan() { + return false; + } + return one.to_bits() == two.to_bits(); } + // CPython 3.14 gives complex objects pointer identity, handled above. // `W_AbstractTupleObject.is_w` (tupleobject.py:47-55): a `tuple` is // identical to another only when both are the empty tuple — "empty // tuples are unique-ified". Non-empty tuples keep pointer identity diff --git a/pyre/pyre-interpreter/src/function.rs b/pyre/pyre-interpreter/src/function.rs index 4ddde28e86f..6c475fe1008 100644 --- a/pyre/pyre-interpreter/src/function.rs +++ b/pyre/pyre-interpreter/src/function.rs @@ -2530,7 +2530,6 @@ pub unsafe fn fdel_func_doc(obj: PyObjectRef) -> Result<(), crate::PyError> { const IDTAG_SHIFT: i64 = 4; const IDTAG_INT: i64 = 1; const IDTAG_FLOAT: i64 = 5; -const IDTAG_COMPLEX: i64 = 7; const IDTAG_SPECIAL: i64 = 11; #[inline] @@ -2556,25 +2555,18 @@ pub fn immutable_unique_id(obj: PyObjectRef) -> Option { if is_exact_type(obj, &FLOAT_TYPE) { // `float2longlong(float_w(self))` reinterprets the f64 bits as // a signed i64; the same `| IDTAG_FLOAT` == `+ IDTAG_FLOAT`. - let bits = pyre_object::floatobject::w_float_get_value(obj).to_bits() as i64; + // NaNs use the address uid, matching `is_w`'s pointer identity. + let value = pyre_object::floatobject::w_float_get_value(obj); + if value.is_nan() { + return None; + } + let bits = value.to_bits() as i64; let b = (majit_rlib::rbigint::RBigInt::from(bits) << IDTAG_SHIFT as usize) + majit_rlib::rbigint::RBigInt::from(IDTAG_FLOAT); return Some(pyre_object::functional::range_bigint_to_obj(b)); } - if is_exact_type(obj, &COMPLEX_TYPE) { - // `(real_b << 64 | imag_b) << IDTAG_SHIFT | IDTAG_COMPLEX` - // (complexobject.py:303-314): the real bits are signed - // (`float2longlong`), the imag bits unsigned (`r_ulonglong`); - // the high/low 64-bit halves don't overlap, so each `|` is a - // `+`. - let real_bits = pyre_object::complexobject::w_complex_get_real(obj).to_bits() as i64; - let imag_bits = pyre_object::complexobject::w_complex_get_imag(obj).to_bits(); - let combined = (majit_rlib::rbigint::RBigInt::from(real_bits) << 64usize) - + majit_rlib::rbigint::RBigInt::from(imag_bits); - let b = (combined << IDTAG_SHIFT as usize) - + majit_rlib::rbigint::RBigInt::from(IDTAG_COMPLEX); - return Some(pyre_object::functional::range_bigint_to_obj(b)); - } + // Unlike PyPy's `complexobject.py:303-314`, CPython 3.14 complex + // identity is address-based, so there is no IDTAG_COMPLEX branch. if is_exact_type(obj, &TUPLE_TYPE) { // `W_AbstractTupleObject.immutable_unique_id` // (tupleobject.py:57-62): only the empty tuple is unique-ified diff --git a/pyre/pyre-interpreter/src/objspace/std/mapdict.rs b/pyre/pyre-interpreter/src/objspace/std/mapdict.rs index 74341de69ee..1dcc3bdb3da 100644 --- a/pyre/pyre-interpreter/src/objspace/std/mapdict.rs +++ b/pyre/pyre-interpreter/src/objspace/std/mapdict.rs @@ -3226,8 +3226,17 @@ unsafe fn is_unboxable_int(w_value: PyObjectRef) -> bool { /// Float half of `_pick_unbox_type`: PyPy uses /// `type(w_value) is space.FloatObjectCls`, so a float subclass must retain /// its boxed object and `w_class` too. -unsafe fn is_unboxable_float(w_value: PyObjectRef) -> bool { - if !unsafe { pyre_object::is_float(w_value) } { +/// +/// NaNs also stay boxed: raw-f64 storage reboxes on read and would lose their +/// CPython 3.14 pointer identity. Shared with the JIT STORE_ATTR fold and used +/// for both new and existing unboxed slots. +/// +/// # Safety +/// `w_value` must point to a live object. +pub unsafe fn is_unboxable_float(w_value: PyObjectRef) -> bool { + if !unsafe { pyre_object::is_float(w_value) } + || unsafe { pyre_object::w_float_get_value(w_value) }.is_nan() + { return false; } let exact = crate::typedef::gettypeobject(&pyre_object::FLOAT_TYPE); diff --git a/pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs b/pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs index f0042daffac..74e5eea41d3 100644 --- a/pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs +++ b/pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs @@ -8008,6 +8008,16 @@ fn walker_float_cmp_guard( walker_emit_guard_with_snapshot(ctx, op_pc, guard, &[c]) } +/// Guard the non-NaN precondition of raw-f64 storage. A class guard alone +/// admits NaNs; `raw != raw` sends them back to the boxed interpreter path. +fn walker_guard_float_not_nan( + ctx: &mut WalkContext<'_, '_, Sym>, + op_pc: usize, + raw: OpRef, +) -> Result<(), DispatchError> { + walker_float_cmp_guard(ctx, op_pc, OpCode::FloatNe, &[raw, raw], false) +} + /// Inline trace of `_pow` (floatobject.py, ported as /// `float_pow_inner`) for its fast paths: `y == 2.0` (`float_mul`), /// `y == 0.0` / `bx == 1.0` (constant result), and the mainstream diff --git a/pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs b/pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs index 71c02c2d06b..1f05a35f832 100644 --- a/pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs +++ b/pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs @@ -3979,9 +3979,11 @@ pub(crate) fn try_walker_specialize_store_attr( } } pyre_interpreter::objspace::std::mapdict::UnboxType::Float => { - // A non-float changes the slot to boxed storage and freezes further - // unboxing (mapdict.py), so retain setattr. - if !unsafe { pyre_object::pyobject::is_float(concrete_value) } { + // Match mapdict.py `_direct_write` exactly: subclasses and + // NaNs convert the slot to boxed storage. + if !unsafe { + pyre_interpreter::objspace::std::mapdict::is_unboxable_float(concrete_value) + } { return Ok(None); } } @@ -4015,6 +4017,7 @@ pub(crate) fn try_walker_specialize_store_attr( let live_f = unsafe { pyre_object::w_float_get_value(concrete_value) }; ctx.trace_ctx .set_opref_concrete(raw, majit_ir::Value::Float(live_f)); + walker_guard_float_not_nan(ctx, op_pc, raw)?; ( crate::helpers::jit_mapdict_unboxed_write_f as *const (), raw, @@ -4464,9 +4467,7 @@ pub(crate) fn try_walker_specialize_newlist( Emit::Int(vals) } ListStrategy::Float => { - // `all_floats` is strict `type(w) is W_FloatObject`, so every - // element is an exact `W_FloatObject` (`walker_unbox_float`'s - // `&FLOAT_TYPE` guard holds). + // `list_strategy_for` admits only exact, non-NaN floats here. let mut vals = Vec::with_capacity(len); for &p in &concretes { vals.push(unsafe { pyre_object::w_float_get_value(p) }); @@ -4523,6 +4524,7 @@ pub(crate) fn try_walker_specialize_newlist( let raw = walker_unbox_float(ctx, op_pc, it, float_type_addr)?; ctx.trace_ctx .set_opref_concrete(raw, majit_ir::Value::Float(v)); + walker_guard_float_not_nan(ctx, op_pc, raw)?; raws.push(raw); } crate::helpers::emit_typed_list_inline( @@ -9752,7 +9754,8 @@ unsafe fn orthodox_list_append_recognize( let int_ok = pyre_object::is_plain_int1(value) && !(pyre_object::tagged_int::CAN_BE_TAGGED && pyre_object::tagged_int::is_tagged_int(value)); - let float_ok = !value.is_null() && pyre_object::is_plain_float_strict(value); + // NaNs select Object storage to preserve identity. + let float_ok = pyre_object::is_float_strategy_item(value); // switch_to_correct_strategy routes `is_plain_int1` (exact int or // fits-in-word long) -> Integer with no tagged exclusion. Exclude any // plain-int / float from the object fallback so a tagged-int DECLINES @@ -9760,7 +9763,7 @@ unsafe fn orthodox_list_append_recognize( // traced strategy from the concrete one the commit installs. let obj_ok = !value.is_null() && !pyre_object::is_plain_int1(value) - && !pyre_object::is_plain_float_strict(value); + && !pyre_object::is_float_strategy_item(value); if !int_ok && !float_ok && !obj_ok { return None; } @@ -9782,14 +9785,10 @@ unsafe fn orthodox_list_append_recognize( // object items block — no unboxing, so the value carries no type // precondition. let obj_ok = pyre_object::w_list_uses_object_storage(inner_self) && !value.is_null(); - // Float-storage specialization: a strict `W_FloatObject` stored - // unboxed. `FloatListStrategy.is_correct_type` (listobject.py) is - // `type(w_obj) is W_FloatObject`, the strict predicate the body's Float - // arm also uses. No fits-* long analogue (a float is never re-boxed - // across arithmetic, unlike a fits-int W_LongObject). + // Match `FloatListStrategy.is_correct_type`; NaNs take the residual path + // that converts the receiver to Object storage. let float_ok = pyre_object::w_list_uses_float_storage(inner_self) - && !value.is_null() - && pyre_object::is_plain_float_strict(value); + && pyre_object::is_float_strategy_item(value); if !int_ok && !obj_ok && !float_ok { return None; } @@ -9886,7 +9885,7 @@ pub(crate) fn orthodox_list_append_commit( && pyre_object::tagged_int::is_tagged_int(value)); if int_ok { ListStrategy::Integer - } else if !value.is_null() && pyre_object::is_plain_float_strict(value) { + } else if pyre_object::is_float_strategy_item(value) { ListStrategy::Float } else { ListStrategy::Object @@ -11632,6 +11631,7 @@ pub(crate) fn try_walker_specialize_store_subscr( // route through the generic path — PyPy's IntegerListStrategy rejects a // W_BoolObject (`is_correct_type` is exact-type), switching the list to // object storage, so the int-storage fast path would drop the bool type. + // Float subclasses and NaNs switch the list to Object storage. // EXACT list only: a list SUBCLASS instance shares `ob_type == // &LIST_TYPE` but retags `w_class` and may override `__setitem__`; // `is_exact_list` excludes it so it falls to the generic residual @@ -11653,7 +11653,7 @@ pub(crate) fn try_walker_specialize_store_subscr( { 1i64 } else if pyre_object::w_list_uses_float_storage(list_obj) - && pyre_object::is_float(value_obj) + && pyre_object::is_float_strategy_item(value_obj) { 2i64 } else { @@ -11748,6 +11748,7 @@ pub(crate) fn try_walker_specialize_store_subscr( let elem = unsafe { pyre_object::w_float_get_value(value_obj) }; ctx.trace_ctx .set_opref_concrete(raw, majit_ir::Value::Float(elem)); + walker_guard_float_not_nan(ctx, op_pc, raw)?; crate::state::trace_float_block_setitem_value(ctx.trace_ctx, block, raw_index, raw); } diff --git a/pyre/pyre-jit-trace/src/trace_helpers/typed_trace.rs b/pyre/pyre-jit-trace/src/trace_helpers/typed_trace.rs index 9f5f051cb05..19e4fea9588 100644 --- a/pyre/pyre-jit-trace/src/trace_helpers/typed_trace.rs +++ b/pyre/pyre-jit-trace/src/trace_helpers/typed_trace.rs @@ -147,6 +147,14 @@ pub fn generated_list_setitem_by_strategy unreachable!(), @@ -593,8 +601,9 @@ unsafe fn detect_list_setitem_strategy( let unbox_long = pyre_object::pyobject::is_long(concrete_value); Some((1, unbox_long)) } else if pyre_object::w_list_uses_float_storage(concrete_obj) - && pyre_object::pyobject::is_float(concrete_value) + && pyre_object::is_float_strategy_item(concrete_value) { + // Match `FloatListStrategy.is_correct_type` (listobject.py:2061). Some((2, false)) } else { None diff --git a/pyre/pyre-object/src/listobject.rs b/pyre/pyre-object/src/listobject.rs index b25e99e5628..06a5116aaf5 100644 --- a/pyre/pyre-object/src/listobject.rs +++ b/pyre/pyre-object/src/listobject.rs @@ -538,8 +538,13 @@ fn all_ints(items: &[PyObjectRef]) -> bool { /// containers must retain the original NaN object so `[nan] == [nan]` is true /// for one shared object while two freshly-created NaNs still compare false. /// Keep NaNs in Object storage; ordinary exact floats retain PyPy's strategy. +/// +/// Shared with JIT list stores so traced and concrete strategies agree. +/// +/// # Safety +/// `item` must be null or point to a live object. #[inline] -unsafe fn is_float_strategy_item(item: PyObjectRef) -> bool { +pub unsafe fn is_float_strategy_item(item: PyObjectRef) -> bool { !item.is_null() && is_plain_float_strict(item) && !w_float_get_value(item).is_nan() } @@ -2391,16 +2396,7 @@ pub unsafe fn w_list_find_or_count_fast( let mut result: i64 = 0; let mut i = start.max(0); while i < stop { - // `FloatListStrategy._safe_find_or_count`: ordinary floats - // compare by value, while NaNs compare by their unwrapped - // bit pattern. The latter preserves the identity shortcut - // that `space.eq_w` would observe before the strategy erased - // the original W_FloatObject. - let matches = if target.is_nan() { - items[i as usize].to_bits() == target.to_bits() - } else { - items[i as usize] == target - }; + let matches = items[i as usize] == target; if matches { if count { result += 1; diff --git a/pyre/pyre-object/src/tupleobject.rs b/pyre/pyre-object/src/tupleobject.rs index 58a57891e71..803aa99d8d0 100644 --- a/pyre/pyre-object/src/tupleobject.rs +++ b/pyre/pyre-object/src/tupleobject.rs @@ -189,6 +189,8 @@ pub fn w_tuple_new(items: Vec) -> PyObjectRef { // PyPy can use `_ff` here because its object space gives plain floats // value identity. Pyre follows Python 3.14 pointer identity: `(x, x)` // must contain the exact `x` object, not two freshly boxed copies. + // Keep BUILD_TUPLE's existing Cls_oo shape; restoring Cls_ff for finite + // pairs is a separate JIT representation change. if unsafe { is_plain_float_strict(items[0]) && is_plain_float_strict(items[1]) } { return w_specialised_tuple_oo_new(items[0], items[1]); } @@ -480,13 +482,18 @@ pub unsafe fn w_tuple_set_cached_hash(obj: PyObjectRef, hash: i64) { /// /// Predicates: `listobject.py:2390 is_plain_int1` accepts exact /// `W_IntObject` (not bool, not int subclass) AND fits-int -/// `W_LongObject`; `type(w) is W_FloatObject` is strict identity. +/// `W_LongObject`; `type(w) is W_FloatObject` is strict identity. NaNs are +/// excluded because `Cls_ff` would lose their pointer identity when reboxing. pub fn makespecialisedtuple2(w_arg1: PyObjectRef, w_arg2: PyObjectRef) -> PyObjectRef { unsafe { if is_plain_int1(w_arg1) && is_plain_int1(w_arg2) { return w_specialised_tuple_ii_new(plain_int_w(w_arg1), plain_int_w(w_arg2)); } - if is_plain_float_strict(w_arg1) && is_plain_float_strict(w_arg2) { + if is_plain_float_strict(w_arg1) + && is_plain_float_strict(w_arg2) + && !w_float_get_value(w_arg1).is_nan() + && !w_float_get_value(w_arg2).is_nan() + { return w_specialised_tuple_ff_new( w_float_get_value(w_arg1), w_float_get_value(w_arg2), @@ -707,6 +714,40 @@ mod tests { } } + /// NaNs must decline the raw-f64 `Cls_ff` specialization. + #[test] + fn test_explicit_specialised_nan_pair_declines_ff() { + let nan = crate::floatobject::w_float_new(f64::NAN); + let tup = makespecialisedtuple2(nan, nan); + unsafe { + assert!(!is_specialised_tuple_ff(tup)); + assert!(is_specialised_tuple_oo(tup)); + assert_eq!(w_tuple_getitem(tup, 0).unwrap(), nan); + assert_eq!(w_tuple_getitem(tup, 1).unwrap(), nan); + } + + // One NaN slot is enough — both slots are stored raw. + let finite = crate::floatobject::w_float_new(1.5); + let mixed = makespecialisedtuple2(finite, nan); + unsafe { + assert!(!is_specialised_tuple_ff(mixed)); + assert_eq!(w_tuple_getitem(mixed, 0).unwrap(), finite); + assert_eq!(w_tuple_getitem(mixed, 1).unwrap(), nan); + } + } + + #[test] + fn test_arity2_nan_pair_preserves_boxed_identity() { + let nan = crate::floatobject::w_float_new(f64::NAN); + let tup = w_tuple_new(vec![nan, nan]); + unsafe { + assert!(!is_specialised_tuple_ff(tup)); + assert!(is_specialised_tuple_oo(tup)); + assert_eq!(w_tuple_getitem(tup, 0).unwrap(), nan); + assert_eq!(w_tuple_getitem(tup, 1).unwrap(), nan); + } + } + /// `specialisedtupleobject.py:176` checks `type(w) is W_FloatObject`. /// A float subclass keeps the W_FloatObject payload shape but has a /// different Python-level `w_class`, so it must fall through to `Cls_oo`. From 483c426dd7c63256e01e8c594a873fbab9f836d2 Mon Sep 17 00:00:00 2001 From: kyokuping Date: Wed, 12 Aug 2026 04:51:10 +0900 Subject: [PATCH 2/2] jit: pin w_class on the float list-store fast paths Assisted-By: Claude Opus 5 --- .../float_subclass_unboxed_storage.py | 119 ++++++++++++++++++ .../src/jitcode_dispatch/mod.rs | 2 +- .../src/jitcode_dispatch/specialize.rs | 39 +++++- .../src/trace_helpers/typed_trace.rs | 23 +++- 4 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 pyre/extra_tests/parity_tests/float_subclass_unboxed_storage.py diff --git a/pyre/extra_tests/parity_tests/float_subclass_unboxed_storage.py b/pyre/extra_tests/parity_tests/float_subclass_unboxed_storage.py new file mode 100644 index 00000000000..27e04b15074 --- /dev/null +++ b/pyre/extra_tests/parity_tests/float_subclass_unboxed_storage.py @@ -0,0 +1,119 @@ +# CPython-suite gap: the suite checks float-subclass arithmetic and coercion, +# not whether a subclass instance survives storage in a container. +# parity-tests reason: pyre's raw-f64 storage reboxes on read as an exact float, +# which only `type()` / `is` sees. + +# `FloatListStrategy.is_correct_type`, `AbstractAttribute._pick_unbox_type` and +# `makespecialisedtuple2` are all strict `type(w) is W_FloatObject`, so a float +# SUBCLASS instance must keep its box. pyre gives a subclass the same builtin +# `ob_type` and separates it only by `w_class`, so every JIT fast path that +# unboxes into raw-f64 storage needs a `w_class` guard, not just a class guard: +# a class guard alone lets a subclass reuse a trace recorded for exact floats. + +f = 1.5 + + +class F(float): + pass + + +s = F(2.5) + +assert type(s) is F +assert s == 2.5 +assert type(f) is float + +# --- arity-2 tuple: `makespecialisedtuple2` / Cls_ff ----------------------- +t = (s, s) +assert t[0] is s and t[1] is s + +# One subclass slot is enough — both slots are stored raw. +mixed = (f, s) +assert mixed[0] is f and mixed[1] is s + +# --- instance attribute: mapdict `UnboxedPlainAttribute` ------------------- +class C: + pass + + +c = C() +c.x = s +assert c.x is s and c.__dict__["x"] is s + +# An existing unboxed float slot must convert back to boxed storage. +c2 = C() +c2.y = f +c2.y = s +assert c2.y is s + +# --- list: FloatListStrategy / IntOrFloatListStrategy ---------------------- +lst = [s] +assert lst[0] is s + +lst2 = [1.0, 2.0] +lst2.append(s) +assert lst2[2] is s and type(lst2[0]) is float + +lst3 = [1, 2.0] +lst3.append(s) +assert lst3[2] is s + +lst4 = [1.0, 2.0] +lst4[0] = s +assert lst4[0] is s + +lst5 = [] +lst5.append(s) +assert lst5[0] is s + + +# Hand each finite-float trace a subclass instance on its final iteration. +# A guard failure exits the whole loop iteration, so each direct store needs +# its own loop. +def warm_attr(rounds): + obj = C() + obj.x = 0.5 + for i in range(rounds): + v = s if i == rounds - 1 else i * 0.5 + obj.x = v + return obj.x is s + + +def warm_setitem(rounds): + lst = [0.5] + for i in range(rounds): + v = s if i == rounds - 1 else i * 0.5 + lst[0] = v + return lst[0] is s + + +def warm_newlist(rounds): + for i in range(rounds): + v = s if i == rounds - 1 else i * 0.5 + got = [v] + return got[0] is s + + +def warm_append_empty(rounds): + for i in range(rounds): + v = s if i == rounds - 1 else i * 0.5 + got = [] + got.append(v) + return got[0] is s + + +def warm_append_float(rounds): + for i in range(rounds): + v = s if i == rounds - 1 else i * 0.5 + got = [0.5] + got.append(v) + return got[1] is s + + +assert warm_attr(3000) +assert warm_setitem(3000) +assert warm_newlist(3000) +assert warm_append_empty(3000) +assert warm_append_float(3000) + +print("OK") diff --git a/pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs b/pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs index 74e5eea41d3..3c5fb0918b7 100644 --- a/pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs +++ b/pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs @@ -8583,7 +8583,7 @@ enum WalkerStoreAttrSpecialization { /// /// # Safety /// `obj` must be a non-null, untagged heap object. -unsafe fn walker_exact_builtin_class( +pub(crate) unsafe fn walker_exact_builtin_class( obj: pyre_object::PyObjectRef, ) -> Option { unsafe { diff --git a/pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs b/pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs index 1f05a35f832..31825bbb9a6 100644 --- a/pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs +++ b/pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs @@ -4467,9 +4467,16 @@ pub(crate) fn try_walker_specialize_newlist( Emit::Int(vals) } ListStrategy::Float => { - // `list_strategy_for` admits only exact, non-NaN floats here. + // `list_strategy_for` admits only exact, non-NaN floats here. Its + // subclass term is enforced on replay by pinning each element's + // `w_class`; `is_plain_float_strict` also admits the null spelling + // of "exact float", which no pin can express, so decline such an + // element rather than emit a guard it would fail itself. let mut vals = Vec::with_capacity(len); for &p in &concretes { + if unsafe { walker_exact_builtin_class(p) }.is_none() { + return Ok(None); + } vals.push(unsafe { pyre_object::w_float_get_value(p) }); } Emit::Float(vals) @@ -4524,6 +4531,16 @@ pub(crate) fn try_walker_specialize_newlist( let raw = walker_unbox_float(ctx, op_pc, it, float_type_addr)?; ctx.trace_ctx .set_opref_concrete(raw, majit_ir::Value::Float(v)); + // `walker_unbox_float` guards `ob_type` only, which a float + // SUBCLASS instance shares; pin `w_class` so it side-exits + // instead of being unboxed into Float storage the interpreter + // would have declined (`all_floats` is strict). + walker_guard_exact_w_class( + ctx, + op_pc, + it, + pyre_object::pyobject::get_instantiate(&pyre_object::pyobject::FLOAT_TYPE), + )?; walker_guard_float_not_nan(ctx, op_pc, raw)?; raws.push(raw); } @@ -11655,6 +11672,14 @@ pub(crate) fn try_walker_specialize_store_subscr( } else if pyre_object::w_list_uses_float_storage(list_obj) && pyre_object::is_float_strategy_item(value_obj) { + // The subclass term of that predicate is enforced on replay by + // pinning `w_class` below. `is_plain_float_strict` also admits the + // null spelling of "exact float", which no pin can express, so + // decline such an operand here rather than emit a guard it would + // fail itself (see `walker_guard_exact_w_class`). + if walker_exact_builtin_class(value_obj).is_none() { + return Ok(None); + } 2i64 } else { return Ok(None); @@ -11748,6 +11773,18 @@ pub(crate) fn try_walker_specialize_store_subscr( let elem = unsafe { pyre_object::w_float_get_value(value_obj) }; ctx.trace_ctx .set_opref_concrete(raw, majit_ir::Value::Float(elem)); + // A float SUBCLASS instance shares `ob_type == &FLOAT_TYPE` (so it + // passes the unbox guard) but retags `w_class`; + // `FloatListStrategy.is_correct_type` rejects it, so the interpreter + // switches the list to Object storage instead of writing raw f64. + // Pin the canonical class the same way the list operand is pinned + // above, so such an instance side-exits to the generic residual. + walker_guard_exact_w_class( + ctx, + op_pc, + value_op, + pyre_object::pyobject::get_instantiate(&pyre_object::pyobject::FLOAT_TYPE), + )?; walker_guard_float_not_nan(ctx, op_pc, raw)?; crate::state::trace_float_block_setitem_value(ctx.trace_ctx, block, raw_index, raw); } diff --git a/pyre/pyre-jit-trace/src/trace_helpers/typed_trace.rs b/pyre/pyre-jit-trace/src/trace_helpers/typed_trace.rs index 19e4fea9588..2ee8db874bd 100644 --- a/pyre/pyre-jit-trace/src/trace_helpers/typed_trace.rs +++ b/pyre/pyre-jit-trace/src/trace_helpers/typed_trace.rs @@ -142,10 +142,24 @@ pub fn generated_list_setitem_by_strategy