Skip to main content

rlx_runtime/backend/
cpu_backend.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4use super::*;
5use rlx_cpu::{arena::Arena, thunk};
6use rlx_ir::{DType, NodeId, Op};
7use rlx_opt::memory::{self, MemoryPlan};
8// Arena typed read/write helpers live in `crate::arena` so every
9// backend (CPU, Metal, future CUDA/wgpu/WASM) shares one implementation.
10use rlx_driver::arena::{read_typed_to_f32, write_typed_from_f32};
11
12// `madvise(2)` — used by `release_scratch_pages` to return idle scratch pages to
13// the OS without pulling in the whole `libc` crate.
14#[cfg(unix)]
15unsafe extern "C" {
16    fn madvise(
17        addr: *mut core::ffi::c_void,
18        len: usize,
19        advice: core::ffi::c_int,
20    ) -> core::ffi::c_int;
21}
22
23pub struct CpuBackend;
24
25impl Backend for CpuBackend {
26    fn supported_ops(&self) -> &'static [rlx_ir::OpKind] {
27        rlx_cpu::SUPPORTED_OPS
28    }
29
30    fn compile(&self, graph: Graph, options: &CompileOptions) -> Box<dyn ExecutableGraph> {
31        use rlx_opt::pass::Pass as _;
32        static ONNX_KERNELS: std::sync::Once = std::sync::Once::new();
33        ONNX_KERNELS.call_once(rlx_cpu::onnx_ref::register_onnx_reference_kernels);
34        // Claimed `Op::If` / `Op::While` → primitives before legalize (CPU has
35        // no native sub-graph executor). Also runs again in the fusion
36        // pipeline; no-op when neither op is present.
37        let graph = rlx_opt::LowerControlFlow.run(graph);
38        // Lower f32 SPD-manifold ops (ReEig/LogEig/BiMap/SpdBatchNorm) to the
39        // graph-primitive Jacobi eigensolver BEFORE legalize — like If/While,
40        // the CPU backend has no f32 SPD kernel (the native ones are f64
41        // LAPACK). No-op unless an f32 SPD op is present; f64 SPD nodes are
42        // left untouched for the native LAPACK path.
43        let graph = rlx_opt::LowerSpectral.run(graph);
44        // PLAN L4: legalize against the backend's claimed op set
45        // BEFORE running fusion (so the diagnostic points at the
46        // user's IR, not at a fused-away node).
47        if let Err(errors) = rlx_opt::legalize_for_backend(&graph, rlx_cpu::SUPPORTED_OPS) {
48            panic!("{}", rlx_opt::format_legalize_error("cpu", &errors));
49        }
50        let policy = options.policy.clone();
51        let _precision = options.precision;
52        let cfg = rlx_cpu::config::RuntimeConfig::global();
53
54        let graph = crate::precompile::precompile_cleanup(graph, options);
55
56        // Run fusion pipeline (HIR/MIR/LIR ideology — fusion is first-class).
57        let mut compile_opts = options.clone();
58        compile_opts.arena_alignment = cfg.arena_alignment;
59        let compile_result = crate::stages::compile_graph_stages_for_backend(
60            rlx_driver::Device::Cpu,
61            graph,
62            &compile_opts,
63            rlx_cpu::SUPPORTED_OPS,
64        );
65        crate::stages::maybe_log_fusion(&compile_result.fusion);
66        let fused = compile_result.lir.into_graph();
67        // FusedConvBiasAct / PartitionedConv / FusedTransformerLayer /
68        // TransformRegion / BatchElementwiseRegion may survive fusion now
69        // that they are claimed — expand to primitives the thunk path runs.
70        let fused = rlx_cpu::prepare_graph_for_thunks(fused);
71
72        // Apply precision policy AFTER fusion — Cast nodes don't disrupt
73        // the now-flattened fused ops.
74        let fused = match policy {
75            Some(p) => rlx_opt::AutoMixedPrecision::new(p).run(fused),
76            None => fused,
77        };
78
79        let io_manifest = cpu_low_precision::IoDtypeManifest::from_graph(&fused);
80        let exec_graph = if cpu_low_precision::needs_f32_exec(&fused) {
81            cpu_low_precision::promote_to_f32(fused)
82        } else {
83            fused
84        };
85
86        // Re-plan after precision rewrites (may change dtypes / sizes).
87        // CPU runs the schedule strictly in-order, so it does NOT need the
88        // conservative `pin_output_ancestors` guard (which pins the whole output
89        // ancestor DAG to the last step and destroys slot reuse on deep
90        // feed-forward graphs — the supertonic vector_estimator/vocoder arenas
91        // ballooned to 1.36 GB / 1.17 GB from it). `plan_memory_native` keeps the
92        // same Native dtype widths but only pins when the graph has host indexing
93        // (Scatter/Gather), so ordinary graphs get full liveness-based reuse.
94        //
95        // `_in_order` additionally drops the `dequant_host_fallback` pin for the
96        // same in-order reason: that guard protects GPU backends whose dequant
97        // matmuls flush on a DEFERRED host path, which CPU never takes (it runs
98        // `Thunk::DequantMatMulGguf` inline). Left on, its chain-walk pins nearly
99        // every activation of a packed K-quant prefill to the graph end — the
100        // difference between a ~4 GB reused arena and a 60 GB pinned one on a
101        // 30B-class GGUF.
102        let plan = memory::plan_memory_native_in_order(&exec_graph, cfg.arena_alignment);
103        if rlx_ir::env::flag("RLX_CPU_ARENA_REPORT") {
104            eprint!("[rlx-arena-report]\n{}", plan.report());
105        }
106        if cfg.verbose >= 1 {
107            eprintln!(
108                "[rlx] arena: {} bytes, {} buffers, alignment: {}",
109                plan.arena_size,
110                plan.assignments.len(),
111                cfg.arena_alignment
112            );
113        }
114        Box::new(build_cpu_executable(
115            exec_graph,
116            plan,
117            io_manifest,
118            options.rng,
119        ))
120    }
121
122    fn compile_lir(&self, lir: LirModule, options: &CompileOptions) -> Box<dyn ExecutableGraph> {
123        // `Instant` is unimplemented on wasm32 — never touch it there.
124        #[cfg(not(target_arch = "wasm32"))]
125        let prof = rlx_ir::env::var_os("RLX_PROFILE_COMPILE").is_some();
126        #[cfg(not(target_arch = "wasm32"))]
127        let tt = if prof {
128            Some(std::time::Instant::now())
129        } else {
130            None
131        };
132        let alignment = lir.buffers.alignment.max(options.arena_alignment);
133        let mut graph = lir.into_graph();
134        // Session path: fusion may leave claimed fused/region ops in LIR;
135        // expand before broadcast legalize + arena plan + thunks.
136        graph = rlx_cpu::prepare_graph_for_thunks(graph);
137        {
138            use rlx_opt::pass::Pass as _;
139            graph = rlx_opt::LegalizeBroadcast.run(graph);
140        }
141        if let Some(p) = options.policy.clone() {
142            use rlx_opt::pass::Pass;
143            graph = rlx_opt::AutoMixedPrecision::new(p).run(graph);
144        }
145        let io_manifest = cpu_low_precision::IoDtypeManifest::from_graph(&graph);
146        let promote = cpu_low_precision::needs_f32_exec(&graph);
147        let exec_graph = if promote {
148            cpu_low_precision::promote_to_f32(graph)
149        } else {
150            graph
151        };
152        #[cfg(not(target_arch = "wasm32"))]
153        let t_prep = tt.map(|t| t.elapsed());
154        #[cfg(not(target_arch = "wasm32"))]
155        let t1 = if prof {
156            Some(std::time::Instant::now())
157        } else {
158            None
159        };
160        // LegalizeBroadcast may insert Expand nodes — must replan; the
161        // embedded LIR buffer map is from before legalization. In-order CPU
162        // executor → `plan_memory_native_in_order` (unpinned unless host
163        // indexing, and no deferred-host dequant pin) for full liveness slot
164        // reuse; see the note at the other plan site above.
165        let plan = memory::plan_memory_native_in_order(&exec_graph, alignment);
166        #[cfg(not(target_arch = "wasm32"))]
167        let t_plan = t1.map(|t| t.elapsed());
168        #[cfg(not(target_arch = "wasm32"))]
169        if prof {
170            eprintln!(
171                "[compile_lir] {} nodes: into_graph+passes={:?} plan_memory={:?}",
172                exec_graph.nodes().len(),
173                t_prep.unwrap(),
174                t_plan.unwrap(),
175            );
176        }
177        let cfg = rlx_cpu::config::RuntimeConfig::global();
178        if cfg.verbose >= 1 {
179            eprintln!(
180                "[rlx] compile_lir: arena {} bytes ({} buffers, alignment {})",
181                plan.arena_size,
182                plan.assignments.len(),
183                alignment,
184            );
185        }
186        #[cfg(not(target_arch = "wasm32"))]
187        let t2 = if prof {
188            Some(std::time::Instant::now())
189        } else {
190            None
191        };
192        let exe = build_cpu_executable(exec_graph, plan, io_manifest, options.rng);
193        #[cfg(not(target_arch = "wasm32"))]
194        if let Some(t2) = t2 {
195            eprintln!("[compile_lir] build_thunks={:?}", t2.elapsed());
196        }
197        Box::new(exe)
198    }
199}
200
201fn build_cpu_executable(
202    graph: Graph,
203    plan: MemoryPlan,
204    io_manifest: cpu_low_precision::IoDtypeManifest,
205    rng: rlx_ir::RngOptions,
206) -> CpuExecutable {
207    let mut arena = Arena::from_plan(plan);
208    let mut input_ids = HashMap::new();
209    let mut param_ids = HashMap::new();
210    let mut node_dtypes: HashMap<NodeId, DType> = HashMap::new();
211    for node in graph.nodes() {
212        node_dtypes.insert(node.id, node.shape.dtype());
213        match &node.op {
214            Op::Input { name } => {
215                input_ids.insert(name.clone(), node.id);
216            }
217            Op::Param { name } => {
218                param_ids.insert(name.clone(), node.id);
219            }
220            _ => {}
221        }
222    }
223
224    let schedule = thunk::compile_thunks_with_rng(&graph, &arena, rng);
225
226    let mut input_slots = Vec::new();
227    for node in graph.nodes() {
228        if let Op::Input { name } = &node.op {
229            let off = arena.byte_offset(node.id);
230            let len = node.shape.num_elements().unwrap_or(0);
231            input_slots.push((name.clone(), off, len, node.shape.dtype()));
232        }
233    }
234
235    let output_slots: Vec<(usize, usize)> = graph
236        .outputs
237        .iter()
238        .map(|&id| {
239            let off = arena.byte_offset(id);
240            let len = graph.node(id).shape.num_elements().unwrap_or(0);
241            (off, len)
242        })
243        .collect();
244
245    for node in graph.nodes() {
246        if let Op::Constant { data } = &node.op
247            && arena.has_buffer(node.id)
248            && !data.is_empty()
249        {
250            match node.shape.dtype() {
251                // True-width dtypes (their arena slot is sized to the real
252                // element width, not f32): copy the raw bytes. I64/I32/U32
253                // constants were previously caught by the f32-reinterpret
254                // branch below, which read them in 4-byte chunks as f32 —
255                // corrupting e.g. the VITS sequence-mask `arange` constant
256                // (i64 [0..T-1]) so the downstream i64 `Compare` read garbage.
257                // Bool/U8/I8 must also raw-copy: a 1-byte `ConstantOfShape(true)`
258                // has `data.len()/4 == 0` in the f32 branch, so the slot stayed
259                // zero and Soprano's causal `And(ones, mask)` collapsed → Softmax NaN.
260                DType::F64
261                | DType::F16
262                | DType::BF16
263                | DType::I64
264                | DType::I32
265                | DType::U32
266                | DType::Bool
267                | DType::U8
268                | DType::I8 => {
269                    let off = arena.byte_offset(node.id);
270                    let buf = arena.raw_buf_mut();
271                    let n = buf.len().saturating_sub(off).min(data.len());
272                    buf[off..off + n].copy_from_slice(&data[..n]);
273                }
274                _ => {
275                    let buf = arena.slice_mut(node.id);
276                    let n_floats = data.len() / 4;
277                    let n = buf.len().min(n_floats);
278                    for i in 0..n {
279                        let bytes = [
280                            data[i * 4],
281                            data[i * 4 + 1],
282                            data[i * 4 + 2],
283                            data[i * 4 + 3],
284                        ];
285                        buf[i] = f32::from_le_bytes(bytes);
286                    }
287                }
288            }
289        }
290    }
291
292    CpuExecutable {
293        graph,
294        arena,
295        input_ids,
296        param_ids,
297        node_dtypes,
298        io_manifest,
299        schedule,
300        input_slots,
301        output_slots,
302        handles: HashMap::new(),
303        active_extent: None,
304        moe_resident: None,
305        moe_resident_layers: None,
306        moe_topk_capture: None,
307        baseline_written: false,
308    }
309}
310
311#[derive(Clone)]
312struct CpuExecutable {
313    graph: Graph,
314    arena: Arena,
315    input_ids: HashMap<String, NodeId>,
316    param_ids: HashMap<String, NodeId>,
317    /// Per-node arena dtype. Lets set_param/run cast f32 ↔ F16/BF16
318    /// when AutoMixedPrecision has rewritten the graph.
319    node_dtypes: HashMap<NodeId, DType>,
320    /// User-facing boundary dtypes (before f32 promotion for CPU exec).
321    io_manifest: cpu_low_precision::IoDtypeManifest,
322    schedule: thunk::ThunkSchedule,
323    // Pre-resolved: ordered list of (input_name, arena_byte_offset, max_elems, dtype)
324    input_slots: Vec<(String, usize, usize, DType)>,
325    /// Output (byte_offset, num_elements). dtype is in node_dtypes.
326    output_slots: Vec<(usize, usize)>,
327    /// Persistent buffer handles (KV-cache, optimizer state, etc.).
328    /// Lives outside the arena and survives across run() calls.
329    /// On run(): if a handle's name matches a graph input, the
330    /// handle's data is used as the input.
331    handles: HashMap<String, Vec<f32>>,
332    /// Active-extent hint (`Some((actual, upper))`) for L1 bucketed
333    /// dispatch. When set AND every thunk in the schedule is in
334    /// `Thunk::safe_for_active_extent`, the executor processes only
335    /// `actual / upper` of each kernel's work. Otherwise (or when
336    /// `None`) runs at the full compiled extent. See PLAN L1.
337    active_extent: Option<(usize, usize)>,
338    moe_resident: Option<std::sync::Arc<[bool]>>,
339    moe_resident_layers: Option<std::sync::Arc<Vec<std::sync::Arc<[bool]>>>>,
340    moe_topk_capture: Option<std::sync::Arc<rlx_cpu::moe_topk_capture::MoeTopkCapture>>,
341    /// Whether params + constants are already resident in the arena. While
342    /// `true`, `restore_arena_baseline` zeros only the scratch buffers instead
343    /// of re-zeroing + rewriting every param each run (which is O(params) and
344    /// allocates a full params clone — catastrophic for multi-GB models).
345    /// `set_param`/`set_param_typed` reset it to `false`.
346    baseline_written: bool,
347}
348
349unsafe impl Send for CpuExecutable {}
350
351impl CpuExecutable {
352    /// `RLX_CPU_ARENA_REPORT=1`: attribute the arena's resident bytes to nodes,
353    /// split persistent (Param/Input/Constant — live the whole run) vs scratch,
354    /// so a bloated footprint can be traced to specific buffers.
355    fn arena_report_if_requested(&self) {
356        if !rlx_ir::env::flag("RLX_CPU_ARENA_REPORT") {
357            return;
358        }
359        let mut rows: Vec<(usize, NodeId, bool)> = Vec::new();
360        for node in self.graph.nodes() {
361            if !self.arena.has_buffer(node.id) {
362                continue;
363            }
364            let dt = self
365                .node_dtypes
366                .get(&node.id)
367                .copied()
368                .unwrap_or(DType::F32);
369            let bytes = node.shape.num_elements().unwrap_or(0) * dt.size_bytes();
370            let persistent = matches!(
371                node.op,
372                Op::Param { .. } | Op::Input { .. } | Op::Constant { .. }
373            );
374            rows.push((bytes, node.id, persistent));
375        }
376        rows.sort_by_key(|b| std::cmp::Reverse(b.0));
377        let persist: usize = rows.iter().filter(|r| r.2).map(|r| r.0).sum();
378        let scratch: usize = rows.iter().filter(|r| !r.2).map(|r| r.0).sum();
379        eprintln!(
380            "[arena-report] {} buffers, persistent={}MB scratch={}MB",
381            rows.len(),
382            persist >> 20,
383            scratch >> 20
384        );
385        for (b, id, p) in rows.iter().take(15) {
386            eprintln!("  {:>6}MB  node {:>5}  persistent={}", b >> 20, id, p);
387        }
388    }
389
390    /// Per-node dump (mirror of rlx-wgpu's `RLX_WGPU_DUMP_NODES`) for
391    /// cross-backend divergence bisection: each F32 node's max|x| + nonzero
392    /// count in topo order. Diff against the wgpu dump to find the first
393    /// diverging node. `RLX_CPU_DUMP_FLAT=<i>` also prints that flat element.
394    fn dump_nodes_if_requested(&self) {
395        self.arena_report_if_requested();
396        if !rlx_ir::env::flag("RLX_CPU_DUMP_NODES") {
397            return;
398        }
399        let limit = rlx_ir::env::parse_or("RLX_CPU_DUMP_NODES_LIMIT", 2000usize);
400        let flat_probe = rlx_ir::env::parse_or::<usize>("RLX_CPU_DUMP_FLAT", usize::MAX);
401        eprintln!("[rlx-cpu-dump] per-node max |x| (topo order, limit={limit})");
402        let buf = self.arena.raw_buf();
403        // `RLX_CPU_DUMP_NODE_DATA=<id>[,<id>...]` writes each node's raw f32
404        // buffer to `$RLX_CPU_DUMP_DIR/node_<id>.f32`. Unlike the summary loop
405        // below this does not skip Param/Reshape, so a matmul's weights can be
406        // pulled out and the op recomputed in f64 to decide which backend is
407        // right when two disagree.
408        if let Some(spec) = rlx_ir::env::var("RLX_CPU_DUMP_NODE_DATA") {
409            let dir = rlx_ir::env::var("RLX_CPU_DUMP_DIR").unwrap_or_else(|| "/tmp".into());
410            for want in spec.split(',').filter_map(|t| t.trim().parse::<u32>().ok()) {
411                let Some(node) = self.graph.nodes().iter().find(|n| n.id.0 == want) else {
412                    continue;
413                };
414                if !self.arena.has_buffer(node.id) {
415                    continue;
416                }
417                let off = self.arena.byte_offset(node.id);
418                let n = node.shape.num_elements().unwrap_or(0);
419                let data: &[f32] =
420                    unsafe { std::slice::from_raw_parts(buf.as_ptr().add(off) as *const f32, n) };
421                let bytes: Vec<u8> = data.iter().flat_map(|v| v.to_le_bytes()).collect();
422                let path = format!("{dir}/node_{want}.f32");
423                match std::fs::write(&path, &bytes) {
424                    Ok(()) => eprintln!(
425                        "[rlx-cpu-dump] wrote {path} ({n} f32) byte_range=[{off}, {})",
426                        off + n * 4
427                    ),
428                    Err(e) => eprintln!("[rlx-cpu-dump] {path}: {e}"),
429                }
430            }
431        }
432        let mut shown = 0usize;
433        let (mut no_buf, mut skipped_op, mut not_f32) = (0usize, 0usize, 0usize);
434        for (i, node) in self.graph.nodes().iter().enumerate() {
435            if !self.arena.has_buffer(node.id) {
436                no_buf += 1;
437                continue;
438            }
439            if matches!(
440                node.op,
441                rlx_ir::Op::Input { .. }
442                    | rlx_ir::Op::Param { .. }
443                    | rlx_ir::Op::Constant { .. }
444                    | rlx_ir::Op::Reshape { .. }
445                    | rlx_ir::Op::Cast { .. }
446            ) {
447                skipped_op += 1;
448                continue;
449            }
450            if self
451                .node_dtypes
452                .get(&node.id)
453                .copied()
454                .unwrap_or(DType::F32)
455                != DType::F32
456            {
457                not_f32 += 1;
458                continue;
459            }
460            let off = self.arena.byte_offset(node.id);
461            let n = node.shape.num_elements().unwrap_or(0);
462            let data: &[f32] =
463                unsafe { std::slice::from_raw_parts(buf.as_ptr().add(off) as *const f32, n) };
464            let max = data.iter().fold(0f32, |m, &v| m.max(v.abs()));
465            let nz = data.iter().filter(|&&v| v != 0.0).count();
466            // f64 sum: max/tail are single-element summaries and two tensors can
467            // share both while differing elsewhere, which hides the real first
468            // divergence. A whole-tensor sum does not.
469            let sum: f64 = data.iter().map(|&v| v as f64).sum();
470            // Max over the LAST row (final index of the second-to-last axis).
471            // Cross-backend divergences often sit in the tail token only, where
472            // the whole-tensor max is dominated by other rows and matches.
473            let row = node
474                .shape
475                .dims()
476                .last()
477                .and_then(|d| match d {
478                    rlx_ir::Dim::Static(n) => Some(*n),
479                    rlx_ir::Dim::Dynamic(_) => None,
480                })
481                .unwrap_or(data.len())
482                .max(1);
483            let tail = data
484                .len()
485                .checked_sub(row)
486                .map(|o| data[o..].iter().fold(0f32, |m, &v| m.max(v.abs())))
487                .unwrap_or(max);
488            // Sum of |v| over the tail row. A LayerNorm row sums to ~0, so a
489            // plain sum cancels and hides real differences; this does not.
490            let tabs: f64 = data
491                .len()
492                .checked_sub(row)
493                .map(|o| data[o..].iter().map(|&v| v.abs() as f64).sum())
494                .unwrap_or(0.0);
495            let flat_s = if flat_probe < data.len() {
496                format!(" flat[{flat_probe}]={:.6}", data[flat_probe])
497            } else {
498                String::new()
499            };
500            eprintln!(
501                "  [{i:>3}] in={:?} id={:?} name={:?} {:?} shape={:?} max={max:.6} tail={tail:.6} sum={sum:.6} tabs={tabs:.6} nonzero={nz}/{}{flat_s}",
502                node.inputs.iter().map(|n| n.0).collect::<Vec<_>>(),
503                node.id,
504                node.name,
505                node.op,
506                node.shape.dims(),
507                data.len()
508            );
509            // `RLX_CPU_ROW_TABS=<node_id>`: per-row sum of |v| for one node.
510            // Lets a GPU tail row be searched for among ALL the reference rows —
511            // if it matches a different row exactly, the kernel is indexing
512            // wrong rather than computing wrong.
513            if rlx_ir::env::parse_or::<u32>("RLX_CPU_ROW_TABS", u32::MAX) == node.id.0 {
514                for (r, chunk) in data.chunks(row).enumerate() {
515                    let t: f64 = chunk.iter().map(|&v| v.abs() as f64).sum();
516                    eprintln!("  rowtabs node={} row={r} tabs={t:.6}", node.id.0);
517                }
518            }
519            shown += 1;
520            if shown >= limit {
521                break;
522            }
523        }
524        eprintln!(
525            "[rlx-cpu-dump] nodes={} shown={shown} no_buffer={no_buf} skipped_op={skipped_op} not_f32={not_f32}",
526            self.graph.nodes().len()
527        );
528    }
529
530    /// Write a f32 input slice into the arena, casting to the node's dtype.
531    fn write_input(&mut self, id: NodeId, data: &[f32]) {
532        let dtype = self.node_dtypes.get(&id).copied().unwrap_or(DType::F32);
533        let off = self.arena.byte_offset(id);
534        let buf = self.arena.raw_buf_mut();
535        let elem_size = dtype.size_bytes();
536        let max_elems = (buf.len() - off) / elem_size;
537        unsafe {
538            write_typed_from_f32(buf.as_mut_ptr().add(off), dtype, data, max_elems);
539        }
540    }
541
542    /// Read a node's arena bytes back as Vec<f32>, casting from its dtype.
543    fn read_output(&self, id: NodeId) -> Vec<f32> {
544        let dtype = self.node_dtypes.get(&id).copied().unwrap_or(DType::F32);
545        let off = self.arena.byte_offset(id);
546        let buf = self.arena.raw_buf();
547        let n_elems = self.graph.node(id).shape.num_elements().unwrap_or(0);
548        unsafe { read_typed_to_f32(buf.as_ptr().add(off), dtype, n_elems) }
549    }
550}
551
552impl ExecutableGraph for CpuExecutable {
553    fn capabilities(&self) -> crate::ExecutableCapabilities {
554        crate::ExecutableCapabilities {
555            clone: true,
556            moe: true,
557            // `bind_handle` / `read_handle` are implemented below and return
558            // real data; the flag said otherwise.
559            persistent_handles: true,
560            typed_io: true,
561            active_extent: true,
562            ..crate::ExecutableCapabilities::NONE
563        }
564    }
565
566    fn clone_box(&self) -> Box<dyn ExecutableGraph> {
567        Box::new(self.clone())
568    }
569    fn set_param(&mut self, name: &str, data: &[f32]) {
570        // Params live solely in the arena (dedicated, never-aliased slots, see
571        // the memory planner) — no redundant CPU-side copy is kept, which would
572        // double the weight footprint for multi-GB models.
573        // Cast f32 → arena dtype when the param has been rewritten to F16/BF16.
574        if let Some(&id) = self.param_ids.get(name)
575            && self.arena.has_buffer(id)
576        {
577            let dtype = self.node_dtypes.get(&id).copied().unwrap_or(DType::F32);
578            let off = self.arena.byte_offset(id);
579            let buf = self.arena.raw_buf_mut();
580            let elem_size = dtype.size_bytes();
581            let max_elems = (buf.len() - off) / elem_size;
582            unsafe {
583                write_typed_from_f32(buf.as_mut_ptr().add(off), dtype, data, max_elems);
584            }
585        }
586    }
587
588    fn set_param_range(&mut self, name: &str, byte_offset: usize, data: &[u8]) -> bool {
589        // Paged-MoE residency: rewrite ONE expert slot of a large bank instead
590        // of re-uploading the bank. Without this the caller has to assemble the
591        // whole gathered bank in a Vec and hand it over, copying every active
592        // byte twice — at GLM-5.3-Flash scale ~2 GB of redundant memcpy per
593        // token, and CPU is the backend the cluster planner assigns paged
594        // stages to most often.
595        let Some(&id) = self.param_ids.get(name) else {
596            return false;
597        };
598        if !self.arena.has_buffer(id) {
599            return false;
600        }
601        // The bytes go in verbatim, and the signature carries no dtype to check
602        // them against. A param that AMP rewrote to F16/BF16 would be silently
603        // corrupted by F32 bytes, so refuse anything but F32 storage and let
604        // the caller take the whole-buffer path, which does convert.
605        if self.node_dtypes.get(&id).copied().unwrap_or(DType::F32) != DType::F32 {
606            return false;
607        }
608        // Bound to the PARAM, not to the arena: params sit in a shared buffer,
609        // so an over-long write does not fault — it lands in whichever tensor
610        // is next and corrupts it silently.
611        let size = self.arena.byte_size(id);
612        let Some(end) = byte_offset.checked_add(data.len()) else {
613            return false;
614        };
615        if end > size {
616            return false;
617        }
618        let off = self.arena.byte_offset(id);
619        self.arena.raw_buf_mut()[off + byte_offset..off + end].copy_from_slice(data);
620        true
621    }
622
623    fn run(&mut self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
624        self.restore_arena_baseline();
625        // 1. Apply persistent handles first — they act like default inputs.
626        //    Explicit `inputs` passed to run() override matching handle names.
627        let handle_names: Vec<String> = self.handles.keys().cloned().collect();
628        for name in &handle_names {
629            if let Some(&id) = self.input_ids.get(name)
630                && self.arena.has_buffer(id)
631            {
632                let data = self.handles.get(name).cloned().unwrap_or_default();
633                self.write_input(id, &data);
634            }
635        }
636        // 2. Explicit per-call inputs override handles.
637        for &(name, data) in inputs {
638            if let Some(&id) = self.input_ids.get(name)
639                && self.arena.has_buffer(id)
640            {
641                self.write_input(id, data);
642            }
643        }
644
645        // Active-extent fast-path (PLAN L1): if hinted AND every thunk
646        // in the schedule supports it, run scaled. Otherwise fall back
647        // to full-extent dispatch — preserves correctness when the
648        // schedule contains a thunk that hasn't yet been wired in.
649        let active_used = if let Some((actual, upper)) = self.active_extent {
650            thunk::execute_thunks_active(&self.schedule, self.arena.raw_buf_mut(), actual, upper)
651        } else {
652            false
653        };
654        if !active_used {
655            // Execute via pre-compiled thunks (zero per-node dispatch overhead)
656            thunk::execute_thunks(&self.schedule, self.arena.raw_buf_mut());
657        }
658
659        self.dump_nodes_if_requested();
660
661        // 3. Sync any handle whose name matches a graph OUTPUT —
662        //    KV-cache pattern: outputs flow back into the same-named
663        //    handle for the next iteration.
664        for (idx, &out_id) in self.graph.outputs.iter().enumerate() {
665            let name = format!("out{idx}");
666            if self.handles.contains_key(&name) {
667                let v = self.read_output(out_id);
668                self.handles.insert(name, v);
669            }
670        }
671
672        self.graph
673            .outputs
674            .iter()
675            .map(|&out_id| self.read_output(out_id))
676            .collect()
677    }
678
679    fn run_raw(&mut self, inputs: &[(&str, &[f32])]) -> Vec<(*const f32, usize)> {
680        self.restore_arena_baseline();
681        // Copy inputs by name (HashMap lookup), casting to arena dtype.
682        for &(name, data) in inputs {
683            if let Some(&id) = self.input_ids.get(name)
684                && self.arena.has_buffer(id)
685            {
686                self.write_input(id, data);
687            }
688        }
689        thunk::execute_thunks(&self.schedule, self.arena.raw_buf_mut());
690        // Note: pointers are raw arena bytes — for F16 outputs, callers
691        // must read 2 bytes/elem, not 4. run() is the safe path for
692        // mixed precision; run_raw() is only meaningful for F32.
693        self.graph
694            .outputs
695            .iter()
696            .map(|&out_id| {
697                let (ptr, len) = self.arena.raw_ptr(out_id);
698                (ptr as *const f32, len)
699            })
700            .collect()
701    }
702
703    /// Fastest path: inputs by index (matching input_slots order), zero-copy output.
704    /// No HashMap, no name matching, no Vec allocation. Casts f32 input
705    /// to F16/BF16 if the input slot's dtype was rewritten.
706    fn run_slots(&mut self, inputs: &[&[f32]]) -> &[(usize, usize)] {
707        self.restore_arena_baseline();
708        let buf = self.arena.raw_buf_mut();
709        for (i, &data) in inputs.iter().enumerate() {
710            if i < self.input_slots.len() {
711                let (_, off, max_len, dtype) = &self.input_slots[i];
712                unsafe {
713                    write_typed_from_f32(buf.as_mut_ptr().add(*off), *dtype, data, *max_len);
714                }
715            }
716        }
717        thunk::execute_thunks(&self.schedule, self.arena.raw_buf_mut());
718        &self.output_slots
719    }
720
721    fn arena_ptr(&self) -> *const u8 {
722        self.arena.raw_buf_mut_ptr()
723    }
724
725    fn bind_handle(&mut self, name: &str, data: &[f32]) -> bool {
726        // Persistent buffer: stored separately from arena, survives run().
727        // If the name matches a graph input, run() will use this data
728        // as the input. If the graph also writes back to this name (via
729        // an output binding pattern), read_handle returns the latest.
730        self.handles.insert(name.to_string(), data.to_vec());
731        true
732    }
733
734    fn read_handle(&self, name: &str) -> Option<Vec<f32>> {
735        self.handles.get(name).cloned()
736    }
737
738    fn set_active_extent(&mut self, extent: Option<(usize, usize)>) {
739        self.active_extent = extent;
740    }
741
742    fn release_scratch(&mut self) {
743        self.release_scratch_pages();
744    }
745
746    fn set_rng(&mut self, rng: rlx_ir::RngOptions) {
747        *self.schedule.rng.write().unwrap() = rng;
748    }
749
750    fn rng(&self) -> rlx_ir::RngOptions {
751        *self.schedule.rng.read().unwrap()
752    }
753
754    fn set_moe_resident_experts(&mut self, mask: &[bool]) {
755        self.moe_resident_layers = None;
756        self.schedule.moe_resident_layers = None;
757        self.moe_resident = Some(Arc::from(mask));
758        self.schedule.moe_resident = self.moe_resident.clone();
759    }
760
761    fn set_moe_resident_experts_per_layer(&mut self, masks: &[&[bool]]) {
762        self.moe_resident = None;
763        self.schedule.moe_resident = None;
764        let layers: Vec<Arc<[bool]>> = masks.iter().map(|m| Arc::from(*m)).collect();
765        let arc = Arc::new(layers);
766        self.moe_resident_layers = Some(arc.clone());
767        self.schedule.moe_resident_layers = Some(arc);
768    }
769
770    fn enable_moe_topk_capture(&mut self, num_experts: usize) -> bool {
771        let cap = rlx_cpu::moe_topk_capture::MoeTopkCapture::new(num_experts);
772        self.moe_topk_capture = Some(cap.clone());
773        self.schedule.moe_topk_capture = Some(cap);
774        true
775    }
776
777    fn take_moe_topk_capture(&mut self) -> Option<Vec<Vec<u32>>> {
778        let cap = self.moe_topk_capture.as_ref()?;
779        let layers = cap.take_layers();
780        if layers.is_empty() {
781            None
782        } else {
783            Some(layers)
784        }
785    }
786
787    fn take_moe_residency_stats(&mut self) -> Option<crate::MoeResidencyStats> {
788        rlx_cpu::moe_residency::take_last_forward_stats()
789    }
790
791    /// Typed param upload. F32 / F16 / BF16 go through the existing
792    /// widen-to-f32 path (the CPU arena is historically f32 with
793    /// optional half-precision rewrite). F64 (and any future
794    /// non-widenable dtype) lands directly in the arena as bytes —
795    /// the f32 path would lose precision.
796    fn set_param_typed(&mut self, name: &str, data: &[u8], dtype: rlx_ir::DType) {
797        if matches!(dtype, DType::F64 | DType::I64 | DType::I32 | DType::U32) {
798            self.set_param_bytes(name, data, dtype);
799            return;
800        }
801        // U8 / I8 raw byte tensors: opaque storage for the GGUF
802        // K-quant `Op::DequantMatMul` path (weights stay packed
803        // in the arena). One arena byte = one element.
804        if matches!(dtype, DType::U8 | DType::I8) {
805            self.set_param_bytes(name, data, dtype);
806            return;
807        }
808        if dtype == DType::F32 {
809            let n = data.len() / 4;
810            let s = unsafe { std::slice::from_raw_parts(data.as_ptr() as *const f32, n) };
811            self.set_param(name, s);
812        } else {
813            let f32_buf = super::widen_bytes_to_f32(data, dtype);
814            self.set_param(name, &f32_buf);
815        }
816    }
817
818    /// Typed run with mixed-dtype inputs/outputs.
819    ///
820    /// For each input: if its declared graph dtype matches the
821    /// caller's bytes, we write directly into the arena (zero
822    /// precision loss — F64 stays F64). For F32 with a half-precision
823    /// arena rewrite, we widen as before. F16/BF16 callers go
824    /// through the existing widen path.
825    ///
826    /// Outputs are read straight from the arena in the graph node's
827    /// declared dtype — F64 outputs come back as 8 bytes/element,
828    /// F32 as 4, etc.
829    fn run_typed(
830        &mut self,
831        inputs: &[(&str, &[u8], rlx_ir::DType)],
832    ) -> Vec<(Vec<u8>, rlx_ir::DType)> {
833        // Decide: are *all* inputs F64? If so, use the direct-byte
834        // path for everything and skip the f32 widening machinery
835        // entirely. Mixed dtype graphs (F32 + F64) take the
836        // per-input dispatch route below.
837        let all_f64 = !inputs.is_empty() && inputs.iter().all(|(_, _, dt)| *dt == DType::F64);
838
839        if all_f64 {
840            for (name, data, _) in inputs {
841                if let Some(&id) = self.input_ids.get(*name) {
842                    if !self.arena.has_buffer(id) {
843                        continue;
844                    }
845                    let off = self.arena.byte_offset(id);
846                    let buf = self.arena.raw_buf_mut();
847                    let n = data.len();
848                    debug_assert!(
849                        off + n <= buf.len(),
850                        "run_typed: input '{name}' overflows arena slot"
851                    );
852                    buf[off..off + n].copy_from_slice(data);
853                }
854            }
855            thunk::execute_thunks(&self.schedule, self.arena.raw_buf_mut());
856        } else {
857            // Mixed-dtype path: dtypes that survive untouched
858            // through the f32-aliased arena (F64, I32, I64, U32)
859            // go in as bytes; F32 and the half-precision family
860            // route through widen-to-f32 + run.
861            let mut f32_owned: Vec<(String, Vec<f32>)> = Vec::new();
862            for (name, data, dt) in inputs {
863                let direct = matches!(
864                    *dt,
865                    DType::F64 | DType::I32 | DType::I64 | DType::U32 | DType::C64 | DType::C128
866                );
867                if direct {
868                    if let Some(&id) = self.input_ids.get(*name) {
869                        if !self.arena.has_buffer(id) {
870                            continue;
871                        }
872                        let off = self.arena.byte_offset(id);
873                        let buf = self.arena.raw_buf_mut();
874                        buf[off..off + data.len()].copy_from_slice(data);
875                    }
876                } else {
877                    let v = super::widen_bytes_to_f32(data, *dt);
878                    f32_owned.push((name.to_string(), v));
879                }
880            }
881            for (name, data) in &f32_owned {
882                if let Some(&id) = self.input_ids.get(name.as_str()) {
883                    if self.arena.has_buffer(id) {
884                        self.write_input(id, data);
885                    }
886                }
887            }
888            let active_used = if let Some((actual, upper)) = self.active_extent {
889                thunk::execute_thunks_active(
890                    &self.schedule,
891                    self.arena.raw_buf_mut(),
892                    actual,
893                    upper,
894                )
895            } else {
896                false
897            };
898            if !active_used {
899                thunk::execute_thunks(&self.schedule, self.arena.raw_buf_mut());
900            }
901        }
902
903        self.dump_nodes_if_requested();
904
905        // Read outputs in declared boundary dtypes.
906        self.graph
907            .outputs
908            .iter()
909            .enumerate()
910            .map(|(idx, &id)| {
911                let exec_dtype = self.graph.node(id).shape.dtype();
912                let declared = self.io_manifest.output_dtype(idx, exec_dtype);
913                if matches!(
914                    exec_dtype,
915                    DType::F64
916                        | DType::F16
917                        | DType::BF16
918                        | DType::I32
919                        | DType::I64
920                        | DType::U32
921                        | DType::C64
922                        | DType::C128
923                ) {
924                    let n_elems = self.graph.node(id).shape.num_elements().unwrap_or(0);
925                    let n_bytes = n_elems * exec_dtype.size_bytes();
926                    let off = self.arena.byte_offset(id);
927                    let bytes = self.arena.raw_buf()[off..off + n_bytes].to_vec();
928                    return (bytes, declared);
929                }
930                let f32_vals = self.read_output(id);
931                if declared != exec_dtype {
932                    return (super::narrow_f32_to_bytes(&f32_vals, declared), declared);
933                }
934                let bytes = f32_vals.iter().flat_map(|v| v.to_le_bytes()).collect();
935                (bytes, declared)
936            })
937            .collect()
938    }
939}
940
941impl CpuExecutable {
942    /// Clear ephemeral (scratch) arena slots before each `run()`. Params are
943    /// written into their dedicated, never-aliased arena slots by `set_param`
944    /// and live for the whole execution, so they are NOT re-zeroed/rewritten
945    /// here — only the intermediate buffers (which carry stale data from the
946    /// previous pass) are zeroed. Compile-time constants are written once.
947    ///
948    /// This keeps the per-run cost O(scratch) instead of O(params): a previous
949    /// version cloned + rewrote the entire (multi-GB) weight region every run,
950    /// which made large models swap-thrash.
951    /// Sorted, merged persistent (param + constant) byte ranges in the arena.
952    /// These live for the whole execution; everything else is ephemeral scratch.
953    /// Used by `release_scratch_pages` (returns the complement's pages to the
954    /// OS), which is `cfg(unix)` — hence the same gate here.
955    /// `restore_arena_baseline` computes its own persistent set inline.
956    #[cfg(unix)]
957    fn persistent_keep_ranges(&self) -> Vec<(usize, usize)> {
958        let persistent: std::collections::HashSet<NodeId> = {
959            let mut s: std::collections::HashSet<NodeId> =
960                self.param_ids.values().copied().collect();
961            for node in self.graph.nodes() {
962                if matches!(node.op, Op::Constant { .. }) {
963                    s.insert(node.id);
964                }
965            }
966            s
967        };
968        let mut keep: Vec<(usize, usize)> = self
969            .graph
970            .nodes()
971            .iter()
972            .filter_map(|node| {
973                let id = node.id;
974                if !persistent.contains(&id) || !self.arena.has_buffer(id) {
975                    return None;
976                }
977                let dtype = self.node_dtypes.get(&id).copied().unwrap_or(DType::F32);
978                let nbytes = node.shape.num_elements().unwrap_or(0) * dtype.size_bytes();
979                let off = self.arena.byte_offset(id);
980                Some((off, off + nbytes))
981            })
982            .collect();
983        keep.sort_unstable();
984        keep
985    }
986
987    /// Return the ephemeral (scratch) pages of the arena to the OS via
988    /// `madvise(MADV_FREE)`, dropping resident RSS while this cached graph sits
989    /// idle between pipeline stages. The arena's virtual allocation and its param
990    /// bytes stay intact; only whole pages that fall ENTIRELY inside the scratch
991    /// complement of the param ranges are advised, so weight bytes are never
992    /// touched. The next run's `restore_arena_baseline` re-zeros scratch, so this
993    /// is purely advisory and cannot affect correctness. Call it only when the
994    /// graph won't run again immediately (e.g. a TTS pipeline finished with a
995    /// subgraph) — never between the steps of a hot loop, or the pages just fault
996    /// back in. No-op on non-unix.
997    #[allow(unused_variables)]
998    fn release_scratch_pages(&self) {
999        #[cfg(unix)]
1000        {
1001            // 16 KiB covers Apple-silicon pages and is a multiple of 4 KiB, so
1002            // rounding to it yields valid page-aligned spans on 4 KiB hosts too.
1003            const PAGE: usize = 16 * 1024;
1004            let keep = self.persistent_keep_ranges();
1005            let base = self.arena.raw_buf_mut_ptr() as usize;
1006            let total = self.arena.size();
1007            // Walk the complement of `keep` = the scratch spans.
1008            let mut cursor = 0usize;
1009            let mut spans: Vec<(usize, usize)> = Vec::new();
1010            for &(s, e) in &keep {
1011                let s = s.min(total);
1012                if cursor < s {
1013                    spans.push((cursor, s));
1014                }
1015                cursor = cursor.max(e.min(total));
1016            }
1017            if cursor < total {
1018                spans.push((cursor, total));
1019            }
1020            for (s, e) in spans {
1021                let a = (base + s).div_ceil(PAGE) * PAGE;
1022                let b = (base + e) / PAGE * PAGE;
1023                if b > a {
1024                    // macOS MADV_FREE (5) is lazy — it flags pages freeable but does
1025                    // NOT drop them from the physical footprint until memory
1026                    // pressure. MADV_FREE_REUSABLE (7) returns them to the OS now and
1027                    // decrements phys_footprint (the next access refaults). Linux
1028                    // MADV_DONTNEED (4) drops anonymous pages immediately and
1029                    // zero-fills on refault. Override with RLX_MADV to trade footprint
1030                    // for refault latency (e.g. RLX_MADV=5 for lazy/pressure-only).
1031                    #[cfg(target_os = "macos")]
1032                    const ADVICE: i32 = 7;
1033                    #[cfg(not(target_os = "macos"))]
1034                    const ADVICE: i32 = 4;
1035                    let advice = rlx_ir::env::var("RLX_MADV")
1036                        .and_then(|s| s.parse().ok())
1037                        .unwrap_or(ADVICE);
1038                    unsafe { madvise(a as *mut core::ffi::c_void, b - a, advice) };
1039                }
1040            }
1041        }
1042    }
1043
1044    fn restore_arena_baseline(&mut self) {
1045        // Persistent slots (params + constants) — never zeroed.
1046        let persistent: std::collections::HashSet<NodeId> = {
1047            let mut s: std::collections::HashSet<NodeId> =
1048                self.param_ids.values().copied().collect();
1049            for node in self.graph.nodes() {
1050                if matches!(node.op, Op::Constant { .. }) {
1051                    s.insert(node.id);
1052                }
1053            }
1054            s
1055        };
1056
1057        // Write compile-time constants into the arena once (a fresh arena is
1058        // zero-initialized; params are already resident via set_param).
1059        if !self.baseline_written {
1060            let constants: Vec<(NodeId, DType, Vec<u8>)> = self
1061                .graph
1062                .nodes()
1063                .iter()
1064                .filter_map(|node| {
1065                    if let Op::Constant { data } = &node.op
1066                        && self.arena.has_buffer(node.id)
1067                        && !data.is_empty()
1068                    {
1069                        Some((node.id, node.shape.dtype(), data.clone()))
1070                    } else {
1071                        None
1072                    }
1073                })
1074                .collect();
1075            for (id, dtype, data) in constants {
1076                self.write_constant_to_arena(id, dtype, &data);
1077            }
1078            self.baseline_written = true;
1079        }
1080
1081        // Zero everything EXCEPT the persistent (param + constant) byte ranges.
1082        //
1083        // We zero the *complement* of the persistent ranges rather than each
1084        // scratch node's exact byte span. That covers inter-slot padding and
1085        // arena gaps too — a kernel that over-reads its input into adjacent
1086        // alignment padding (common in SIMD reductions) would otherwise pick up
1087        // stale bytes from a previous run, since per-node zeroing only clears
1088        // `num_elements` and leaves the padding dirty. The cost stays O(arena −
1089        // params): for a 7B the params dominate the arena and are skipped, so
1090        // the swept region is tiny.
1091        let mut keep: Vec<(usize, usize)> = self
1092            .graph
1093            .nodes()
1094            .iter()
1095            .filter_map(|node| {
1096                let id = node.id;
1097                if !persistent.contains(&id) || !self.arena.has_buffer(id) {
1098                    return None;
1099                }
1100                let dtype = self.node_dtypes.get(&id).copied().unwrap_or(DType::F32);
1101                let nbytes = node.shape.num_elements().unwrap_or(0) * dtype.size_bytes();
1102                let off = self.arena.byte_offset(id);
1103                Some((off, off + nbytes))
1104            })
1105            .collect();
1106        keep.sort_unstable();
1107
1108        let buf = self.arena.raw_buf_mut();
1109        let len = buf.len();
1110        let mut cursor = 0usize;
1111        for (start, end) in keep {
1112            let start = start.min(len);
1113            if cursor < start {
1114                buf[cursor..start].fill(0);
1115            }
1116            cursor = cursor.max(end.min(len));
1117        }
1118        if cursor < len {
1119            buf[cursor..len].fill(0);
1120        }
1121    }
1122
1123    fn write_constant_to_arena(&mut self, id: NodeId, dtype: DType, data: &[u8]) {
1124        match dtype {
1125            DType::F64
1126            | DType::F16
1127            | DType::BF16
1128            | DType::U8
1129            | DType::I8
1130            | DType::Bool
1131            | DType::I64
1132            | DType::I32
1133            | DType::U32 => {
1134                let off = self.arena.byte_offset(id);
1135                let buf = self.arena.raw_buf_mut();
1136                let n = buf.len().saturating_sub(off).min(data.len());
1137                buf[off..off + n].copy_from_slice(&data[..n]);
1138            }
1139            _ => {
1140                let buf = self.arena.slice_mut(id);
1141                let n_floats = data.len() / 4;
1142                let n = buf.len().min(n_floats);
1143                for i in 0..n {
1144                    let bytes = [
1145                        data[i * 4],
1146                        data[i * 4 + 1],
1147                        data[i * 4 + 2],
1148                        data[i * 4 + 3],
1149                    ];
1150                    buf[i] = f32::from_le_bytes(bytes);
1151                }
1152            }
1153        }
1154    }
1155
1156    /// Direct-byte param upload — copies caller's bytes into the
1157    /// arena slot for the named param without any dtype conversion.
1158    /// Used by `set_param_typed` for dtypes that f32-widening would
1159    /// corrupt (F64). Caller is responsible for matching the param's
1160    /// declared graph dtype.
1161    fn set_param_bytes(&mut self, name: &str, data: &[u8], _dtype: rlx_ir::DType) {
1162        // Byte-backed params also live solely in the arena (no CPU-side copy).
1163        self.write_param_bytes_to_arena(name, data);
1164    }
1165
1166    fn write_param_bytes_to_arena(&mut self, name: &str, data: &[u8]) {
1167        if let Some(&id) = self.param_ids.get(name)
1168            && self.arena.has_buffer(id)
1169        {
1170            let off = self.arena.byte_offset(id);
1171            let buf = self.arena.raw_buf_mut();
1172            debug_assert!(
1173                off + data.len() <= buf.len(),
1174                "set_param_bytes: '{name}' would overflow arena slot"
1175            );
1176            buf[off..off + data.len()].copy_from_slice(data);
1177        }
1178    }
1179}