diff --git a/majit/majit-backend-dynasm/src/aarch64/assembler.rs b/majit/majit-backend-dynasm/src/aarch64/assembler.rs index 592c4628717..3d33ae563ad 100644 --- a/majit/majit-backend-dynasm/src/aarch64/assembler.rs +++ b/majit/majit-backend-dynasm/src/aarch64/assembler.rs @@ -183,6 +183,18 @@ const CC_LE: u8 = 12; // signed <= const CC_G: u8 = 13; // signed > /// Invert a condition code. +/// Widest value `cmp Xn, #imm` encodes — `codebuilder.py:389 CMP_ri`. +const MAX_CMP_IMM12: u32 = 4095; + +/// Forward reach of `b.cond`: a signed 19-bit displacement in 4-byte words. +const BCOND_FORWARD_RANGE: usize = 1 << 20; + +/// Ceiling assumed for one operation's machine code when deciding whether a +/// trace can use single-instruction guard branches. The largest emitters are +/// `Label`/`Jump` (one store per live register, ~120 bytes) and calls with a +/// full argument list; 1KB leaves roughly an 8x margin. +const MAX_BYTES_PER_OP: usize = 1024; + fn invert_cc(cc: u8) -> u8 { match cc { CC_O => CC_NO, @@ -277,6 +289,31 @@ pub struct AssemblerARM64<'a> { /// consumed by a following GUARD_TRUE/GUARD_FALSE. /// Stores an abstract condition code (CC_* constants). guard_success_cc: Option, + /// `(result location, condition code)` of the comparison emitted by the + /// immediately preceding `RegAllocOp`, so a `GUARD_TRUE`/`GUARD_FALSE` + /// that consumes exactly that result can branch on the live NZCV flags + /// instead of re-testing the materialized boolean. + /// `assembler.py:1186-1198 _walk_operations` folds the pair by handing + /// `prevop` to `guard_operations[...]`, which reaches + /// `regalloc.py:780 guard_impl` -> `dispatch_comparison(prevop)` and + /// returns the comparison's own `fcond`; the guard then emits only the + /// conditional branch. pyre's regalloc emits the two ops separately, + /// so the pairing is recognised here at emit time instead. + /// Cleared by every non-guard `RegAllocOp`, so only an ADJACENT pair + /// folds — the same `operations[i + 1]` window upstream uses. + pending_cmp_cc: Option<(RegLoc, u8)>, + + /// Whether guards must use the two-instruction long-branch form. + /// `b.cond` carries a signed 19-bit word displacement, so it reaches + /// +1MB; the failure-recovery stubs are written straight after the body, + /// so a guard reaches its stub whenever the trace's own code fits inside + /// that window. Only a trace too large for that has to pay the extra + /// inversion and `b`. Decided once per trace in `_assemble`. + long_guard_branch: bool, + + /// Offset of the current trace's first emitted byte, for the + /// `long_guard_branch` range check. + trace_start_offset: usize, /// x86/assembler.py:93 target_tokens_currently_compiling parity. /// Keyed by descriptor pointer identity (PyPy uses Python `is`). target_tokens_currently_compiling: IndexMap, @@ -473,6 +510,9 @@ impl<'a> AssemblerARM64<'a> { constants, next_slot: 0, guard_success_cc: None, + pending_cmp_cc: None, + long_guard_branch: false, + trace_start_offset: 0, target_tokens_currently_compiling: IndexMap::new(), compiled_target_tokens: Vec::new(), vtable_offset, @@ -1061,6 +1101,15 @@ impl<'a> AssemblerARM64<'a> { } /// Emit: CMP loc0, loc1 + /// `codebuilder.py:113 ADD_ri` — `add Xd, Xn, #imm12`. Encoded directly + /// because dynasm's immediate form cannot take a dynamic register (it + /// cannot tell whether the register is SP). + pub(crate) fn emit_add_ri(&mut self, rd: u8, rn: u8, imm: u32) { + debug_assert!(imm <= MAX_CMP_IMM12, "add immediate {imm} exceeds 12 bits"); + let word: u32 = (0b1001000100u32 << 22) | (imm << 10) | ((rn as u32) << 5) | (rd as u32); + dynasm!(self.mc ; .arch aarch64 ; .u32 word); + } + fn emit_cmp_loc_loc(&mut self, loc0: &Loc, loc1: &Loc) { // Load loc0 into x16 if needed, loc1 into x17 if needed let r0 = match loc0 { @@ -1075,6 +1124,22 @@ impl<'a> AssemblerARM64<'a> { } _ => return, }; + // `opassembler.py:129 emit_int_comp_op` takes `CMP_ri` when the + // right-hand side is an immediate; `codebuilder.py:389 CMP_ri` holds + // a 12-bit unsigned field, so anything wider still needs a register. + if let Loc::Immed(i) = loc1 { + if let Ok(imm) = u32::try_from(i.value) { + if imm <= MAX_CMP_IMM12 { + // dynasm's `cmp Xn|SP, #uimm` form cannot take a dynamic + // register operand, so encode it the way + // `codebuilder.py:389 CMP_ri` does: SUBS with Rd = xzr. + let word: u32 = + (0b1111000100u32 << 22) | (imm << 10) | ((r0 as u32) << 5) | 0b11111; + dynasm!(self.mc ; .arch aarch64 ; .u32 word); + return; + } + } + } let r1 = match loc1 { Loc::Reg(s) => s.value, Loc::Frame(f) => { @@ -1982,6 +2047,8 @@ impl<'a> AssemblerARM64<'a> { fn _assemble(&mut self, emit_prologue: bool) -> Result<(), BackendError> { let inputargs: &'a [InputArg] = self.inputargs; let ops: &'a [Op] = self.operations; + self.trace_start_offset = self.mc.offset().0; + self.long_guard_branch = ops.len().saturating_mul(MAX_BYTES_PER_OP) >= BCOND_FORWARD_RANGE; if emit_prologue { self._call_header(inputargs); } else { @@ -2076,6 +2143,10 @@ impl<'a> AssemblerARM64<'a> { ); } self.regalloc_mov(src, dst); + // A reload/spill/register move sits between the + // comparison and its guard, so they are no longer the + // adjacent pair `_walk_operations` folds. + self.pending_cmp_cc = None; continue; } RegAllocOp::Perform { @@ -2163,6 +2234,22 @@ impl<'a> AssemblerARM64<'a> { ); } + // The `MAX_BYTES_PER_OP` estimate that let this trace use + // single-instruction guard branches has to have held: every guard's + // stub is written after this point, so the body plus the stubs must + // stay inside `b.cond`'s forward reach. One stub is a handful of + // instructions per guard, so charge 64 bytes each. + debug_assert!( + self.long_guard_branch + || (self.mc.offset().0 - self.trace_start_offset) + + self.pending_guard_tokens.len() * 64 + < BCOND_FORWARD_RANGE, + "trace emitted {} bytes with {} guards but used single-instruction \ + guard branches; MAX_BYTES_PER_OP is too small", + self.mc.offset().0 - self.trace_start_offset, + self.pending_guard_tokens.len(), + ); + // assembler.py:1167-1171 `_assemble`: grow the frame to fit a // cross-loop JUMP target. The closing `br` jumps into the target // loop's body, which can use deeper frame slots than this trace; for @@ -2186,6 +2273,10 @@ impl<'a> AssemblerARM64<'a> { fail_index: u32, ops: &[Op], ) { + // Only an ADJACENT comparison/guard pair may share NZCV; anything + // emitted in between invalidates the record (the comparison arms + // below re-arm it). + self.pending_cmp_cc = None; match op.opcode { OpCode::IntAddOvf => { // RPython aarch64/opassembler.py int_add_impl parity — @@ -2221,8 +2312,11 @@ impl<'a> AssemblerARM64<'a> { } } OpCode::IntMulOvf => { - // aarch64/opassembler.py emit_comp_op_int_mul_ovf: smulh+mul+asr+cmp - // against the 64-bit sign-extended high half. + // `opassembler.py:94 emit_comp_op_int_mul_ovf`: the product + // overflowed iff the high half differs from the low half's + // sign extension. `CMP_rr_shifted(ip0, res, 63)` is + // `cmp Xn, Xm, asr #63`, which does the shift as part of the + // compare rather than through a scratch register. if let (Some(Loc::Reg(dst)), Some(lhs), Some(src)) = (result_loc, arglocs.first(), arglocs.get(1)) { @@ -2230,8 +2324,7 @@ impl<'a> AssemblerARM64<'a> { dynasm!(self.mc ; .arch aarch64 ; smulh x15, X(lhs_reg as u8), X(src_reg as u8) ; mul X(dst.value), X(lhs_reg as u8), X(src_reg as u8) - ; asr x14, X(dst.value), 63 - ; cmp x15, x14 + ; cmp x15, X(dst.value), asr #63 ); self.guard_success_cc = Some(CC_E); } @@ -2311,21 +2404,19 @@ impl<'a> AssemblerARM64<'a> { if arglocs.len() >= 2 { self.emit_cmp_loc_loc(&arglocs[0], &arglocs[1]); } - if let Some(Loc::Reg(r)) = result_loc { - let cc = Self::opcode_to_cc(op.opcode); - self.emit_setcc(cc, r.value); - } + let cc = Self::opcode_to_cc(op.opcode); + self.flush_cc(cc, result_loc); } OpCode::IntIsTrue => { - if let (Some(src), Some(Loc::Reg(r))) = (arglocs.first(), result_loc) { + if let Some(src) = arglocs.first() { self.emit_test_loc(src); - self.emit_setcc(CC_NE, r.value); + self.flush_cc(CC_NE, result_loc); } } OpCode::IntIsZero => { - if let (Some(src), Some(Loc::Reg(r))) = (arglocs.first(), result_loc) { + if let Some(src) = arglocs.first() { self.emit_test_loc(src); - self.emit_setcc(CC_E, r.value); + self.flush_cc(CC_E, result_loc); } } OpCode::UintMulHigh => { @@ -2475,10 +2566,11 @@ impl<'a> AssemblerARM64<'a> { scratch }; dynasm!(self.mc ; .arch aarch64 ; fcmp D(a.value), D(b.value)); - if let Some(Loc::Reg(r)) = result_loc { - let cc = Self::float_opcode_to_cc(op.opcode); - self.emit_setcc(cc, r.value); - } + // `opassembler.py:138 emit_comp_op_float_*` returns the + // condition instead of materialising a boolean, so an + // adjacent guard branches straight off `fcmp`'s NZCV. + let cc = Self::float_opcode_to_cc(op.opcode); + self.flush_cc(cc, result_loc); } } // ── Casts ── @@ -2886,9 +2978,11 @@ impl<'a> AssemblerARM64<'a> { // to load(ip0, ofs_loc) + ADD_rr. ip0 = x16 (reserved scratch). let combined_index = if ofs != 0 { if (0..4096).contains(&ofs) { - dynasm!(self.mc ; .arch aarch64 - ; mov x16, X(index.value) - ; add x16, x16, ofs as u32); + // `opassembler.py:403 ADD_ri(ip0, index, ofs)` is a single + // instruction; dynasm's `add Xd|SP, Xn|SP, #uimm` form rejects a + // dynamic register operand, so encode the word directly + // (`codebuilder.py:113 ADD_ri`). + self.emit_add_ri(16, index.value, ofs as u32); } else { self.emit_mov_imm64(16, ofs); dynasm!(self.mc ; .arch aarch64 @@ -3424,16 +3518,18 @@ impl<'a> AssemblerARM64<'a> { OpCode::GuardTrue | OpCode::VecGuardTrue | OpCode::GuardNonnull => { // arglocs[0] = condition location if let Some(loc) = arglocs.first() { - self.emit_test_loc(loc); - self.guard_success_cc = Some(CC_NE); + self.load_condition_into_cc(loc); } self.implement_guard_with_faillocs(op, op_index, fail_index, faillocs); } + // `assembler.py:1777 genop_guard_guard_false` inverts the + // published cc, then implements: a folded IntLt that published + // CC_L becomes a CC_L failure jump here. OpCode::GuardFalse | OpCode::VecGuardFalse | OpCode::GuardIsnull => { if let Some(loc) = arglocs.first() { - self.emit_test_loc(loc); - self.guard_success_cc = Some(CC_E); + self.load_condition_into_cc(loc); } + self.guard_success_cc = self.guard_success_cc.map(invert_cc); self.implement_guard_with_faillocs(op, op_index, fail_index, faillocs); } OpCode::GuardValue => { @@ -3527,6 +3623,10 @@ impl<'a> AssemblerARM64<'a> { self.implement_guard_nojump_with_faillocs(op, op_index, fail_index, faillocs); } } + // Every arm emits its own flag-setting code, so a comparison record + // that `load_condition_into_cc` did not consume cannot describe the + // live NZCV any more. + self.pending_cmp_cc = None; } /// Helper: guard class comparison @@ -3787,19 +3887,90 @@ impl<'a> AssemblerARM64<'a> { } } - /// Map a float comparison OpCode to a condition code (after ucomisd). + /// Map a float comparison OpCode to a condition code (after `fcmp`). + /// + /// `fcmp` sets NZCV = 0b0011 when either operand is NaN, so C is SET and + /// V is SET for an unordered compare. Only conditions that are false in + /// that state may be used for `<`, `<=`, `>`, `>=`; `==` must be false and + /// `!=` true. `opassembler.py:310-315` picks exactly these: + /// `VFP_LT` = `lo` (C clear), `VFP_LE` = `ls` (C clear or Z set), + /// `gt` (Z clear and N == V), `ge` (N == V), `eq`, `ne`. + /// + /// `hi`/`hs` — the x86 `seta`/`setae` spelling, correct after `ucomisd` + /// because that sets CF on unordered — are both TRUE after an unordered + /// `fcmp` and must not be used here. fn float_opcode_to_cc(opcode: OpCode) -> u8 { match opcode { - OpCode::FloatLt => CC_B, // ucomisd: below = less than - OpCode::FloatLe => CC_BE, // below or equal - OpCode::FloatGt => CC_A, // above - OpCode::FloatGe => CC_AE, // above or equal - OpCode::FloatEq => CC_E, // equal - OpCode::FloatNe => CC_NE, // not equal + OpCode::FloatLt => CC_B, // lo: C clear + OpCode::FloatLe => CC_BE, // ls: C clear or Z set + OpCode::FloatGt => CC_G, // gt: Z clear and N == V + OpCode::FloatGe => CC_GE, // ge: N == V + OpCode::FloatEq => CC_E, // eq + OpCode::FloatNe => CC_NE, // ne _ => CC_E, } } + /// `x86/regalloc.py:265 force_allocate_reg_or_cc` hands a comparison the + /// frame register as its result when the next op consumes the flags + /// directly (`RegAlloc::force_allocate_reg_or_cc`). That is a sentinel, + /// not a real destination — `cset x29, cc` would destroy the frame + /// pointer — so publish the condition for the following guard and emit + /// nothing. Otherwise materialize the boolean and remember the pair so + /// an adjacent guard can still branch on the live flags. + fn flush_cc(&mut self, cond: u8, result_loc: Option<&Loc>) { + // `assembler.py:1293 flush_cc` opens with + // `assert self.guard_success_cc == rx86.cond_none` — a condition + // still pending here was published by an earlier op and never + // consumed, which would make the following guard branch on it. + debug_assert!( + self.guard_success_cc.is_none(), + "flush_cc: guard_success_cc already set", + ); + let frame_reg_value = crate::aarch64::regalloc::frame_reg().value; + if let Some(Loc::Reg(r)) = result_loc { + if r.value == frame_reg_value { + self.guard_success_cc = Some(cond); + return; + } + self.emit_setcc(cond, r.value); + self.pending_cmp_cc = Some((*r, cond)); + } + } + + /// The comparison NZCV flags are still live iff the immediately + /// preceding `RegAllocOp` was a comparison writing exactly `loc` — the + /// `operations[i + 1]` adjacency `assembler.py:1186 _walk_operations` + /// requires before folding a comparison into its guard. Every other + /// `RegAllocOp` clears the record, so nothing in between can have + /// clobbered the flags. Returns the condition under which the + /// comparison was TRUE. + fn take_pending_cmp_cc(&mut self, loc: &Loc) -> Option { + let (cmp_reg, cc) = self.pending_cmp_cc.take()?; + let Loc::Reg(guard_reg) = loc else { + return None; + }; + (cmp_reg.value == guard_reg.value && cmp_reg.is_xmm == guard_reg.is_xmm).then_some(cc) + } + + /// `x86/regalloc.py:429 load_condition_into_cc` on the emit side. The + /// comparison either published its condition through the frame-register + /// sentinel (`flush_cc`) or materialized a boolean; in the second case + /// an ADJACENT comparison still left its flags live, so branch on those. + /// Only when neither holds is the operand an unrelated boolean that has + /// to be re-tested. + fn load_condition_into_cc(&mut self, loc: &Loc) { + if self.guard_success_cc.is_some() { + return; + } + if let Some(cc) = self.take_pending_cmp_cc(loc) { + self.guard_success_cc = Some(cc); + return; + } + self.emit_test_loc(loc); + self.guard_success_cc = Some(CC_NE); + } + /// Guard with faillocs — emit conditional jump and store faillocs on descr. fn implement_guard_with_faillocs( &mut self, @@ -4488,13 +4659,36 @@ impl<'a> AssemblerARM64<'a> { fail_label } - /// Emit a conditional branch to `label` using the long-branch pattern: - /// `b. skip; b =>label; skip:` so that the displacement of the - /// unconditional `b` is 26-bit / ±128MB instead of the 19-bit / ±1MB - /// of `b.cond`. The extra inversion+skip adds one instruction per - /// guard, but avoids `ImpossibleRelocation` on large traces (logo's - /// 70000-op trace generates >1MB of machine code). + /// Emit a conditional branch to `label`. + /// + /// `opassembler.py:857 _emit_op_cond_call` and the guard emitters reserve + /// one instruction and patch it with `B_ofs_cond`, which is what the + /// steady-state loop should contain. A trace whose code exceeds + /// `b.cond`'s +1MB reach cannot do that, and falls back to + /// `b. skip; b =>label; skip:` — the unconditional `b` carries a + /// 26-bit displacement (±128MB) at the cost of one extra instruction per + /// guard. logo's 70000-op trace needs it. fn emit_bcond_to_label(&mut self, cc: u8, label: DynamicLabel) { + if !self.long_guard_branch { + match cc { + CC_L => dynasm!(self.mc ; .arch aarch64 ; b.lt =>label), + CC_LE => dynasm!(self.mc ; .arch aarch64 ; b.le =>label), + CC_G => dynasm!(self.mc ; .arch aarch64 ; b.gt =>label), + CC_GE => dynasm!(self.mc ; .arch aarch64 ; b.ge =>label), + CC_E => dynasm!(self.mc ; .arch aarch64 ; b.eq =>label), + CC_NE => dynasm!(self.mc ; .arch aarch64 ; b.ne =>label), + CC_B => dynasm!(self.mc ; .arch aarch64 ; b.lo =>label), + CC_BE => dynasm!(self.mc ; .arch aarch64 ; b.ls =>label), + CC_A => dynasm!(self.mc ; .arch aarch64 ; b.hi =>label), + CC_AE => dynasm!(self.mc ; .arch aarch64 ; b.hs =>label), + CC_O => dynasm!(self.mc ; .arch aarch64 ; b.vs =>label), + CC_NO => dynasm!(self.mc ; .arch aarch64 ; b.vc =>label), + CC_S => dynasm!(self.mc ; .arch aarch64 ; b.mi =>label), + CC_NS => dynasm!(self.mc ; .arch aarch64 ; b.pl =>label), + _ => dynasm!(self.mc ; .arch aarch64 ; b.eq =>label), + } + return; + } let skip = self.mc.new_dynamic_label(); // Invert: branch over the unconditional `b` when the guard succeeds match cc { @@ -6888,12 +7082,21 @@ impl<'a> AssemblerARM64<'a> { /// False)` parity, inlined like the WB slowpath. fn genop_discard_cond_call(&mut self, op: &Op, arglocs: &[Loc]) { let _ = op; - // Test the condition in a scratch register (ip0/x16), not an - // allocatable register, so the test never clobbers a live value - // before it is saved. - self.emit_load_loc_to_ip0(arglocs[0]); let skip_label = self.mc.new_dynamic_label(); - dynasm!(self.mc ; .arch aarch64 ; cbz x16, =>skip_label); + // `opassembler.py:864 _emit_op_cond_call` skips the CMP when + // `arglocs[0] is None` — the condition is already in the flags. + // Here that case is signalled by `guard_success_cc`, published by + // `flush_cc` when the regalloc handed the comparison the + // frame-register sentinel. + if let Some(cc) = self.guard_success_cc.take() { + self.emit_jcc_to_label(invert_cc(cc), skip_label); + } else { + // Test the condition in a scratch register (ip0/x16), not an + // allocatable register, so the test never clobbers a live value + // before it is saved. + self.emit_load_loc_to_ip0(arglocs[0]); + dynasm!(self.mc ; .arch aarch64 ; cbz x16, =>skip_label); + } self.emit_push_all_volatile_regs(); self.emit_call_from_arglocs(arglocs, 1); diff --git a/majit/majit-backend-dynasm/src/aarch64/opassembler.rs b/majit/majit-backend-dynasm/src/aarch64/opassembler.rs index 12a39718742..5b4a9ae8cbb 100644 --- a/majit/majit-backend-dynasm/src/aarch64/opassembler.rs +++ b/majit/majit-backend-dynasm/src/aarch64/opassembler.rs @@ -188,9 +188,11 @@ impl<'a> AssemblerARM64<'a> { // (regalloc.py:161) admits 0..4095 for ADD_ri; anything else // falls back to `mov x16, #ofs; add x16, x16, index`. if (0..4096).contains(&ofs) { - dynasm!(self.mc ; .arch aarch64 - ; mov x16, X(index.value) - ; add x16, x16, ofs as u32); + // `opassembler.py:403 ADD_ri(ip0, index, ofs)` is a single + // instruction; dynasm's `add Xd|SP, Xn|SP, #uimm` form rejects a + // dynamic register operand, so encode the word directly + // (`codebuilder.py:113 ADD_ri`). + self.emit_add_ri(16, index.value, ofs as u32); } else { self.emit_mov_imm64(16, ofs); dynasm!(self.mc ; .arch aarch64 diff --git a/majit/majit-backend-dynasm/src/aarch64/regalloc.rs b/majit/majit-backend-dynasm/src/aarch64/regalloc.rs index e43043089b5..b96a35218e1 100644 --- a/majit/majit-backend-dynasm/src/aarch64/regalloc.rs +++ b/majit/majit-backend-dynasm/src/aarch64/regalloc.rs @@ -267,6 +267,13 @@ impl<'a> RegAlloc<'a> { /// aarch64 `int_is_true` / `int_is_zero`: shares the 3-op `prepare_unary` /// shape from regalloc.py:456 since `cmp Xn, #0 ; cset Wd, ne` keeps /// the input register live while writing a fresh destination. + /// + /// When the next op consumes the flags, `regalloc.py:469 + /// prepare_comp_unary` allocates no destination at all and + /// `opassembler.py:210 emit_comp_op_int_is_true` emits the `cmp` alone, + /// returning the condition. `force_allocate_reg_or_cc` spells that as + /// the frame-register sentinel, which the `IntIsTrue` / `IntIsZero` emit + /// arms recognise through `flush_cc`. pub(crate) fn consider_int_is_true_j2( &mut self, dst: OpRef, @@ -274,7 +281,15 @@ impl<'a> RegAlloc<'a> { i: usize, output: &mut Vec, ) { - self.consider_unary_int_j2(dst, arg, i, output); + assert!( + !arg.is_constant(), + "prepare_comp_unary expects a non-const arg; got constant OpRef {arg:?} (should have been folded earlier)" + ); + let arg_loc = self.make_sure_var_in_reg(arg, Type::Int, &[], None, false); + self.possibly_free_var(arg, Type::Int); + let ops_ref: &[majit_ir::Op] = self.operations; + let res = self.force_allocate_reg_or_cc(dst, ops_ref, i); + self.perform(i, vec![arg_loc], Some(res), output); } /// aarch64/regalloc.py:397 `prepare_op_uint_mul_high = prepare_op_int_mul`. diff --git a/majit/majit-backend-dynasm/src/regalloc.rs b/majit/majit-backend-dynasm/src/regalloc.rs index 65b0f8a2047..434d0598185 100644 --- a/majit/majit-backend-dynasm/src/regalloc.rs +++ b/majit/majit-backend-dynasm/src/regalloc.rs @@ -3603,7 +3603,6 @@ impl<'a> RegAlloc<'a> { /// - `CondCallN` via `guard_success_cc` (see /// `genop_discard_cond_call`, mirrors `x86/assembler.py:2526 /// cond_call`). - #[cfg(target_arch = "x86_64")] fn next_op_can_accept_cc(&self, ops: &[Op], i: usize, result: OpRef) -> bool { if i + 1 >= ops.len() { return false; @@ -3666,19 +3665,15 @@ impl<'a> RegAlloc<'a> { /// following guard. Otherwise force-allocate a general-purpose /// register (with `need_lower_byte` so `SETcc r8b` is encodable). /// - /// The CC-sentinel path is x86-only: the aarch64 CompOp emit at - /// `aarch64/assembler.rs` unconditionally emits `setcc result_loc` - /// and has no `flush_cc` equivalent, so receiving `frame_reg` as - /// the result there would clobber x29 (the frame pointer). On - /// non-x86 architectures, fall through to a plain force-allocate. + /// Both backends recognise the sentinel: `x86/assembler.rs flush_cc` + /// and `aarch64/assembler.rs flush_cc` publish `guard_success_cc` and + /// emit nothing when `result_loc` is the frame register, so neither + /// clobbers rbp / x29. pub(crate) fn force_allocate_reg_or_cc(&mut self, result: OpRef, ops: &[Op], i: usize) -> Loc { - #[cfg(target_arch = "x86_64")] if self.next_op_can_accept_cc(ops, i, result) { self.rm.force_allocate_frame_reg(result); return Loc::Reg(arch_regalloc::frame_reg()); } - #[cfg(not(target_arch = "x86_64"))] - let _ = (ops, i); Loc::Reg(self.force_allocate_reg(result, Type::Int, &[], None, true)) } @@ -4137,8 +4132,10 @@ impl<'a> RegAlloc<'a> { if !vx_in_reg && !vy_in_reg && !vx.is_constant() { arglocs[0] = self.make_sure_var_in_reg(vx, Type::Float, &[], None, false); } - let result_loc = - Loc::Reg(self.force_allocate_reg(op.pos.get(), Type::Int, &[], None, false)); + // x86/regalloc.py:682 — a float comparison whose only consumer is the + // next guard leaves its answer in the flags, like the integer one. + let ops_ref: &[Op] = self.operations; + let result_loc = self.force_allocate_reg_or_cc(op.pos.get(), ops_ref, i); self.perform(i, arglocs, Some(result_loc), output); } @@ -4156,7 +4153,8 @@ impl<'a> RegAlloc<'a> { if !lhs_in_reg && !rhs_in_reg && !lhs.is_constant() { arglocs[0] = self.make_sure_var_in_reg(lhs, Type::Float, &[], None, false); } - let result_loc = Loc::Reg(self.force_allocate_reg(dst, Type::Int, &[], None, false)); + let ops_ref: &[Op] = self.operations; + let result_loc = self.force_allocate_reg_or_cc(dst, ops_ref, i); self.perform(i, arglocs, Some(result_loc), output); } diff --git a/majit/majit-backend-dynasm/src/x86/assembler.rs b/majit/majit-backend-dynasm/src/x86/assembler.rs index 83014404b1c..bb03de41989 100644 --- a/majit/majit-backend-dynasm/src/x86/assembler.rs +++ b/majit/majit-backend-dynasm/src/x86/assembler.rs @@ -3314,11 +3314,29 @@ impl<'a> Assembler386<'a> { self.regalloc_mov(b_loc, &Loc::Reg(scratch)); scratch }; - dynasm!(self.mc ; .arch x64 ; ucomisd Rx(a.value), Rx(b.value)); - if let Some(Loc::Reg(r)) = result_loc { - let cc = Self::float_opcode_to_cc(op.opcode); - self.emit_setcc(cc, r.value); + // `assembler.py:1322 _cmpop_float`: UCOMISD sets + // ZF = PF = CF = 1 when either operand is NaN, so only the + // `A` / `AE` forms are already false on an unordered + // compare. FLOAT_LT / FLOAT_LE reach them by comparing in + // the reverse order (`rev_cond`); FLOAT_EQ / FLOAT_NE have + // no such form and take the parity fixup instead. + let (lhs, rhs, cc, need_parity) = match op.opcode { + OpCode::FloatLt => (b, a, CC_A, false), + OpCode::FloatLe => (b, a, CC_AE, false), + OpCode::FloatGt => (a, b, CC_A, false), + OpCode::FloatGe => (a, b, CC_AE, false), + OpCode::FloatEq => (a, b, CC_E, true), + _ => (a, b, CC_NE, true), + }; + dynasm!(self.mc ; .arch x64 ; ucomisd Rx(lhs.value), Rx(rhs.value)); + if need_parity { + self.emit_if_parity_clear_zero_and_carry(); } + // `assembler.py:1345 genop_cmp_float` ends in `flush_cc`, so + // a comparison whose only consumer is the next guard keeps + // its answer in the flags instead of materialising a + // boolean the guard would immediately re-test. + self.flush_cc(cc, result_loc); } } // ── Casts ── @@ -5069,6 +5087,22 @@ impl<'a> Assembler386<'a> { dynasm!(self.mc ; .arch x64 ; movzx Rd(dst_reg), Rb(dst_reg)); } + /// `assembler.py:1314 _if_parity_clear_zero_and_carry`. + /// + /// UCOMISD sets PF on an unordered compare, together with ZF and CF, so + /// `sete` / `setb` / `setbe` would report NaN as equal / less-than and + /// `setne` would report it as not-not-equal. `cmp rbp, 0` on the frame + /// pointer — never null inside compiled code — clears ZF and CF, and is + /// jumped over when PF is clear. + fn emit_if_parity_clear_zero_and_carry(&mut self) { + let ordered = self.mc.new_dynamic_label(); + dynasm!(self.mc ; .arch x64 + ; jnp =>ordered + ; cmp rbp, 0 + ; =>ordered + ); + } + /// x86/assembler.py:1286 `flush_cc` parity. /// /// After emitting a CMP/TEST that leaves a boolean in the @@ -5081,18 +5115,26 @@ impl<'a> Assembler386<'a> { /// value for non-guard consumers (e.g. boolean stored into a /// frame slot). fn flush_cc(&mut self, cond: u8, result_loc: Option<&Loc>) { + // `assembler.py:1293 flush_cc` opens with + // `assert self.guard_success_cc == rx86.cond_none` — a condition + // still pending here was published by an earlier op and never + // consumed, which would make the following guard branch on it. + debug_assert!( + self.guard_success_cc.is_none(), + "flush_cc: guard_success_cc already set", + ); let frame_reg_value = crate::x86::regalloc::frame_reg().value; if let Some(Loc::Reg(r)) = result_loc { if r.value == frame_reg_value { // Sentinel: the next op accepts cc. - debug_assert!( - self.guard_success_cc.is_none(), - "flush_cc: guard_success_cc already set", - ); self.guard_success_cc = Some(cond); return; } - dynasm!(self.mc ; .arch x64 ; mov Rq(r.value), 0); + // `assembler.py:1300` clears the destination with `MOV imm0` + // before `SET_ir` because `SETcc` writes only the low byte. + // `emit_setcc` ends in `movzx r32, r8`, which zeroes bits 8..63 + // on its own, so the same two-instruction sequence is spelled + // without the leading MOV. self.emit_setcc(cond, r.value); } } @@ -5128,19 +5170,6 @@ impl<'a> Assembler386<'a> { } } - /// Map a float comparison OpCode to a condition code (after ucomisd). - fn float_opcode_to_cc(opcode: OpCode) -> u8 { - match opcode { - OpCode::FloatLt => CC_B, // ucomisd: below = less than - OpCode::FloatLe => CC_BE, // below or equal - OpCode::FloatGt => CC_A, // above - OpCode::FloatGe => CC_AE, // above or equal - OpCode::FloatEq => CC_E, // equal - OpCode::FloatNe => CC_NE, // not equal - _ => CC_E, - } - } - /// Guard with faillocs — emit conditional jump and store faillocs on descr. fn implement_guard_with_faillocs( &mut self, diff --git a/majit/majit-translate/src/front/mir.rs b/majit/majit-translate/src/front/mir.rs index 4262055e177..bfbaf32eb44 100644 --- a/majit/majit-translate/src/front/mir.rs +++ b/majit/majit-translate/src/front/mir.rs @@ -4628,12 +4628,15 @@ impl<'a> Lowering<'a> { // // Upstream materialises the address as `Constant(funcptr)` of // lltype `Ptr(FuncType)` (`rtyper.getcallable`, and - // `sub_helper_funcptr_constant` for the sub-helper twins), whose - // `getkind` is `r`. The slot here stays `Int` because majit - // materialises a funcptr as its integer address everywhere else - // (`jtransform.rs direct_funcptr_value` emits `ConstInt(fnaddr)`, - // which the assembler encodes through the `'i'` argcode), and the - // flowspace fold gives the define a `Signed` legacy slot to match. + // `sub_helper_funcptr_constant` for the sub-helper twins). + // `FuncType._gckind` is `raw`, so `getkind` maps that pointer to + // `int`, not to `ref` — the `Int` slot here IS that mapping rather + // than a departure from it, and re-stamping it `Ref` would be the + // deviation. majit materialises a funcptr as its integer address + // everywhere else as well (`jtransform.rs direct_funcptr_value` + // emits `ConstInt(fnaddr)`, which the assembler encodes through the + // `'i'` argcode), and the flowspace fold gives the define a + // `Signed` legacy slot to match. DecodedConst::FnPath(segments) => { let mut synthetic = Vec::with_capacity(segments.len() + 1); synthetic.push(crate::model::FN_CONST_HEAD.to_string()); diff --git a/pyre/pyre-interpreter/src/eval.rs b/pyre/pyre-interpreter/src/eval.rs index 9f65a15c2da..a808919e5d9 100644 --- a/pyre/pyre-interpreter/src/eval.rs +++ b/pyre/pyre-interpreter/src/eval.rs @@ -607,17 +607,11 @@ pub fn capture_pyframe_root_area() -> *const () { /// slot instead holds a `JitVirtualRef`, and reading that as a `PyFrame` would /// interpret its `virtual_token` word as frame fields. Hop through the vref /// instead. A still-virtual vref ends the walk: the frames it stands for have -/// no heap image to visit, and `virtualref.py:157 force_virtual_if_necessary` +/// no heap image to visit, and `virtualref.py force_virtual_if_necessary` /// cannot run here because materializing one allocates. #[inline] unsafe fn chain_next_frame(f_backref: *mut PyFrame) -> *mut PyFrame { - unsafe { - if majit_metainterp::virtualref::ptr_is_virtual_ref(f_backref as *const u8) { - majit_metainterp::virtualref::vref_forced(f_backref as *const u8) as *mut PyFrame - } else { - f_backref - } - } + crate::executioncontext::vref_referent(f_backref) } /// Walk one captured thread's active frame and interpreter root state. diff --git a/pyre/pyre-interpreter/src/executioncontext.rs b/pyre/pyre-interpreter/src/executioncontext.rs index 3ea1032c321..895ae871497 100644 --- a/pyre/pyre-interpreter/src/executioncontext.rs +++ b/pyre/pyre-interpreter/src/executioncontext.rs @@ -49,9 +49,34 @@ pub fn register_force_vref_hook(f: ForceVRefFn) { let _ = FORCE_VREF_HOOK.set(f); } +/// The frame a chain slot NAMES, read WITHOUT forcing — +/// `virtualref.py force_virtual`'s trailing `return vref.forced`. +/// +/// Exact for the whole recording walk: `virtual_ref_during_tracing` writes +/// `forced = real_object` at allocation and only `continue_tracing` ever +/// rewrites it. Null when the vref is still virtual with nothing +/// materialized — a live compiled frame — so callers must read that as +/// "names no reachable frame", never as a match. +/// +/// For identity tests only, never for handing a frame to application code. +/// Forcing would be wrong here, not merely expensive: a live vref carries +/// `TOKEN_TRACING_RESCALL` across a residual, `force_virtual` clears it, and +/// that cleared token is the one marker `tracing_after_residual_call` reads as +/// "the callee forced this vref". A reader that forced would report its own +/// read as a callee escape. +#[inline] +pub fn vref_referent(ptr: *mut PyFrame) -> *mut PyFrame { + if unsafe { majit_metainterp::virtualref::ptr_is_virtual_ref(ptr as *const u8) } { + unsafe { majit_metainterp::virtualref::vref_forced(ptr as *const u8) as *mut PyFrame } + } else { + ptr + } +} + /// Force a vref stored in the frame chain (`topframeref` / `f_backref`). -/// `virtualref.py:135`: `if inst.typeptr != jit_virtual_ref_vtable: return inst` -/// (the pointer already *is* the frame) else materialize via `force_virtual`. +/// `virtualref.py force_virtual_if_necessary`: `if inst.typeptr != +/// jit_virtual_ref_vtable: return inst` (the pointer already *is* the frame) +/// else materialize via `force_virtual`. #[inline] pub(crate) fn force_vref(ptr: *mut PyFrame) -> *mut PyFrame { if unsafe { majit_metainterp::virtualref::ptr_is_virtual_ref(ptr as *const u8) } { @@ -907,7 +932,7 @@ impl ExecutionContext { self.w_tracefunc = pyre_object::PY_NULL; } else { self.force_all_frames(false); - // executioncontext.py:296-298 — increase the JIT's + // executioncontext.py settrace — increase the JIT's // trace_limit when a tracefunc is installed; tracing // generates a ton of extra ops per bytecode. crate::call::set_jit_param("trace_limit", 10000); @@ -918,7 +943,7 @@ impl ExecutionContext { self.w_tracefunc } - /// pypy/interpreter/executioncontext.py:303-310 setprofile. + /// `executioncontext.py setprofile`. pub fn setprofile(&mut self, w_func: PyObjectRef) -> Result<(), crate::PyError> { if w_func.is_null() || w_func == pyre_object::w_none() { self.profilefunc = None; @@ -929,19 +954,19 @@ impl ExecutionContext { } } - /// pypy/interpreter/executioncontext.py:312-313 getprofile. + /// `executioncontext.py getprofile`. pub fn getprofile(&self) -> PyObjectRef { self.w_profilefuncarg } - /// pypy/interpreter/executioncontext.py:315-321 setllprofile. + /// `executioncontext.py setllprofile`. pub fn setllprofile( &mut self, func: Option, w_arg: PyObjectRef, ) -> Result<(), crate::PyError> { if func.is_some() { - // executioncontext.py:317-318 `if w_arg is None: raise + // executioncontext.py setllprofile: `if w_arg is None: raise // ValueError("Cannot call setllprofile with real None")`. // The check is against RPython-level None (== null in pyre); // Python-level `w_none()` (`space.w_None`) is a valid user @@ -958,9 +983,24 @@ impl ExecutionContext { Ok(()) } + /// `executioncontext.py force_all_frames` — "force" every frame in the + /// sense of the JIT, so one that is running in assembler fails its next + /// `GUARD_NOT_FORCED` and falls back to interpreted execution, where the + /// freshly installed trace / profile callback is honoured. + /// + /// Upstream gets that effect from the walk itself: `f_backref` holds a + /// `jit.virtual_ref`, so `getnextframe_nohidden`'s `frame.f_backref()` is a + /// `jit_force_virtual`, and `virtualref.force_virtual` runs + /// `ResumeGuardForcedDescr.force_now` on a token that still names a live + /// JIT frame. Pyre's walk calls [`force_vref`] at the same points, but + /// nothing stores a `JitVirtualRef` in the chain yet, so it is the identity + /// and the walk forces nothing. Until the tracer emits `VIRTUAL_REF` at + /// the inline push, this consumer — whose whole purpose is the force — + /// states it directly. pub fn force_all_frames(&mut self, is_being_profiled: bool) { let mut frame = self.gettopframe_nohidden(); while !frame.is_null() { + force_frame(frame); if is_being_profiled { unsafe { (*frame).getorcreatedebug(-1).is_being_profiled = true; diff --git a/pyre/pyre-interpreter/src/jit_fnaddr.rs b/pyre/pyre-interpreter/src/jit_fnaddr.rs index 8b71c47bc0d..71e4c67b519 100644 --- a/pyre/pyre-interpreter/src/jit_fnaddr.rs +++ b/pyre/pyre-interpreter/src/jit_fnaddr.rs @@ -1479,12 +1479,10 @@ pub fn jit_trace_fnaddrs() -> Vec<(&'static str, i64)> { // store leaves to native ops, leaving `list_write_barrier(l)` as a // residual call. Register it so the codewriter resolves the residual to a // runtime-patchable address instead of a `symbolic_fnaddr_for_path` hash - // the inline sub-walk must decline. The address is also what the walker - // matches on to drop the residual entirely when the backend GC rewrite - // already covers the store (`FbwWalkMode::append_inplace_wb_covered`); - // with an off-GC ItemsBlock the residual stays, because there the - // collector reaches the block's slots only through the remembered - // `W_ListObject`. + // the inline sub-walk must decline. The residual barrier remembers the + // enclosing `W_ListObject`, whose trace reaches every item slot, and is + // the only thing keeping an appended `old -> young` element reachable + // across a minor collection. push_alias_pair( &mut entries, "pyre_object::listobject::list_write_barrier", diff --git a/pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs b/pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs index 3a6b74984d6..a5e627baa89 100644 --- a/pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs +++ b/pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs @@ -46,7 +46,7 @@ //! | `setfield_gc_i/rid`, `setfield_gc_r/rrd` | PARITY (heapcache-aware, alias-clearing) | r-bank box + (i\|r)-bank valuebox + descr. If `getfield_cached(obj,descr) == Some(valuebox)` skip recording (RPython `if upd.currfieldbox is valuebox: return`); otherwise record `OpCode::SetfieldGc(obj, valuebox)` + `setfield_cached` write-through. Aliasing semantics: `CacheEntry.do_write_with_aliasing` (heapcache.py:90-94) routes through `_clear_cache_on_write(seen_alloc)` — always wipes `cache_anything`, additionally wipes `cache_seen_allocation` when the write target itself isn't seen-allocated. RPython `pyjitpl.py:973-988 _opimpl_setfield_gc_any`. The disabled is_unescaped branch (`pyjitpl.py:981-988`) is intentionally not ported — RPython itself has it commented out. `iid` / `ird` (int box) shapes stay unsupported (kind-flow territory). | //! | `getarrayitem_gc_r/rid>r` | PARITY (heapcache-aware) | r-bank array + i-bank index + descr → heapcache `getarrayitem` lookup. Cache hit returns cached OpRef without IR; cache miss records `OpCode::GetarrayitemGcR(array, index)` + `getarrayitem_now_known` writeback. RPython `pyjitpl.py:639-688 _do_getarrayitem_gc_any`. All three `_i` / `_r` / `_f` result shapes are wired to this same heapcache body (kind-keyed dst bank, dispatch arms below) and registered in `wellknown_bh_insns()` (`insns.rs:865-866`) for blackhole execution + codewriter emission. | //! | `setarrayitem_gc_r/rird`, `setarrayitem_gc_r/rcrd` | PARITY (heapcache-aware) | r-bank array + i-bank index + r-bank value + descr. Always records `OpCode::SetarrayitemGc(array, index, value)` + `heapcache.setarrayitem(...)` write. RPython `pyjitpl.py:736-744 _opimpl_setarrayitem_gc_any` — no skip-on-redundant short-circuit because `setarrayitem` does aliasing-aware invalidation. The `rcrd` `c`-argcode form (USE_C_FORM `assembler.py:99-107/312`) decodes the index as one inline signed byte → ConstInt; same recording body otherwise. `rrid` / `rrrd` / `rrfd` (Ref index) shapes stay unsupported (kind-flow). | -//! | `residual_call_r_r/iRd>r` | TODO (`direct_assembler_call` + `capture_resumedata` not yet wired) | classifies the call by `EffectInfo`. Wired sub-cases: (1) release-gil via [`direct_call_release_gil`] — `CallReleaseGilI` + arglist `[savebox, funcbox] + argboxes[1:]` reshape per `pyjitpl.py:3675-3681`, plus the outer forces-branch `GUARD_NOT_FORCED` (`:2079`) + `GUARD_NO_EXCEPTION` (`:2082`); (2) loop-invariant heapcache via [`loopinvariant_lookup`] / [`loopinvariant_now_known`] per `pyjitpl.py:2088 + 2109`; (3) vable IR bookkeeping (`pyjitpl.py:2055-2080`) via [`maybe_walker_vable_and_vrefs_before_residual_call`] — emits FORCE_TOKEN + SETFIELD_GC only; the runtime heap halves of the token protocol (`vinfo.tracing_before_residual_call` / `vrefinfo.tracing_before_residual_call` and the after-call `vinfo.tracing_after_residual_call`, `pyjitpl.py`) are bracketed around the concrete callee execution by [`try_execute_residual_call_via_executor`], which arms TOKEN_TRACING_RESCALL before the call and probe-and-clears it after, surfacing [`DispatchError::VableEscapedDuringResidualCall`] on a detected force (`pyjitpl.py` ABORT_ESCAPE parity). The vref halves of the bracket are unported — see the module preamble. The remaining branches go through [`select_residual_call_opcode`]: `CallMayForce*` + `GuardNotForced` on the rest of the forces-virtual path (`pyjitpl.py:2017-2082`), `CallLoopinvariant*` on `EF_LOOPINVARIANT` (`pyjitpl.py:2087-2110`), `CallPure*` on elidable, otherwise `Call*`. `GuardNoException` follows whenever `effectinfo.check_can_raise(False)` is true (`pyjitpl.py:2082 handle_possible_exception`). `heapcache.invalidate_caches_varargs(call_opcode, ei, allboxes)` (`pyjitpl.py:2042 + 2659`) is wired around every recorded call op. `OS_NOT_IN_TRACE` is fail-loud-guarded up front via [`do_not_in_trace_call_result`] — `effect_info_for_call_flavor` stub never sets the index today (`flatten.rs:431`), making it dead until producers land. Same fail-loud treatment via [`do_jit_force_virtual_guard`] for `OS_JIT_FORCE_VIRTUAL` (stricter-than-PyPy — needs OpRef→concrete-pointer resolver). Still deferred (each blocked on infrastructure absent from pyre-jit-trace): `direct_libffi_call` / `direct_assembler_call` specialization (`pyjitpl.py:1908-1990` — assembler_call paths route through `inline_call_*/dR>X` instead), KEEPALIVE for vablebox (only fires when `direct_assembler_call` returns a vablebox), and `num_live`-aware `capture_resumedata(after_residual_call=True)` on the guards (`pyjitpl.py:2078-2082 → 2586`). | +//! | `residual_call_r_r/iRd>r` | TODO (`direct_assembler_call` + `capture_resumedata` not yet wired) | classifies the call by `EffectInfo`. Wired sub-cases: (1) release-gil via [`direct_call_release_gil`] — `CallReleaseGilI` + arglist `[savebox, funcbox] + argboxes[1:]` reshape per `pyjitpl.py:3675-3681`, plus the outer forces-branch `GUARD_NOT_FORCED` (`:2079`) + `GUARD_NO_EXCEPTION` (`:2082`); (2) loop-invariant heapcache via [`loopinvariant_lookup`] / [`loopinvariant_now_known`] per `pyjitpl.py:2088 + 2109`; (3) vable IR bookkeeping (`pyjitpl.py:2055-2080`) via [`maybe_walker_vable_and_vrefs_before_residual_call`] — emits FORCE_TOKEN + SETFIELD_GC only; the runtime heap halves of the token protocol (`vinfo.tracing_before_residual_call` / `vrefinfo.tracing_before_residual_call` and the after-call `vinfo.tracing_after_residual_call`, `pyjitpl.py`) are bracketed around the concrete callee execution by [`try_execute_residual_call_via_executor`], which arms TOKEN_TRACING_RESCALL before the call and probe-and-clears it after, surfacing [`DispatchError::VableEscapedDuringResidualCall`] on a detected force (`pyjitpl.py` ABORT_ESCAPE parity). The vref halves of the bracket are ported on `TraceCtx` but uncalled by the walker — see the module preamble. The remaining branches go through [`select_residual_call_opcode`]: `CallMayForce*` + `GuardNotForced` on the rest of the forces-virtual path (`pyjitpl.py:2017-2082`), `CallLoopinvariant*` on `EF_LOOPINVARIANT` (`pyjitpl.py:2087-2110`), `CallPure*` on elidable, otherwise `Call*`. `GuardNoException` follows whenever `effectinfo.check_can_raise(False)` is true (`pyjitpl.py:2082 handle_possible_exception`). `heapcache.invalidate_caches_varargs(call_opcode, ei, allboxes)` (`pyjitpl.py:2042 + 2659`) is wired around every recorded call op. `OS_NOT_IN_TRACE` is fail-loud-guarded up front via [`do_not_in_trace_call_result`] — `effect_info_for_call_flavor` stub never sets the index today (`flatten.rs:431`), making it dead until producers land. Same fail-loud treatment via [`do_jit_force_virtual_guard`] for `OS_JIT_FORCE_VIRTUAL` (stricter-than-PyPy — needs OpRef→concrete-pointer resolver). Still deferred (each blocked on infrastructure absent from pyre-jit-trace): `direct_libffi_call` / `direct_assembler_call` specialization (`pyjitpl.py:1908-1990` — assembler_call paths route through `inline_call_*/dR>X` instead), KEEPALIVE for vablebox (only fires when `direct_assembler_call` returns a vablebox), and `num_live`-aware `capture_resumedata(after_residual_call=True)` on the guards (`pyjitpl.py:2078-2082 → 2586`). | //! | `residual_call_r_i/iRd>i` | PARITY (kind sibling of `_r_r`) | same EffectInfo classification + guard emission as `_r_r` — `select_residual_call_opcode('i', ...)` returns the int-typed `Call*` family (`CallReleaseGilI` / `CallMayForceI` / `CallLoopinvariantI` / `CallPureI` / `CallI`); only the dst writeback bank (`registers_i`) differs. RPython parity: `pyjitpl.py:1346 opimpl_residual_call_r_i = _opimpl_residual_call1`; `do_residual_call`'s `descr.get_normalized_result_type()` dispatch (pyjitpl.py:2022-2044) selects the int-result CALL op. Argboxes pass through [`build_allboxes`] same as `_r_r` (R-list-only argboxes → identity permutation when arg_types is ref-only). | //! | `residual_call_ir_r/iIRd>r` | PARITY (shape sibling of `_r_r`) | adds an i-bank list between funcptr and the R-list. RPython parity: `pyjitpl.py:1349 opimpl_residual_call_ir_r = _opimpl_residual_call2`; `boxes2` argcode (`pyjitpl.py:3750-3760`) decodes the two count-prefixed lists into `argboxes = [i_args..., r_args...]`. Walker passes that flat list through [`build_allboxes`] (line-by-line port of `pyjitpl.py:1960-1993 _build_allboxes`) which permutes argboxes by `descr.get_arg_types()` so the recorded `Call*` arglist matches the callee's actual ABI even for mixed orderings like `[REF, INT, REF, INT]`. Same EffectInfo classification + guard emission as `_r_r` via [`select_residual_call_opcode`]. | //! | `raise/r` | PARITY (`GUARD_CLASS`) | sets `ctx.last_exc_value` (`pyjitpl.py:1695`); top-level records `Finish(exc) descr=exit_frame_with_exception_descr_ref` (`pyjitpl.py:3238-3242 compile_exit_frame_with_exception`); sub-walk surfaces `SubRaise{exc}`. Caller-side handler scan (`finishframe_exception`) lives on `inline_call`'s SubRaise arm (above). RPython `pyjitpl.py:1690-1693` also emits `GUARD_CLASS(exc, cls_of_box(exc))` when `heapcache.is_class_known(exc) == false`; the retired trait-side path read `concrete_exc.ob_header.ob_type` from the concrete frame snapshot and emitted the orthodox `GuardClass(exc_box, cls_const)` per the heapcache `is_class_known` gate. | @@ -101,16 +101,18 @@ //! token before the call and probe-and-clears it after, surfacing //! [`DispatchError::VableEscapedDuringResidualCall`] on a force //! (`pyjitpl.py` ABORT_ESCAPE parity). The vref halves -//! (`vrefs_before_residual_call` / `vrefs_after_residual_call`) -//! remain unported. `PyreSym` does carry `virtualref_boxes` -//! (`state.rs`, written by `opimpl_virtual_ref` / -//! `opimpl_virtual_ref_finish` and restored by the resume-side -//! decode), so the gap is the residual-call bracket itself: -//! neither the pre-call `vrefinfo.tracing_before_residual_call` -//! loop nor the post-call `stop_tracking_virtualref` exists on -//! the walker. Unreachable today — the codewriter emits no -//! `jit.virtual_ref` producers (`jit/call.rs`), leaving -//! `virtualref_boxes` empty so both loops iterate zero times. +//! (`vrefs_before_residual_call` / `vrefs_after_residual_call` / +//! `stop_tracking_virtualref`) ARE ported — on `TraceCtx`, and +//! wired on the metainterp leg — but the walker never calls +//! them, so the gap is the call, not the port. Two +//! `virtualref_boxes` also exist: `TraceCtx`'s, which the +//! bracket and the guard snapshots read, and `PyreSym`'s, which +//! only the caller-less `opimpl_virtual_ref` / +//! `opimpl_virtual_ref_finish` and the resume-side decode +//! touch. A producer must push into `TraceCtx`'s or nothing +//! downstream sees it. Unreachable today — the codewriter +//! emits no `jit.virtual_ref` producers (`jit/call.rs`), +//! leaving both empty so every loop iterates zero times. //! b. **Codewriter-side**: `direct_assembler_call` + KEEPALIVE on //! vablebox (`pyjitpl.py:3589-3609 + 2080-2081`). Walker's //! residual_call dispatchers never receive `assembler_call=True` @@ -889,34 +891,6 @@ pub struct FbwWalkMode { /// boundary rather than mapping the callee `op_pc` through the outer /// jitcode in `walker_capture_snapshot_for_last_guard`. pub inline_subwalk: bool, - /// The enclosing `w_list_append` fold took the Object strategy's in-place - /// arm on a block that existed before this append, so the appended ref - /// lands in a `SetarrayitemGc` the backend GC rewrite already covers with - /// `COND_CALL_GC_WB_ARRAY` - /// (`rewrite.py:936-944` `handle_write_barrier_setarrayitem`). The list's - /// own `items` pointer is unchanged on that arm, so remembering the - /// `W_ListObject` adds nothing the array barrier does not already do. - /// - /// `list_write_barrier` still RUNS concretely during the walk — the walk - /// mutates the live heap — but recording it would leave a barrier call in - /// the compiled trace, and upstream emits none: pyjitpl never executes a - /// write barrier (`executor.py:446`), `COND_CALL_GC_WB` is neither - /// can-raise nor a call (`resoperation.py:1124-1125`), and the only - /// barrier in a compiled loop is the one the backend rewrite inserts. - /// - /// Carries the receiver address rather than a flag so only that list's - /// barrier is dropped — a barrier reached for any other list inside the - /// sub-walk is recorded normally. - /// - /// Never set for Empty->Object promotion: that transition also installs - /// the new block in `W_ListObject.items`, so the owner barrier is - /// load-bearing even though the subsequent element store is in-place. - /// - /// Only set while the items block is GC-managed. With - /// `PYRE_GC_ITEMSBLOCK=0` the block is `std::alloc` memory with no GC - /// header, the collector reaches its slots only through the remembered - /// `W_ListObject`, and the hand barrier is load-bearing. - pub append_inplace_wb_covered_receiver: Option, /// A bridge-carrier resume folds nested self-recursive calls directly to /// `CALL_ASSEMBLER` (`opimpl_recursive_call_assembler`) rather than /// re-unrolling the call tree to the multi-frame depth cap. @@ -982,7 +956,6 @@ impl Default for FbwWalkMode { Self { snapshot_sym: std::ptr::null(), inline_subwalk: false, - append_inplace_wb_covered_receiver: None, carrier_resume: false, current_exception_seed: None, current_exception_seed_concrete: pyre_object::PY_NULL, 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 597846e1f7d..4385ce4219b 100644 --- a/pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs +++ b/pyre/pyre-jit-trace/src/jitcode_dispatch/residual_call.rs @@ -439,7 +439,7 @@ impl Drop for InlineConcreteFrameGuard { } } -/// `executioncontext.py:85 enter` / `:91 leave` around one concretely executed +/// `executioncontext.py enter` / `leave` around one concretely executed /// residual call of an inline sub-walk. /// /// Inlining a call elides the callee's real call sequence, so nothing @@ -452,8 +452,19 @@ impl Drop for InlineConcreteFrameGuard { struct ResidualFrameChainGuard { ec: *mut pyre_interpreter::PyExecutionContext, frame: *mut pyre_interpreter::PyFrame, - saved_topframeref: *mut pyre_interpreter::PyFrame, + /// Shadow-stack index of the caller `topframeref` this guard displaced, + /// held there rather than in the struct because the residual runs + /// arbitrary user code. Frames themselves never move — `FrameBox::new` + /// allocates old-gen — but once the tracer stores a `JitVirtualRef` in the + /// chain the displaced value is a nursery object, and a minor collection + /// inside the residual would leave `Drop` writing back a pre-move pointer. + /// Rooting lets the collector forward it in place, as `CurrentFrameGuard` + /// already does for the same field. + saved_root: usize, previous_published: *mut pyre_interpreter::PyFrame, + /// Whether this guard performed the chain write, so `Drop` restores only + /// what it changed. False when the chain already named `frame`. + entered: bool, } impl ResidualFrameChainGuard { @@ -467,20 +478,33 @@ impl ResidualFrameChainGuard { return None; } let saved_topframeref = unsafe { (*ec).topframeref }; - // Re-entering the same frame would make it its own caller. - if std::ptr::eq(saved_topframeref, frame) { - return None; - } - unsafe { - (*frame).f_backref = saved_topframeref; - (*ec).topframeref = frame; + // Re-entering the same frame would make it its own caller. `topframeref` + // holds a `jit.virtual_ref`, so a vref that NAMES this frame is the same + // re-entry as the bare pointer; resolve the referent without forcing, + // because forcing clears `TOKEN_TRACING_RESCALL` and + // `tracing_after_residual_call` reads that as a callee escape. + let entered = !std::ptr::eq( + pyre_interpreter::executioncontext::vref_referent(saved_topframeref), + frame, + ); + if entered { + unsafe { + (*frame).f_backref = saved_topframeref; + (*ec).topframeref = frame; + } } + let saved_root = majit_gc::shadow_stack::push(majit_ir::GcRef(saved_topframeref as usize)); + // Published whether or not this guard wrote the chain: `frame` is the + // one a force inside the residual must redirect its escape onto + // (`flush_active_frame_escape`), and the chain already naming it makes + // that more true, not less. let previous_published = PUBLISHED_INLINE_FRAME.with(|slot| slot.replace(frame)); Some(Self { ec, frame, - saved_topframeref, + saved_root, previous_published, + entered, }) } } @@ -488,14 +512,21 @@ impl ResidualFrameChainGuard { impl Drop for ResidualFrameChainGuard { fn drop(&mut self) { unsafe { - // `executioncontext.py:91-109 leave`: move the raw caller vref - // back without forcing it, then, when the frame escaped, force - // the caller and mark it escaped too. A frame handed to - // application code keeps a reference to its caller, so the caller - // must stay materialised; dropping that propagation would leave - // the escape recorded only on a frame the walk owns privately. + // `executioncontext.py leave`: move the raw caller vref back without + // forcing it, then, when the frame escaped, force the caller and + // mark it escaped too. A frame handed to application code keeps a + // reference to its caller, so the caller must stay materialised; + // dropping that propagation would leave the escape recorded only on + // a frame the walk owns privately. + // Read the root back before popping: a collection during the + // residual forwards it in place. + let saved_topframeref = + majit_gc::shadow_stack::get(self.saved_root).0 as *mut pyre_interpreter::PyFrame; + majit_gc::shadow_stack::pop_to(self.saved_root); PUBLISHED_INLINE_FRAME.with(|slot| slot.set(self.previous_published)); - (*self.ec).topframeref = self.saved_topframeref; + if self.entered { + (*self.ec).topframeref = saved_topframeref; + } if (*self.frame).escaped() { let f_back = (*self.frame).get_f_back(); if !f_back.is_null() { @@ -1480,9 +1511,7 @@ pub(crate) fn try_execute_residual_call_via_executor( // (`rpython/jit/metainterp/executor.py:446`), is neither can-raise nor a // call (`resoperation.py:1124-1125`), and is inserted only by the backend // GC rewrite pass after optimization (`backend/llsupport/rewrite.py:948`), - // so it never participates in the metainterp's side-effect analysis. On - // the in-place Object-append arm it is not recorded at all, for the same - // reason — see `FbwWalkMode::append_inplace_wb_covered`. + // so it never participates in the metainterp's side-effect analysis. let is_idempotent_gc_barrier = pyre_interpreter::is_list_write_barrier(func_ptr as usize); if allboxes.len() - 1 > majit_translate::codewriter::insns::MAX_HOST_CALL_ARITY { return Ok(ResidualExecOutcome::Declined(ResidualDecline::Symbolic)); @@ -2492,14 +2521,15 @@ pub(crate) fn do_not_in_trace_call_result( /// `*token_ptr == 0` assertion in `tracing_before_residual_call` /// intact. /// -/// `vrefs_before_residual_call` / `vrefs_after_residual_call` -/// (`pyjitpl.py`) are unported. `PyreSym` does carry -/// `virtualref_boxes` (`state.rs`), so what is missing is the bracket -/// itself: the pre-call `vrefinfo.tracing_before_residual_call` loop -/// and the post-call `stop_tracking_virtualref`. Unreachable today — -/// the codewriter emits no `jit.virtual_ref` producers -/// (`jit/call.rs`), leaving `virtualref_boxes` empty so both upstream -/// loops iterate zero times. +/// Despite the name it records no vref half. `vrefs_before_residual_call` / +/// `vrefs_after_residual_call` / `stop_tracking_virtualref` are ported on +/// `TraceCtx` and wired on the metainterp leg; what is missing is the walker +/// calling them. Note the two `virtualref_boxes`: the bracket and the guard +/// snapshots read `TraceCtx`'s, while `PyreSym`'s is touched only by the +/// caller-less `opimpl_virtual_ref` and the resume-side decode. Unreachable +/// today — the codewriter emits no `jit.virtual_ref` producers +/// (`jit/call.rs`), leaving both empty so every upstream loop iterates zero +/// times. pub(crate) fn walker_vable_and_vrefs_before_residual_call(ctx: &mut TraceCtx) { // pyjitpl.py: vinfo = self.jitdriver_sd.virtualizable_info; // if vinfo is not None: @@ -3398,31 +3428,18 @@ pub(crate) fn dispatch_residual_call_iRd_kind( .profiler() .count_ops(call_opcode, majit_metainterp::counters::RECORDED_OPS); } - // `list_write_barrier` on the Object strategy's in-place append arm: - // the backend GC rewrite already marks the same store's items block - // with `COND_CALL_GC_WB_ARRAY`, and the list's `items` pointer did not - // change, so a recorded barrier call is a second barrier upstream never - // emits. Skip the record; the executor below still runs it concretely, - // because the walk itself mutates the live heap. `OpRef::NONE` is safe - // as the result slot: the barrier is void, so the only consumer - // (`set_opref_concrete` on the executed result) is the `Type::Void` - // no-op arm. - let wb_covered = ctx - .fbw_mode - .append_inplace_wb_covered_receiver - .is_some_and(|receiver| { - allboxes.len() == 2 - && matches!(ctx.trace_ctx.box_value(allboxes[0]), Some(majit_ir::Value::Int(addr)) - if pyre_interpreter::is_list_write_barrier(addr as usize)) - && matches!(ctx.trace_ctx.box_value(allboxes[1]), Some(majit_ir::Value::Ref(r)) - if r.as_usize() == receiver) - }); - let recorded = if wb_covered { - OpRef::NONE - } else { - ctx.trace_ctx - .record_op_with_descr(call_opcode, &allboxes, descr.clone()) - }; + // Always record `list_write_barrier` on the Object strategy's in-place + // append arm. Dropping it in favour of the backend's + // `COND_CALL_GC_WB_ARRAY` on the block's `setarrayitem` is unsound: a + // guard-failure bridge that re-materializes the items block appends into + // it without that array barrier ever firing, so an `old -> young` slot + // store leaves the block off the remembered set. A later minor frees + // the still-referenced young element and the collector then reads a + // freed (poison) header. The list barrier remembers the enclosing + // `W_ListObject`, whose trace reaches every slot, and keeps them alive. + let recorded = ctx + .trace_ctx + .record_op_with_descr(call_opcode, &allboxes, descr.clone()); // pyjitpl.py `_record_helper_pure` parity: for // `CallPure*` whose every argbox carries a known `box_value`, diff --git a/pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs b/pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs index 83a764e2bfc..a043c44126f 100644 --- a/pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs +++ b/pyre/pyre-jit-trace/src/jitcode_dispatch/specialize.rs @@ -109,9 +109,10 @@ use super::*; /// that the converged walker would route through. Production reach /// today is zero — `jtransform.rs jit.force_virtual` is the only /// producer and pyre's interpreter does not emit it. -/// - `vrefs_after_residual_call` is unported; no `jit.virtual_ref` -/// producers exist today, so the upstream loops are empty. Vable forces -/// are detected by the residual-call execution path's heap-token bracket. +/// - `vrefs_after_residual_call` is ported on `TraceCtx` but the walker +/// never calls it; no `jit.virtual_ref` producers exist today, so the +/// upstream loops are empty either way. Vable forces are detected by the +/// residual-call execution path's heap-token bracket. /// - `direct_libffi_call` (`pyjitpl.py`) — pyre's live /// tracer also returns `None` from this helper unless a /// `CIF_DESCRIPTION_P` parser + dynamic `calldescr` builder lands @@ -5421,16 +5422,6 @@ pub(crate) fn orthodox_list_append_commit( let value_concrete = ConcreteValue::Ref(value); let saved_fbw_mode = ctx.fbw_mode; ctx.fbw_mode.inline_subwalk = true; - // Read the arm AFTER any empty-strategy promotion above, so the predicate - // sees the storage the sub-walk will actually append into. An - // Empty->Object promotion is not the pre-existing-block in-place case: - // it has just installed a young `items` block into the (possibly old) - // list wrapper. Keep the body's `list_write_barrier` for that transition; - // only an append whose Object block predated this append is covered solely - // by the SetarrayitemGc array barrier. - ctx.fbw_mode.append_inplace_wb_covered_receiver = (!promote_empty - && unsafe { pyre_object::w_list_append_stores_into_gc_block_in_place(inner_self) }) - .then_some(inner_self as usize); let walk_result = run_sub_jitcode_walk( ctx, op.pc, diff --git a/pyre/pyre-jit-trace/src/state.rs b/pyre/pyre-jit-trace/src/state.rs index 759810b9eb6..ade08ca5c8e 100644 --- a/pyre/pyre-jit-trace/src/state.rs +++ b/pyre/pyre-jit-trace/src/state.rs @@ -10047,15 +10047,25 @@ fn materialize_virtual_object( return None; } - unsafe { - let ptr = raw as *mut PyObject; - (*ptr).ob_type = vtable as *const PyType; - // rclass.py:739-743 set `w_class` from the cached instantiate - // pointer on the PyType. Tracing may later overwrite this via - // an explicit `SetfieldGc(w_class)`; the field replay below - // takes precedence for that case (heaptracker.py:66-style - // "typeptr" filter does NOT apply to w_class in pyre). - (*ptr).w_class = get_instantiate(&*(vtable as *const PyType)); + if vtable as u64 == majit_metainterp::virtualref::JIT_VIRTUAL_REF_VTABLE { + // `JitVirtualRef` is a `GcStruct` whose `('super', rclass.OBJECT)` slot + // holds the type-id constant itself, not a `PyType *`. It has no + // `w_class`, and running the arm below would dereference the + // `JIT_VIRTUAL_REF_VTABLE` magic as a type object. The field replay + // then fills `virtual_token` and `forced` from the traced values, the + // same two the optimizer seeds when it lowers `VIRTUAL_REF`. + unsafe { (raw as *mut u64).write(vtable as u64) }; + } else { + unsafe { + let ptr = raw as *mut PyObject; + (*ptr).ob_type = vtable as *const PyType; + // rclass.py:739-743 set `w_class` from the cached instantiate + // pointer on the PyType. Tracing may later overwrite this via + // an explicit `SetfieldGc(w_class)`; the field replay below + // takes precedence for that case (heaptracker.py:66-style + // "typeptr" filter does NOT apply to w_class in pyre). + (*ptr).w_class = get_instantiate(&*(vtable as *const PyType)); + } } // resume.py:597-603 setfields parity: for each traced field, diff --git a/pyre/pyre-jit-trace/src/trace.rs b/pyre/pyre-jit-trace/src/trace.rs index 537bfc02162..5b2f18b3da7 100644 --- a/pyre/pyre-jit-trace/src/trace.rs +++ b/pyre/pyre-jit-trace/src/trace.rs @@ -2375,7 +2375,12 @@ fn try_adopt_multi_frame_blackhole( (*(cf_addr as *mut pyre_interpreter::PyFrame)).execution_context as *mut pyre_interpreter::PyExecutionContext }; - let saved_topframeref = unsafe { (*ec).topframeref }; + // Rooted for the whole drive: frames themselves never move, but once the + // tracer stores a `JitVirtualRef` in the chain the displaced value is a + // nursery object, and a collection inside the drive would leave the + // restore below writing back a pre-move pointer. + let saved_root = + majit_gc::shadow_stack::push(majit_ir::GcRef(unsafe { (*ec).topframeref } as usize)); // `enter`: publish `ec.topframeref = ` before it runs. let set_topframeref = |frame_ptr: i64| unsafe { (*ec).topframeref = frame_ptr as *mut pyre_interpreter::PyFrame; @@ -2407,7 +2412,11 @@ fn try_adopt_multi_frame_blackhole( Some(per_frame.as_slice()), Some(&set_topframeref as &dyn Fn(i64)), ); - // `leave`: restore `ec.topframeref` to the portal after the inline chain. + // `leave`: restore `ec.topframeref` to the portal after the inline chain, + // reading the root back so an in-place forward during the drive is kept. + let saved_topframeref = + majit_gc::shadow_stack::get(saved_root).0 as *mut pyre_interpreter::PyFrame; + majit_gc::shadow_stack::pop_to(saved_root); unsafe { (*ec).topframeref = saved_topframeref; } diff --git a/pyre/pyre-object/src/listobject.rs b/pyre/pyre-object/src/listobject.rs index bbc151387aa..b35008b2677 100644 --- a/pyre/pyre-object/src/listobject.rs +++ b/pyre/pyre-object/src/listobject.rs @@ -1161,27 +1161,6 @@ pub unsafe fn w_list_uses_empty_storage(obj: PyObjectRef) -> bool { list.strategy == ListStrategy::Empty } -/// True when the next `w_list_append` on `obj` takes the Object strategy's -/// in-place arm (`rlist.py:285` resize-ge fast case) into a GC-managed items -/// block: spare capacity, so the store is an item-slot write and the list's -/// `items` pointer does not change. -/// -/// The tracer uses this to decide whether the recorded trace needs -/// `list_write_barrier` at all — see `FbwWalkMode::append_inplace_wb_covered`. -/// A `std::alloc` block (`PYRE_GC_ITEMSBLOCK=0`) answers false: it has no GC -/// header for an array barrier to mark, so the barrier on the enclosing -/// `W_ListObject` is the only thing keeping the block's slots reachable. -/// -/// # Safety -/// `obj` must point to a valid `W_ListObject`. -pub unsafe fn w_list_append_stores_into_gc_block_in_place(obj: PyObjectRef) -> bool { - let list = &*(obj as *const W_ListObject); - list.strategy == ListStrategy::Object - && ll_list_obj_length(list) < ll_list_obj_capacity(list) - && !list.items.is_null() - && crate::gc_hook::try_gc_owns_object(list.items as *mut u8) -} - /// Rebuild the list's object storage from a Vec. unsafe fn rebuild_object_items(list: &mut W_ListObject, items: Vec) { list.set_object_items_from_vec(items); diff --git a/pyre/pyre-object/src/unicodeobject.rs b/pyre/pyre-object/src/unicodeobject.rs index c76668e2580..8fe015de347 100644 --- a/pyre/pyre-object/src/unicodeobject.rs +++ b/pyre/pyre-object/src/unicodeobject.rs @@ -22,9 +22,10 @@ use crate::pyobject::*; /// Python string object. /// /// Layout: -/// `[ob_type | w_class | value:*mut Wtf8Buf | byte_len | len | w_slots | hash]` -/// `byte_len` is the WTF-8 byte count (RPython STR `rstr.py:1226 -/// Array(Char)` parity — `llmodel.py:667 bh_strlen` reads this). +/// `[ob_type | w_class | value:*mut Wtf8Buf | byte_len | len | w_slots | +/// index_storage:*mut Utf8IndexStorage | hash]` +/// `byte_len` is the WTF-8 byte count (RPython STR `rstr.py Array(Char)` +/// parity — `llmodel.py bh_strlen` reads this). /// `len` is the codepoint count (RPython UNICODE parity — /// `bh_unicodelen` reads this). The `value` pointer owns a /// heap-allocated `Wtf8Buf` (via `Box::into_raw`).