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    /// How a node is actually *stored*, which is not always its declared dtype.
532    ///
533    /// The lane rule the fused regions already follow
534    /// (`thunk::types::LaneKind::resolve`): a slot's width is what the memory
535    /// plan assigned, and a 4-byte slot holding an `F16`/`BF16`-typed node
536    /// contains a widened `f32`. `plan_memory_native_in_order` widens half
537    /// *activations* to f32 (`ArenaWidthPolicy::NativeHalfWidened`) because the
538    /// thunks compute in f32; half *params* stay packed at 2 B.
539    ///
540    /// Reading the declared dtype instead — which these two used to do — writes
541    /// 2 B/elem into a 4 B/elem slot and reads it back as pairs of halves:
542    ///
543    /// ```text
544    /// [1,2,3,4] into a BF16 input, + 0  ->  [2.0038757, 4.007843, 0.0, 0.0]
545    /// ```
546    fn storage_dtype(&self, id: NodeId) -> DType {
547        let declared = self.node_dtypes.get(&id).copied().unwrap_or(DType::F32);
548        if !matches!(declared, DType::F16 | DType::BF16) {
549            return declared;
550        }
551        let n_elems = self.graph.node(id).shape.num_elements().unwrap_or(0);
552        if n_elems == 0 {
553            return declared;
554        }
555        match self.arena.byte_size(id) / n_elems {
556            2 => declared,   // packed at its true width
557            _ => DType::F32, // widened by the plan
558        }
559    }
560
561    fn write_input(&mut self, id: NodeId, data: &[f32]) {
562        let dtype = self.storage_dtype(id);
563        let off = self.arena.byte_offset(id);
564        let buf = self.arena.raw_buf_mut();
565        let elem_size = dtype.size_bytes();
566        let max_elems = (buf.len() - off) / elem_size;
567        unsafe {
568            write_typed_from_f32(buf.as_mut_ptr().add(off), dtype, data, max_elems);
569        }
570    }
571
572    /// Read a node's arena bytes back as Vec<f32>, casting from how it is
573    /// *stored* (see [`Self::storage_dtype`]), not from what it is declared.
574    fn read_output(&self, id: NodeId) -> Vec<f32> {
575        let dtype = self.storage_dtype(id);
576        let off = self.arena.byte_offset(id);
577        let buf = self.arena.raw_buf();
578        let n_elems = self.graph.node(id).shape.num_elements().unwrap_or(0);
579        unsafe { read_typed_to_f32(buf.as_ptr().add(off), dtype, n_elems) }
580    }
581}
582
583impl ExecutableGraph for CpuExecutable {
584    fn capabilities(&self) -> crate::ExecutableCapabilities {
585        crate::ExecutableCapabilities {
586            clone: true,
587            moe: true,
588            // `bind_handle` / `read_handle` are implemented below and return
589            // real data; the flag said otherwise.
590            persistent_handles: true,
591            typed_io: true,
592            active_extent: true,
593            ..crate::ExecutableCapabilities::NONE
594        }
595    }
596
597    fn clone_box(&self) -> Box<dyn ExecutableGraph> {
598        Box::new(self.clone())
599    }
600    fn set_param(&mut self, name: &str, data: &[f32]) {
601        // Params live solely in the arena (dedicated, never-aliased slots, see
602        // the memory planner) — no redundant CPU-side copy is kept, which would
603        // double the weight footprint for multi-GB models.
604        // Cast f32 → arena dtype when the param has been rewritten to F16/BF16.
605        if let Some(&id) = self.param_ids.get(name)
606            && self.arena.has_buffer(id)
607        {
608            let dtype = self.node_dtypes.get(&id).copied().unwrap_or(DType::F32);
609            let off = self.arena.byte_offset(id);
610            let buf = self.arena.raw_buf_mut();
611            let elem_size = dtype.size_bytes();
612            let max_elems = (buf.len() - off) / elem_size;
613            unsafe {
614                write_typed_from_f32(buf.as_mut_ptr().add(off), dtype, data, max_elems);
615            }
616        }
617    }
618
619    fn set_param_range(&mut self, name: &str, byte_offset: usize, data: &[u8]) -> bool {
620        // Paged-MoE residency: rewrite ONE expert slot of a large bank instead
621        // of re-uploading the bank. Without this the caller has to assemble the
622        // whole gathered bank in a Vec and hand it over, copying every active
623        // byte twice — at GLM-5.3-Flash scale ~2 GB of redundant memcpy per
624        // token, and CPU is the backend the cluster planner assigns paged
625        // stages to most often.
626        let Some(&id) = self.param_ids.get(name) else {
627            return false;
628        };
629        if !self.arena.has_buffer(id) {
630            return false;
631        }
632        // The bytes go in verbatim, and the signature carries no dtype to check
633        // them against. A param that AMP rewrote to F16/BF16 would be silently
634        // corrupted by F32 bytes, so refuse anything but F32 storage and let
635        // the caller take the whole-buffer path, which does convert.
636        if self.node_dtypes.get(&id).copied().unwrap_or(DType::F32) != DType::F32 {
637            return false;
638        }
639        // Bound to the PARAM, not to the arena: params sit in a shared buffer,
640        // so an over-long write does not fault — it lands in whichever tensor
641        // is next and corrupts it silently.
642        let size = self.arena.byte_size(id);
643        let Some(end) = byte_offset.checked_add(data.len()) else {
644            return false;
645        };
646        if end > size {
647            return false;
648        }
649        let off = self.arena.byte_offset(id);
650        self.arena.raw_buf_mut()[off + byte_offset..off + end].copy_from_slice(data);
651        true
652    }
653
654    fn run(&mut self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
655        self.restore_arena_baseline();
656        // 1. Apply persistent handles first — they act like default inputs.
657        //    Explicit `inputs` passed to run() override matching handle names.
658        let handle_names: Vec<String> = self.handles.keys().cloned().collect();
659        for name in &handle_names {
660            if let Some(&id) = self.input_ids.get(name)
661                && self.arena.has_buffer(id)
662            {
663                let data = self.handles.get(name).cloned().unwrap_or_default();
664                self.write_input(id, &data);
665            }
666        }
667        // 2. Explicit per-call inputs override handles.
668        for &(name, data) in inputs {
669            if let Some(&id) = self.input_ids.get(name)
670                && self.arena.has_buffer(id)
671            {
672                self.write_input(id, data);
673            }
674        }
675
676        // Active-extent fast-path (PLAN L1): if hinted AND every thunk
677        // in the schedule supports it, run scaled. Otherwise fall back
678        // to full-extent dispatch — preserves correctness when the
679        // schedule contains a thunk that hasn't yet been wired in.
680        let active_used = if let Some((actual, upper)) = self.active_extent {
681            thunk::execute_thunks_active(&self.schedule, self.arena.raw_buf_mut(), actual, upper)
682        } else {
683            false
684        };
685        if !active_used {
686            // Execute via pre-compiled thunks (zero per-node dispatch overhead)
687            thunk::execute_thunks(&self.schedule, self.arena.raw_buf_mut());
688        }
689
690        self.dump_nodes_if_requested();
691
692        // 3. Sync any handle whose name matches a graph OUTPUT —
693        //    KV-cache pattern: outputs flow back into the same-named
694        //    handle for the next iteration.
695        for (idx, &out_id) in self.graph.outputs.iter().enumerate() {
696            let name = format!("out{idx}");
697            if self.handles.contains_key(&name) {
698                let v = self.read_output(out_id);
699                self.handles.insert(name, v);
700            }
701        }
702
703        self.graph
704            .outputs
705            .iter()
706            .map(|&out_id| self.read_output(out_id))
707            .collect()
708    }
709
710    fn run_raw(&mut self, inputs: &[(&str, &[f32])]) -> Vec<(*const f32, usize)> {
711        self.restore_arena_baseline();
712        // Copy inputs by name (HashMap lookup), casting to arena dtype.
713        for &(name, data) in inputs {
714            if let Some(&id) = self.input_ids.get(name)
715                && self.arena.has_buffer(id)
716            {
717                self.write_input(id, data);
718            }
719        }
720        thunk::execute_thunks(&self.schedule, self.arena.raw_buf_mut());
721        // Note: pointers are raw arena bytes — for F16 outputs, callers
722        // must read 2 bytes/elem, not 4. run() is the safe path for
723        // mixed precision; run_raw() is only meaningful for F32.
724        self.graph
725            .outputs
726            .iter()
727            .map(|&out_id| {
728                let (ptr, len) = self.arena.raw_ptr(out_id);
729                (ptr as *const f32, len)
730            })
731            .collect()
732    }
733
734    /// Fastest path: inputs by index (matching input_slots order), zero-copy output.
735    /// No HashMap, no name matching, no Vec allocation. Casts f32 input
736    /// to F16/BF16 if the input slot's dtype was rewritten.
737    fn run_slots(&mut self, inputs: &[&[f32]]) -> &[(usize, usize)] {
738        self.restore_arena_baseline();
739        let buf = self.arena.raw_buf_mut();
740        for (i, &data) in inputs.iter().enumerate() {
741            if i < self.input_slots.len() {
742                let (_, off, max_len, dtype) = &self.input_slots[i];
743                unsafe {
744                    write_typed_from_f32(buf.as_mut_ptr().add(*off), *dtype, data, *max_len);
745                }
746            }
747        }
748        thunk::execute_thunks(&self.schedule, self.arena.raw_buf_mut());
749        &self.output_slots
750    }
751
752    fn arena_ptr(&self) -> *const u8 {
753        self.arena.raw_buf_mut_ptr()
754    }
755
756    fn bind_handle(&mut self, name: &str, data: &[f32]) -> bool {
757        // Persistent buffer: stored separately from arena, survives run().
758        // If the name matches a graph input, run() will use this data
759        // as the input. If the graph also writes back to this name (via
760        // an output binding pattern), read_handle returns the latest.
761        self.handles.insert(name.to_string(), data.to_vec());
762        true
763    }
764
765    fn read_handle(&self, name: &str) -> Option<Vec<f32>> {
766        self.handles.get(name).cloned()
767    }
768
769    fn set_active_extent(&mut self, extent: Option<(usize, usize)>) {
770        self.active_extent = extent;
771    }
772
773    fn release_scratch(&mut self) {
774        self.release_scratch_pages();
775    }
776
777    fn set_rng(&mut self, rng: rlx_ir::RngOptions) {
778        *self.schedule.rng.write().unwrap() = rng;
779    }
780
781    fn rng(&self) -> rlx_ir::RngOptions {
782        *self.schedule.rng.read().unwrap()
783    }
784
785    fn set_moe_resident_experts(&mut self, mask: &[bool]) {
786        self.moe_resident_layers = None;
787        self.schedule.moe_resident_layers = None;
788        self.moe_resident = Some(Arc::from(mask));
789        self.schedule.moe_resident = self.moe_resident.clone();
790    }
791
792    fn set_moe_resident_experts_per_layer(&mut self, masks: &[&[bool]]) {
793        self.moe_resident = None;
794        self.schedule.moe_resident = None;
795        let layers: Vec<Arc<[bool]>> = masks.iter().map(|m| Arc::from(*m)).collect();
796        let arc = Arc::new(layers);
797        self.moe_resident_layers = Some(arc.clone());
798        self.schedule.moe_resident_layers = Some(arc);
799    }
800
801    fn enable_moe_topk_capture(&mut self, num_experts: usize) -> bool {
802        let cap = rlx_cpu::moe_topk_capture::MoeTopkCapture::new(num_experts);
803        self.moe_topk_capture = Some(cap.clone());
804        self.schedule.moe_topk_capture = Some(cap);
805        true
806    }
807
808    fn take_moe_topk_capture(&mut self) -> Option<Vec<Vec<u32>>> {
809        let cap = self.moe_topk_capture.as_ref()?;
810        let layers = cap.take_layers();
811        if layers.is_empty() {
812            None
813        } else {
814            Some(layers)
815        }
816    }
817
818    fn take_moe_residency_stats(&mut self) -> Option<crate::MoeResidencyStats> {
819        rlx_cpu::moe_residency::take_last_forward_stats()
820    }
821
822    /// Typed param upload. F32 / F16 / BF16 go through the existing
823    /// widen-to-f32 path (the CPU arena is historically f32 with
824    /// optional half-precision rewrite). F64 (and any future
825    /// non-widenable dtype) lands directly in the arena as bytes —
826    /// the f32 path would lose precision.
827    fn set_param_typed(&mut self, name: &str, data: &[u8], dtype: rlx_ir::DType) {
828        if matches!(dtype, DType::F64 | DType::I64 | DType::I32 | DType::U32) {
829            self.set_param_bytes(name, data, dtype);
830            return;
831        }
832        // U8 / I8 raw byte tensors: opaque storage for the GGUF
833        // K-quant `Op::DequantMatMul` path (weights stay packed
834        // in the arena). One arena byte = one element.
835        if matches!(dtype, DType::U8 | DType::I8) {
836            self.set_param_bytes(name, data, dtype);
837            return;
838        }
839        if dtype == DType::F32 {
840            let s = rlx_ir::bytes::decode_le::<f32>(data);
841            self.set_param(name, s.as_ref());
842        } else {
843            let f32_buf = super::widen_bytes_to_f32(data, dtype);
844            self.set_param(name, &f32_buf);
845        }
846    }
847
848    /// Typed run with mixed-dtype inputs/outputs.
849    ///
850    /// For each input: if its declared graph dtype matches the
851    /// caller's bytes, we write directly into the arena (zero
852    /// precision loss — F64 stays F64). For F32 with a half-precision
853    /// arena rewrite, we widen as before. F16/BF16 callers go
854    /// through the existing widen path.
855    ///
856    /// Outputs are read straight from the arena in the graph node's
857    /// declared dtype — F64 outputs come back as 8 bytes/element,
858    /// F32 as 4, etc.
859    fn run_typed(
860        &mut self,
861        inputs: &[(&str, &[u8], rlx_ir::DType)],
862    ) -> Vec<(Vec<u8>, rlx_ir::DType)> {
863        // Decide: are *all* inputs F64? If so, use the direct-byte
864        // path for everything and skip the f32 widening machinery
865        // entirely. Mixed dtype graphs (F32 + F64) take the
866        // per-input dispatch route below.
867        let all_f64 = !inputs.is_empty() && inputs.iter().all(|(_, _, dt)| *dt == DType::F64);
868
869        if all_f64 {
870            for (name, data, _) in inputs {
871                if let Some(&id) = self.input_ids.get(*name) {
872                    if !self.arena.has_buffer(id) {
873                        continue;
874                    }
875                    let off = self.arena.byte_offset(id);
876                    let buf = self.arena.raw_buf_mut();
877                    let n = data.len();
878                    debug_assert!(
879                        off + n <= buf.len(),
880                        "run_typed: input '{name}' overflows arena slot"
881                    );
882                    buf[off..off + n].copy_from_slice(data);
883                }
884            }
885            thunk::execute_thunks(&self.schedule, self.arena.raw_buf_mut());
886        } else {
887            // Mixed-dtype path: dtypes that survive untouched
888            // through the f32-aliased arena (F64, I32, I64, U32)
889            // go in as bytes; F32 routes through widen-to-f32 + run.
890            let mut f32_owned: Vec<(String, Vec<f32>)> = Vec::new();
891            for (name, data, dt) in inputs {
892                let direct = matches!(
893                    *dt,
894                    DType::F64 | DType::I32 | DType::I64 | DType::U32 | DType::C64 | DType::C128
895                );
896                if direct {
897                    if let Some(&id) = self.input_ids.get(*name) {
898                        if !self.arena.has_buffer(id) {
899                            continue;
900                        }
901                        let off = self.arena.byte_offset(id);
902                        let buf = self.arena.raw_buf_mut();
903                        buf[off..off + data.len()].copy_from_slice(data);
904                    }
905                } else {
906                    let v = super::widen_bytes_to_f32(data, *dt);
907                    f32_owned.push((name.to_string(), v));
908                }
909            }
910            for (name, data) in &f32_owned {
911                if let Some(&id) = self.input_ids.get(name.as_str()) {
912                    if self.arena.has_buffer(id) {
913                        self.write_input(id, data);
914                    }
915                }
916            }
917            let active_used = if let Some((actual, upper)) = self.active_extent {
918                thunk::execute_thunks_active(
919                    &self.schedule,
920                    self.arena.raw_buf_mut(),
921                    actual,
922                    upper,
923                )
924            } else {
925                false
926            };
927            if !active_used {
928                thunk::execute_thunks(&self.schedule, self.arena.raw_buf_mut());
929            }
930        }
931
932        self.dump_nodes_if_requested();
933
934        // Read outputs in declared boundary dtypes.
935        self.graph
936            .outputs
937            .iter()
938            .enumerate()
939            .map(|(idx, &id)| {
940                let exec_dtype = self.graph.node(id).shape.dtype();
941                let declared = self.io_manifest.output_dtype(idx, exec_dtype);
942                // F16/BF16 are deliberately absent: under
943                // `ArenaWidthPolicy::NativeHalfWidened` a half *activation*
944                // occupies f32 width and holds f32 bits, because that is what
945                // the thunks compute in. Reading `n_elems * 2` raw bytes here
946                // would take half the buffer and call it the answer. They fall
947                // through to the f32 read + `narrow_f32_to_bytes` below, which
948                // emits the declared boundary dtype.
949                if matches!(
950                    exec_dtype,
951                    DType::F64 | DType::I32 | DType::I64 | DType::U32 | DType::C64 | DType::C128
952                ) {
953                    let n_elems = self.graph.node(id).shape.num_elements().unwrap_or(0);
954                    let n_bytes = n_elems * exec_dtype.size_bytes();
955                    let off = self.arena.byte_offset(id);
956                    let bytes = self.arena.raw_buf()[off..off + n_bytes].to_vec();
957                    return (bytes, declared);
958                }
959                let f32_vals = self.read_output(id);
960                if declared != exec_dtype || matches!(declared, DType::F16 | DType::BF16) {
961                    return (super::narrow_f32_to_bytes(&f32_vals, declared), declared);
962                }
963                let bytes = f32_vals.iter().flat_map(|v| v.to_le_bytes()).collect();
964                (bytes, declared)
965            })
966            .collect()
967    }
968}
969
970impl CpuExecutable {
971    /// Clear ephemeral (scratch) arena slots before each `run()`. Params are
972    /// written into their dedicated, never-aliased arena slots by `set_param`
973    /// and live for the whole execution, so they are NOT re-zeroed/rewritten
974    /// here — only the intermediate buffers (which carry stale data from the
975    /// previous pass) are zeroed. Compile-time constants are written once.
976    ///
977    /// This keeps the per-run cost O(scratch) instead of O(params): a previous
978    /// version cloned + rewrote the entire (multi-GB) weight region every run,
979    /// which made large models swap-thrash.
980    /// Sorted, merged persistent (param + constant) byte ranges in the arena.
981    /// These live for the whole execution; everything else is ephemeral scratch.
982    /// Used by `release_scratch_pages` (returns the complement's pages to the
983    /// OS), which is `cfg(unix)` — hence the same gate here.
984    /// `restore_arena_baseline` computes its own persistent set inline.
985    #[cfg(unix)]
986    fn persistent_keep_ranges(&self) -> Vec<(usize, usize)> {
987        let persistent: std::collections::HashSet<NodeId> = {
988            let mut s: std::collections::HashSet<NodeId> =
989                self.param_ids.values().copied().collect();
990            for node in self.graph.nodes() {
991                if matches!(node.op, Op::Constant { .. }) {
992                    s.insert(node.id);
993                }
994            }
995            s
996        };
997        let mut keep: Vec<(usize, usize)> = self
998            .graph
999            .nodes()
1000            .iter()
1001            .filter_map(|node| {
1002                let id = node.id;
1003                if !persistent.contains(&id) || !self.arena.has_buffer(id) {
1004                    return None;
1005                }
1006                let dtype = self.node_dtypes.get(&id).copied().unwrap_or(DType::F32);
1007                let nbytes = node.shape.num_elements().unwrap_or(0) * dtype.size_bytes();
1008                let off = self.arena.byte_offset(id);
1009                Some((off, off + nbytes))
1010            })
1011            .collect();
1012        keep.sort_unstable();
1013        keep
1014    }
1015
1016    /// Return the ephemeral (scratch) pages of the arena to the OS via
1017    /// `madvise(MADV_FREE)`, dropping resident RSS while this cached graph sits
1018    /// idle between pipeline stages. The arena's virtual allocation and its param
1019    /// bytes stay intact; only whole pages that fall ENTIRELY inside the scratch
1020    /// complement of the param ranges are advised, so weight bytes are never
1021    /// touched. The next run's `restore_arena_baseline` re-zeros scratch, so this
1022    /// is purely advisory and cannot affect correctness. Call it only when the
1023    /// graph won't run again immediately (e.g. a TTS pipeline finished with a
1024    /// subgraph) — never between the steps of a hot loop, or the pages just fault
1025    /// back in. No-op on non-unix.
1026    #[allow(unused_variables)]
1027    fn release_scratch_pages(&self) {
1028        #[cfg(unix)]
1029        {
1030            // 16 KiB covers Apple-silicon pages and is a multiple of 4 KiB, so
1031            // rounding to it yields valid page-aligned spans on 4 KiB hosts too.
1032            const PAGE: usize = 16 * 1024;
1033            let keep = self.persistent_keep_ranges();
1034            let base = self.arena.raw_buf_mut_ptr() as usize;
1035            let total = self.arena.size();
1036            // Walk the complement of `keep` = the scratch spans.
1037            let mut cursor = 0usize;
1038            let mut spans: Vec<(usize, usize)> = Vec::new();
1039            for &(s, e) in &keep {
1040                let s = s.min(total);
1041                if cursor < s {
1042                    spans.push((cursor, s));
1043                }
1044                cursor = cursor.max(e.min(total));
1045            }
1046            if cursor < total {
1047                spans.push((cursor, total));
1048            }
1049            for (s, e) in spans {
1050                let a = (base + s).div_ceil(PAGE) * PAGE;
1051                let b = (base + e) / PAGE * PAGE;
1052                if b > a {
1053                    // macOS MADV_FREE (5) is lazy — it flags pages freeable but does
1054                    // NOT drop them from the physical footprint until memory
1055                    // pressure. MADV_FREE_REUSABLE (7) returns them to the OS now and
1056                    // decrements phys_footprint (the next access refaults). Linux
1057                    // MADV_DONTNEED (4) drops anonymous pages immediately and
1058                    // zero-fills on refault. Override with RLX_MADV to trade footprint
1059                    // for refault latency (e.g. RLX_MADV=5 for lazy/pressure-only).
1060                    #[cfg(target_os = "macos")]
1061                    const ADVICE: i32 = 7;
1062                    #[cfg(not(target_os = "macos"))]
1063                    const ADVICE: i32 = 4;
1064                    let advice = rlx_ir::env::var("RLX_MADV")
1065                        .and_then(|s| s.parse().ok())
1066                        .unwrap_or(ADVICE);
1067                    unsafe { madvise(a as *mut core::ffi::c_void, b - a, advice) };
1068                }
1069            }
1070        }
1071    }
1072
1073    fn restore_arena_baseline(&mut self) {
1074        // Persistent slots (params + constants) — never zeroed.
1075        let persistent: std::collections::HashSet<NodeId> = {
1076            let mut s: std::collections::HashSet<NodeId> =
1077                self.param_ids.values().copied().collect();
1078            for node in self.graph.nodes() {
1079                if matches!(node.op, Op::Constant { .. }) {
1080                    s.insert(node.id);
1081                }
1082            }
1083            s
1084        };
1085
1086        // Write compile-time constants into the arena once (a fresh arena is
1087        // zero-initialized; params are already resident via set_param).
1088        if !self.baseline_written {
1089            let constants: Vec<(NodeId, DType, Vec<u8>)> = self
1090                .graph
1091                .nodes()
1092                .iter()
1093                .filter_map(|node| {
1094                    if let Op::Constant { data } = &node.op
1095                        && self.arena.has_buffer(node.id)
1096                        && !data.is_empty()
1097                    {
1098                        Some((node.id, node.shape.dtype(), data.clone()))
1099                    } else {
1100                        None
1101                    }
1102                })
1103                .collect();
1104            for (id, dtype, data) in constants {
1105                self.write_constant_to_arena(id, dtype, &data);
1106            }
1107            self.baseline_written = true;
1108        }
1109
1110        // Zero everything EXCEPT the persistent (param + constant) byte ranges.
1111        //
1112        // We zero the *complement* of the persistent ranges rather than each
1113        // scratch node's exact byte span. That covers inter-slot padding and
1114        // arena gaps too — a kernel that over-reads its input into adjacent
1115        // alignment padding (common in SIMD reductions) would otherwise pick up
1116        // stale bytes from a previous run, since per-node zeroing only clears
1117        // `num_elements` and leaves the padding dirty. The cost stays O(arena −
1118        // params): for a 7B the params dominate the arena and are skipped, so
1119        // the swept region is tiny.
1120        let mut keep: Vec<(usize, usize)> = self
1121            .graph
1122            .nodes()
1123            .iter()
1124            .filter_map(|node| {
1125                let id = node.id;
1126                if !persistent.contains(&id) || !self.arena.has_buffer(id) {
1127                    return None;
1128                }
1129                let dtype = self.node_dtypes.get(&id).copied().unwrap_or(DType::F32);
1130                let nbytes = node.shape.num_elements().unwrap_or(0) * dtype.size_bytes();
1131                let off = self.arena.byte_offset(id);
1132                Some((off, off + nbytes))
1133            })
1134            .collect();
1135        keep.sort_unstable();
1136
1137        let buf = self.arena.raw_buf_mut();
1138        let len = buf.len();
1139        let mut cursor = 0usize;
1140        for (start, end) in keep {
1141            let start = start.min(len);
1142            if cursor < start {
1143                buf[cursor..start].fill(0);
1144            }
1145            cursor = cursor.max(end.min(len));
1146        }
1147        if cursor < len {
1148            buf[cursor..len].fill(0);
1149        }
1150    }
1151
1152    fn write_constant_to_arena(&mut self, id: NodeId, dtype: DType, data: &[u8]) {
1153        match dtype {
1154            DType::F64
1155            | DType::F16
1156            | DType::BF16
1157            | DType::U8
1158            | DType::I8
1159            | DType::Bool
1160            | DType::I64
1161            | DType::I32
1162            | DType::U32 => {
1163                let off = self.arena.byte_offset(id);
1164                let buf = self.arena.raw_buf_mut();
1165                let n = buf.len().saturating_sub(off).min(data.len());
1166                buf[off..off + n].copy_from_slice(&data[..n]);
1167            }
1168            _ => {
1169                let buf = self.arena.slice_mut(id);
1170                let n_floats = data.len() / 4;
1171                let n = buf.len().min(n_floats);
1172                for i in 0..n {
1173                    let bytes = [
1174                        data[i * 4],
1175                        data[i * 4 + 1],
1176                        data[i * 4 + 2],
1177                        data[i * 4 + 3],
1178                    ];
1179                    buf[i] = f32::from_le_bytes(bytes);
1180                }
1181            }
1182        }
1183    }
1184
1185    /// Direct-byte param upload — copies caller's bytes into the
1186    /// arena slot for the named param without any dtype conversion.
1187    /// Used by `set_param_typed` for dtypes that f32-widening would
1188    /// corrupt (F64). Caller is responsible for matching the param's
1189    /// declared graph dtype.
1190    fn set_param_bytes(&mut self, name: &str, data: &[u8], _dtype: rlx_ir::DType) {
1191        // Byte-backed params also live solely in the arena (no CPU-side copy).
1192        self.write_param_bytes_to_arena(name, data);
1193    }
1194
1195    fn write_param_bytes_to_arena(&mut self, name: &str, data: &[u8]) {
1196        if let Some(&id) = self.param_ids.get(name)
1197            && self.arena.has_buffer(id)
1198        {
1199            let off = self.arena.byte_offset(id);
1200            let buf = self.arena.raw_buf_mut();
1201            debug_assert!(
1202                off + data.len() <= buf.len(),
1203                "set_param_bytes: '{name}' would overflow arena slot"
1204            );
1205            buf[off..off + data.len()].copy_from_slice(data);
1206        }
1207    }
1208}