Skip to main content

rlx_runtime/backend/
cpu_backend.rs

1use super::*;
2use rlx_cpu::{arena::Arena, thunk};
3use rlx_ir::{DType, NodeId, Op};
4use rlx_opt::memory::{self, MemoryPlan};
5// Arena typed read/write helpers live in `crate::arena` so every
6// backend (CPU, Metal, future CUDA/wgpu/WASM) shares one implementation.
7use rlx_driver::arena::{read_typed_to_f32, write_typed_from_f32};
8
9pub struct CpuBackend;
10
11impl Backend for CpuBackend {
12    fn supported_ops(&self) -> &'static [rlx_ir::OpKind] {
13        rlx_cpu::SUPPORTED_OPS
14    }
15
16    fn compile(&self, graph: Graph, options: &CompileOptions) -> Box<dyn ExecutableGraph> {
17        use rlx_opt::pass::Pass as _;
18        static ONNX_KERNELS: std::sync::Once = std::sync::Once::new();
19        ONNX_KERNELS.call_once(rlx_cpu::onnx_ref::register_onnx_reference_kernels);
20        // Lower Op::If / Op::While to primitives BEFORE legalize
21        // so the supported-op check doesn't reject them — the CPU
22        // backend has no native sub-graph executor; this rewrite
23        // makes If/While invisible to the rest of the pipeline.
24        // No-op when neither op is in the graph.
25        let graph = rlx_opt::LowerControlFlow.run(graph);
26        // Lower f32 SPD-manifold ops (ReEig/LogEig/BiMap/SpdBatchNorm) to the
27        // graph-primitive Jacobi eigensolver BEFORE legalize — like If/While,
28        // the CPU backend has no f32 SPD kernel (the native ones are f64
29        // LAPACK). No-op unless an f32 SPD op is present; f64 SPD nodes are
30        // left untouched for the native LAPACK path.
31        let graph = rlx_opt::LowerSpectral.run(graph);
32        // PLAN L4: legalize against the backend's claimed op set
33        // BEFORE running fusion (so the diagnostic points at the
34        // user's IR, not at a fused-away node).
35        if let Err(errors) = rlx_opt::legalize_for_backend(&graph, rlx_cpu::SUPPORTED_OPS) {
36            panic!("{}", rlx_opt::format_legalize_error("cpu", &errors));
37        }
38        let policy = options.policy.clone();
39        let _precision = options.precision;
40        let cfg = rlx_cpu::config::RuntimeConfig::global();
41
42        let graph = crate::precompile::precompile_cleanup(graph, options);
43
44        // Run fusion pipeline (HIR/MIR/LIR ideology — fusion is first-class).
45        let mut compile_opts = options.clone();
46        compile_opts.arena_alignment = cfg.arena_alignment;
47        let compile_result = crate::stages::compile_graph_stages_for_backend(
48            rlx_driver::Device::Cpu,
49            graph,
50            &compile_opts,
51            rlx_cpu::SUPPORTED_OPS,
52        );
53        crate::stages::maybe_log_fusion(&compile_result.fusion);
54        let fused = compile_result.lir.into_graph();
55
56        // Apply precision policy AFTER fusion — Cast nodes don't disrupt
57        // the now-flattened fused ops.
58        let fused = match policy {
59            Some(p) => rlx_opt::AutoMixedPrecision::new(p).run(fused),
60            None => fused,
61        };
62
63        let io_manifest = cpu_low_precision::IoDtypeManifest::from_graph(&fused);
64        let exec_graph = if cpu_low_precision::needs_f32_exec(&fused) {
65            cpu_low_precision::promote_to_f32(fused)
66        } else {
67            fused
68        };
69
70        // Re-plan after precision rewrites (may change dtypes / sizes).
71        let plan = memory::plan_memory_aligned(&exec_graph, cfg.arena_alignment);
72        if cfg.verbose >= 1 {
73            eprintln!(
74                "[rlx] arena: {} bytes, {} buffers, alignment: {}",
75                plan.arena_size,
76                plan.assignments.len(),
77                cfg.arena_alignment
78            );
79        }
80        Box::new(build_cpu_executable(
81            exec_graph,
82            plan,
83            io_manifest,
84            options.rng,
85        ))
86    }
87
88    fn compile_lir(&self, lir: LirModule, options: &CompileOptions) -> Box<dyn ExecutableGraph> {
89        // `Instant` is unimplemented on wasm32 — never touch it there.
90        #[cfg(not(target_arch = "wasm32"))]
91        let prof = std::env::var_os("RLX_PROFILE_COMPILE").is_some();
92        #[cfg(not(target_arch = "wasm32"))]
93        let tt = if prof {
94            Some(std::time::Instant::now())
95        } else {
96            None
97        };
98        let alignment = lir.buffers.alignment.max(options.arena_alignment);
99        let mut graph = lir.into_graph();
100        {
101            use rlx_opt::pass::Pass as _;
102            graph = rlx_opt::LegalizeBroadcast.run(graph);
103        }
104        if let Some(p) = options.policy.clone() {
105            use rlx_opt::pass::Pass;
106            graph = rlx_opt::AutoMixedPrecision::new(p).run(graph);
107        }
108        let io_manifest = cpu_low_precision::IoDtypeManifest::from_graph(&graph);
109        let promote = cpu_low_precision::needs_f32_exec(&graph);
110        let exec_graph = if promote {
111            cpu_low_precision::promote_to_f32(graph)
112        } else {
113            graph
114        };
115        #[cfg(not(target_arch = "wasm32"))]
116        let t_prep = tt.map(|t| t.elapsed());
117        #[cfg(not(target_arch = "wasm32"))]
118        let t1 = if prof {
119            Some(std::time::Instant::now())
120        } else {
121            None
122        };
123        // LegalizeBroadcast may insert Expand nodes — must replan; the
124        // embedded LIR buffer map is from before legalization.
125        let plan = memory::plan_memory_aligned(&exec_graph, alignment);
126        #[cfg(not(target_arch = "wasm32"))]
127        let t_plan = t1.map(|t| t.elapsed());
128        #[cfg(not(target_arch = "wasm32"))]
129        if prof {
130            eprintln!(
131                "[compile_lir] {} nodes: into_graph+passes={:?} plan_memory={:?}",
132                exec_graph.nodes().len(),
133                t_prep.unwrap(),
134                t_plan.unwrap(),
135            );
136        }
137        let cfg = rlx_cpu::config::RuntimeConfig::global();
138        if cfg.verbose >= 1 {
139            eprintln!(
140                "[rlx] compile_lir: arena {} bytes ({} buffers, alignment {})",
141                plan.arena_size,
142                plan.assignments.len(),
143                alignment,
144            );
145        }
146        #[cfg(not(target_arch = "wasm32"))]
147        let t2 = if prof {
148            Some(std::time::Instant::now())
149        } else {
150            None
151        };
152        let exe = build_cpu_executable(exec_graph, plan, io_manifest, options.rng);
153        #[cfg(not(target_arch = "wasm32"))]
154        if let Some(t2) = t2 {
155            eprintln!("[compile_lir] build_thunks={:?}", t2.elapsed());
156        }
157        Box::new(exe)
158    }
159}
160
161fn build_cpu_executable(
162    graph: Graph,
163    plan: MemoryPlan,
164    io_manifest: cpu_low_precision::IoDtypeManifest,
165    rng: rlx_ir::RngOptions,
166) -> CpuExecutable {
167    let mut arena = Arena::from_plan(plan);
168    let mut input_ids = HashMap::new();
169    let mut param_ids = HashMap::new();
170    let mut node_dtypes: HashMap<NodeId, DType> = HashMap::new();
171    for node in graph.nodes() {
172        node_dtypes.insert(node.id, node.shape.dtype());
173        match &node.op {
174            Op::Input { name } => {
175                input_ids.insert(name.clone(), node.id);
176            }
177            Op::Param { name } => {
178                param_ids.insert(name.clone(), node.id);
179            }
180            _ => {}
181        }
182    }
183
184    let schedule = thunk::compile_thunks_with_rng(&graph, &arena, rng);
185
186    let mut input_slots = Vec::new();
187    for node in graph.nodes() {
188        if let Op::Input { name } = &node.op {
189            let off = arena.byte_offset(node.id);
190            let len = node.shape.num_elements().unwrap_or(0);
191            input_slots.push((name.clone(), off, len, node.shape.dtype()));
192        }
193    }
194
195    let output_slots: Vec<(usize, usize)> = graph
196        .outputs
197        .iter()
198        .map(|&id| {
199            let off = arena.byte_offset(id);
200            let len = graph.node(id).shape.num_elements().unwrap_or(0);
201            (off, len)
202        })
203        .collect();
204
205    for node in graph.nodes() {
206        if let Op::Constant { data } = &node.op
207            && arena.has_buffer(node.id)
208            && !data.is_empty()
209        {
210            match node.shape.dtype() {
211                // True-width dtypes (their arena slot is sized to the real
212                // element width, not f32): copy the raw bytes. I64/I32/U32
213                // constants were previously caught by the f32-reinterpret
214                // branch below, which read them in 4-byte chunks as f32 —
215                // corrupting e.g. the VITS sequence-mask `arange` constant
216                // (i64 [0..T-1]) so the downstream i64 `Compare` read garbage.
217                // Bool/U8/I8 must also raw-copy: a 1-byte `ConstantOfShape(true)`
218                // has `data.len()/4 == 0` in the f32 branch, so the slot stayed
219                // zero and Soprano's causal `And(ones, mask)` collapsed → Softmax NaN.
220                DType::F64
221                | DType::F16
222                | DType::BF16
223                | DType::I64
224                | DType::I32
225                | DType::U32
226                | DType::Bool
227                | DType::U8
228                | DType::I8 => {
229                    let off = arena.byte_offset(node.id);
230                    let buf = arena.raw_buf_mut();
231                    let n = buf.len().saturating_sub(off).min(data.len());
232                    buf[off..off + n].copy_from_slice(&data[..n]);
233                }
234                _ => {
235                    let buf = arena.slice_mut(node.id);
236                    let n_floats = data.len() / 4;
237                    let n = buf.len().min(n_floats);
238                    for i in 0..n {
239                        let bytes = [
240                            data[i * 4],
241                            data[i * 4 + 1],
242                            data[i * 4 + 2],
243                            data[i * 4 + 3],
244                        ];
245                        buf[i] = f32::from_le_bytes(bytes);
246                    }
247                }
248            }
249        }
250    }
251
252    CpuExecutable {
253        graph,
254        arena,
255        input_ids,
256        param_ids,
257        node_dtypes,
258        io_manifest,
259        schedule,
260        input_slots,
261        output_slots,
262        handles: HashMap::new(),
263        active_extent: None,
264        moe_resident: None,
265        moe_resident_layers: None,
266        moe_topk_capture: None,
267        baseline_written: false,
268    }
269}
270
271#[derive(Clone)]
272struct CpuExecutable {
273    graph: Graph,
274    arena: Arena,
275    input_ids: HashMap<String, NodeId>,
276    param_ids: HashMap<String, NodeId>,
277    /// Per-node arena dtype. Lets set_param/run cast f32 ↔ F16/BF16
278    /// when AutoMixedPrecision has rewritten the graph.
279    node_dtypes: HashMap<NodeId, DType>,
280    /// User-facing boundary dtypes (before f32 promotion for CPU exec).
281    io_manifest: cpu_low_precision::IoDtypeManifest,
282    schedule: thunk::ThunkSchedule,
283    // Pre-resolved: ordered list of (input_name, arena_byte_offset, max_elems, dtype)
284    input_slots: Vec<(String, usize, usize, DType)>,
285    /// Output (byte_offset, num_elements). dtype is in node_dtypes.
286    output_slots: Vec<(usize, usize)>,
287    /// Persistent buffer handles (KV-cache, optimizer state, etc.).
288    /// Lives outside the arena and survives across run() calls.
289    /// On run(): if a handle's name matches a graph input, the
290    /// handle's data is used as the input.
291    handles: HashMap<String, Vec<f32>>,
292    /// Active-extent hint (`Some((actual, upper))`) for L1 bucketed
293    /// dispatch. When set AND every thunk in the schedule is in
294    /// `Thunk::safe_for_active_extent`, the executor processes only
295    /// `actual / upper` of each kernel's work. Otherwise (or when
296    /// `None`) runs at the full compiled extent. See PLAN L1.
297    active_extent: Option<(usize, usize)>,
298    moe_resident: Option<std::sync::Arc<[bool]>>,
299    moe_resident_layers: Option<std::sync::Arc<Vec<std::sync::Arc<[bool]>>>>,
300    moe_topk_capture: Option<std::sync::Arc<rlx_cpu::moe_topk_capture::MoeTopkCapture>>,
301    /// Whether params + constants are already resident in the arena. While
302    /// `true`, `restore_arena_baseline` zeros only the scratch buffers instead
303    /// of re-zeroing + rewriting every param each run (which is O(params) and
304    /// allocates a full params clone — catastrophic for multi-GB models).
305    /// `set_param`/`set_param_typed` reset it to `false`.
306    baseline_written: bool,
307}
308
309unsafe impl Send for CpuExecutable {}
310
311impl CpuExecutable {
312    /// Per-node dump (mirror of rlx-wgpu's `RLX_WGPU_DUMP_NODES`) for
313    /// cross-backend divergence bisection: each F32 node's max|x| + nonzero
314    /// count in topo order. Diff against the wgpu dump to find the first
315    /// diverging node. `RLX_CPU_DUMP_FLAT=<i>` also prints that flat element.
316    fn dump_nodes_if_requested(&self) {
317        if !rlx_ir::env::flag("RLX_CPU_DUMP_NODES") {
318            return;
319        }
320        let limit = rlx_ir::env::parse_or("RLX_CPU_DUMP_NODES_LIMIT", 2000usize);
321        let flat_probe = rlx_ir::env::parse_or::<usize>("RLX_CPU_DUMP_FLAT", usize::MAX);
322        eprintln!("[rlx-cpu-dump] per-node max |x| (topo order, limit={limit})");
323        let buf = self.arena.raw_buf();
324        let mut shown = 0usize;
325        for (i, node) in self.graph.nodes().iter().enumerate() {
326            if !self.arena.has_buffer(node.id) {
327                continue;
328            }
329            if matches!(
330                node.op,
331                rlx_ir::Op::Input { .. }
332                    | rlx_ir::Op::Param { .. }
333                    | rlx_ir::Op::Constant { .. }
334                    | rlx_ir::Op::Reshape { .. }
335                    | rlx_ir::Op::Cast { .. }
336            ) {
337                continue;
338            }
339            if self
340                .node_dtypes
341                .get(&node.id)
342                .copied()
343                .unwrap_or(DType::F32)
344                != DType::F32
345            {
346                continue;
347            }
348            let off = self.arena.byte_offset(node.id);
349            let n = node.shape.num_elements().unwrap_or(0);
350            let data: &[f32] =
351                unsafe { std::slice::from_raw_parts(buf.as_ptr().add(off) as *const f32, n) };
352            let max = data.iter().fold(0f32, |m, &v| m.max(v.abs()));
353            let nz = data.iter().filter(|&&v| v != 0.0).count();
354            let flat_s = if flat_probe < data.len() {
355                format!(" flat[{flat_probe}]={:.6}", data[flat_probe])
356            } else {
357                String::new()
358            };
359            eprintln!(
360                "  [{i:>3}] {:?} shape={:?} max={max:.6} nonzero={nz}/{}{flat_s}",
361                node.op,
362                node.shape.dims(),
363                data.len()
364            );
365            shown += 1;
366            if shown >= limit {
367                break;
368            }
369        }
370    }
371
372    /// Write a f32 input slice into the arena, casting to the node's dtype.
373    fn write_input(&mut self, id: NodeId, data: &[f32]) {
374        let dtype = self.node_dtypes.get(&id).copied().unwrap_or(DType::F32);
375        let off = self.arena.byte_offset(id);
376        let buf = self.arena.raw_buf_mut();
377        let elem_size = dtype.size_bytes();
378        let max_elems = (buf.len() - off) / elem_size;
379        unsafe {
380            write_typed_from_f32(buf.as_mut_ptr().add(off), dtype, data, max_elems);
381        }
382    }
383
384    /// Read a node's arena bytes back as Vec<f32>, casting from its dtype.
385    fn read_output(&self, id: NodeId) -> Vec<f32> {
386        let dtype = self.node_dtypes.get(&id).copied().unwrap_or(DType::F32);
387        let off = self.arena.byte_offset(id);
388        let buf = self.arena.raw_buf();
389        let n_elems = self.graph.node(id).shape.num_elements().unwrap_or(0);
390        unsafe { read_typed_to_f32(buf.as_ptr().add(off), dtype, n_elems) }
391    }
392}
393
394impl ExecutableGraph for CpuExecutable {
395    fn capabilities(&self) -> crate::ExecutableCapabilities {
396        crate::ExecutableCapabilities {
397            clone: true,
398            moe: true,
399            typed_io: true,
400            active_extent: true,
401            ..crate::ExecutableCapabilities::NONE
402        }
403    }
404
405    fn clone_box(&self) -> Box<dyn ExecutableGraph> {
406        Box::new(self.clone())
407    }
408    fn set_param(&mut self, name: &str, data: &[f32]) {
409        // Params live solely in the arena (dedicated, never-aliased slots, see
410        // the memory planner) — no redundant CPU-side copy is kept, which would
411        // double the weight footprint for multi-GB models.
412        // Cast f32 → arena dtype when the param has been rewritten to F16/BF16.
413        if let Some(&id) = self.param_ids.get(name)
414            && self.arena.has_buffer(id)
415        {
416            let dtype = self.node_dtypes.get(&id).copied().unwrap_or(DType::F32);
417            let off = self.arena.byte_offset(id);
418            let buf = self.arena.raw_buf_mut();
419            let elem_size = dtype.size_bytes();
420            let max_elems = (buf.len() - off) / elem_size;
421            unsafe {
422                write_typed_from_f32(buf.as_mut_ptr().add(off), dtype, data, max_elems);
423            }
424        }
425    }
426
427    fn run(&mut self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
428        self.restore_arena_baseline();
429        // 1. Apply persistent handles first — they act like default inputs.
430        //    Explicit `inputs` passed to run() override matching handle names.
431        let handle_names: Vec<String> = self.handles.keys().cloned().collect();
432        for name in &handle_names {
433            if let Some(&id) = self.input_ids.get(name)
434                && self.arena.has_buffer(id)
435            {
436                let data = self.handles.get(name).cloned().unwrap_or_default();
437                self.write_input(id, &data);
438            }
439        }
440        // 2. Explicit per-call inputs override handles.
441        for &(name, data) in inputs {
442            if let Some(&id) = self.input_ids.get(name)
443                && self.arena.has_buffer(id)
444            {
445                self.write_input(id, data);
446            }
447        }
448
449        // Active-extent fast-path (PLAN L1): if hinted AND every thunk
450        // in the schedule supports it, run scaled. Otherwise fall back
451        // to full-extent dispatch — preserves correctness when the
452        // schedule contains a thunk that hasn't yet been wired in.
453        let active_used = if let Some((actual, upper)) = self.active_extent {
454            thunk::execute_thunks_active(&self.schedule, self.arena.raw_buf_mut(), actual, upper)
455        } else {
456            false
457        };
458        if !active_used {
459            // Execute via pre-compiled thunks (zero per-node dispatch overhead)
460            thunk::execute_thunks(&self.schedule, self.arena.raw_buf_mut());
461        }
462
463        self.dump_nodes_if_requested();
464
465        // 3. Sync any handle whose name matches a graph OUTPUT —
466        //    KV-cache pattern: outputs flow back into the same-named
467        //    handle for the next iteration.
468        for (idx, &out_id) in self.graph.outputs.iter().enumerate() {
469            let name = format!("out{idx}");
470            if self.handles.contains_key(&name) {
471                let v = self.read_output(out_id);
472                self.handles.insert(name, v);
473            }
474        }
475
476        self.graph
477            .outputs
478            .iter()
479            .map(|&out_id| self.read_output(out_id))
480            .collect()
481    }
482
483    fn run_raw(&mut self, inputs: &[(&str, &[f32])]) -> Vec<(*const f32, usize)> {
484        self.restore_arena_baseline();
485        // Copy inputs by name (HashMap lookup), casting to arena dtype.
486        for &(name, data) in inputs {
487            if let Some(&id) = self.input_ids.get(name)
488                && self.arena.has_buffer(id)
489            {
490                self.write_input(id, data);
491            }
492        }
493        thunk::execute_thunks(&self.schedule, self.arena.raw_buf_mut());
494        // Note: pointers are raw arena bytes — for F16 outputs, callers
495        // must read 2 bytes/elem, not 4. run() is the safe path for
496        // mixed precision; run_raw() is only meaningful for F32.
497        self.graph
498            .outputs
499            .iter()
500            .map(|&out_id| {
501                let (ptr, len) = self.arena.raw_ptr(out_id);
502                (ptr as *const f32, len)
503            })
504            .collect()
505    }
506
507    /// Fastest path: inputs by index (matching input_slots order), zero-copy output.
508    /// No HashMap, no name matching, no Vec allocation. Casts f32 input
509    /// to F16/BF16 if the input slot's dtype was rewritten.
510    fn run_slots(&mut self, inputs: &[&[f32]]) -> &[(usize, usize)] {
511        self.restore_arena_baseline();
512        let buf = self.arena.raw_buf_mut();
513        for (i, &data) in inputs.iter().enumerate() {
514            if i < self.input_slots.len() {
515                let (_, off, max_len, dtype) = &self.input_slots[i];
516                unsafe {
517                    write_typed_from_f32(buf.as_mut_ptr().add(*off), *dtype, data, *max_len);
518                }
519            }
520        }
521        thunk::execute_thunks(&self.schedule, self.arena.raw_buf_mut());
522        &self.output_slots
523    }
524
525    fn arena_ptr(&self) -> *const u8 {
526        self.arena.raw_buf_mut_ptr()
527    }
528
529    fn bind_handle(&mut self, name: &str, data: &[f32]) -> bool {
530        // Persistent buffer: stored separately from arena, survives run().
531        // If the name matches a graph input, run() will use this data
532        // as the input. If the graph also writes back to this name (via
533        // an output binding pattern), read_handle returns the latest.
534        self.handles.insert(name.to_string(), data.to_vec());
535        true
536    }
537
538    fn read_handle(&self, name: &str) -> Option<Vec<f32>> {
539        self.handles.get(name).cloned()
540    }
541
542    fn set_active_extent(&mut self, extent: Option<(usize, usize)>) {
543        self.active_extent = extent;
544    }
545
546    fn set_rng(&mut self, rng: rlx_ir::RngOptions) {
547        *self.schedule.rng.write().unwrap() = rng;
548    }
549
550    fn rng(&self) -> rlx_ir::RngOptions {
551        *self.schedule.rng.read().unwrap()
552    }
553
554    fn set_moe_resident_experts(&mut self, mask: &[bool]) {
555        self.moe_resident_layers = None;
556        self.schedule.moe_resident_layers = None;
557        self.moe_resident = Some(Arc::from(mask));
558        self.schedule.moe_resident = self.moe_resident.clone();
559    }
560
561    fn set_moe_resident_experts_per_layer(&mut self, masks: &[&[bool]]) {
562        self.moe_resident = None;
563        self.schedule.moe_resident = None;
564        let layers: Vec<Arc<[bool]>> = masks.iter().map(|m| Arc::from(*m)).collect();
565        let arc = Arc::new(layers);
566        self.moe_resident_layers = Some(arc.clone());
567        self.schedule.moe_resident_layers = Some(arc);
568    }
569
570    fn enable_moe_topk_capture(&mut self, num_experts: usize) -> bool {
571        let cap = rlx_cpu::moe_topk_capture::MoeTopkCapture::new(num_experts);
572        self.moe_topk_capture = Some(cap.clone());
573        self.schedule.moe_topk_capture = Some(cap);
574        true
575    }
576
577    fn take_moe_topk_capture(&mut self) -> Option<Vec<Vec<u32>>> {
578        let cap = self.moe_topk_capture.as_ref()?;
579        let layers = cap.take_layers();
580        if layers.is_empty() {
581            None
582        } else {
583            Some(layers)
584        }
585    }
586
587    fn take_moe_residency_stats(&mut self) -> Option<crate::MoeResidencyStats> {
588        rlx_cpu::moe_residency::take_last_forward_stats()
589    }
590
591    /// Typed param upload. F32 / F16 / BF16 go through the existing
592    /// widen-to-f32 path (the CPU arena is historically f32 with
593    /// optional half-precision rewrite). F64 (and any future
594    /// non-widenable dtype) lands directly in the arena as bytes —
595    /// the f32 path would lose precision.
596    fn set_param_typed(&mut self, name: &str, data: &[u8], dtype: rlx_ir::DType) {
597        if matches!(dtype, DType::F64 | DType::I64 | DType::I32 | DType::U32) {
598            self.set_param_bytes(name, data, dtype);
599            return;
600        }
601        // U8 / I8 raw byte tensors: opaque storage for the GGUF
602        // K-quant `Op::DequantMatMul` path (weights stay packed
603        // in the arena). One arena byte = one element.
604        if matches!(dtype, DType::U8 | DType::I8) {
605            self.set_param_bytes(name, data, dtype);
606            return;
607        }
608        if dtype == DType::F32 {
609            let n = data.len() / 4;
610            let s = unsafe { std::slice::from_raw_parts(data.as_ptr() as *const f32, n) };
611            self.set_param(name, s);
612        } else {
613            let f32_buf = super::widen_bytes_to_f32(data, dtype);
614            self.set_param(name, &f32_buf);
615        }
616    }
617
618    /// Typed run with mixed-dtype inputs/outputs.
619    ///
620    /// For each input: if its declared graph dtype matches the
621    /// caller's bytes, we write directly into the arena (zero
622    /// precision loss — F64 stays F64). For F32 with a half-precision
623    /// arena rewrite, we widen as before. F16/BF16 callers go
624    /// through the existing widen path.
625    ///
626    /// Outputs are read straight from the arena in the graph node's
627    /// declared dtype — F64 outputs come back as 8 bytes/element,
628    /// F32 as 4, etc.
629    fn run_typed(
630        &mut self,
631        inputs: &[(&str, &[u8], rlx_ir::DType)],
632    ) -> Vec<(Vec<u8>, rlx_ir::DType)> {
633        // Decide: are *all* inputs F64? If so, use the direct-byte
634        // path for everything and skip the f32 widening machinery
635        // entirely. Mixed dtype graphs (F32 + F64) take the
636        // per-input dispatch route below.
637        let all_f64 = !inputs.is_empty() && inputs.iter().all(|(_, _, dt)| *dt == DType::F64);
638
639        if all_f64 {
640            for (name, data, _) in inputs {
641                if let Some(&id) = self.input_ids.get(*name) {
642                    if !self.arena.has_buffer(id) {
643                        continue;
644                    }
645                    let off = self.arena.byte_offset(id);
646                    let buf = self.arena.raw_buf_mut();
647                    let n = data.len();
648                    debug_assert!(
649                        off + n <= buf.len(),
650                        "run_typed: input '{name}' overflows arena slot"
651                    );
652                    buf[off..off + n].copy_from_slice(data);
653                }
654            }
655            thunk::execute_thunks(&self.schedule, self.arena.raw_buf_mut());
656        } else {
657            // Mixed-dtype path: dtypes that survive untouched
658            // through the f32-aliased arena (F64, I32, I64, U32)
659            // go in as bytes; F32 and the half-precision family
660            // route through widen-to-f32 + run.
661            let mut f32_owned: Vec<(String, Vec<f32>)> = Vec::new();
662            for (name, data, dt) in inputs {
663                let direct = matches!(
664                    *dt,
665                    DType::F64 | DType::I32 | DType::I64 | DType::U32 | DType::C64
666                );
667                if direct {
668                    if let Some(&id) = self.input_ids.get(*name) {
669                        if !self.arena.has_buffer(id) {
670                            continue;
671                        }
672                        let off = self.arena.byte_offset(id);
673                        let buf = self.arena.raw_buf_mut();
674                        buf[off..off + data.len()].copy_from_slice(data);
675                    }
676                } else {
677                    let v = super::widen_bytes_to_f32(data, *dt);
678                    f32_owned.push((name.to_string(), v));
679                }
680            }
681            for (name, data) in &f32_owned {
682                if let Some(&id) = self.input_ids.get(name.as_str()) {
683                    if self.arena.has_buffer(id) {
684                        self.write_input(id, data);
685                    }
686                }
687            }
688            let active_used = if let Some((actual, upper)) = self.active_extent {
689                thunk::execute_thunks_active(
690                    &self.schedule,
691                    self.arena.raw_buf_mut(),
692                    actual,
693                    upper,
694                )
695            } else {
696                false
697            };
698            if !active_used {
699                thunk::execute_thunks(&self.schedule, self.arena.raw_buf_mut());
700            }
701        }
702
703        self.dump_nodes_if_requested();
704
705        // Read outputs in declared boundary dtypes.
706        self.graph
707            .outputs
708            .iter()
709            .enumerate()
710            .map(|(idx, &id)| {
711                let exec_dtype = self.graph.node(id).shape.dtype();
712                let declared = self.io_manifest.output_dtype(idx, exec_dtype);
713                if matches!(
714                    exec_dtype,
715                    DType::F64
716                        | DType::F16
717                        | DType::BF16
718                        | DType::I32
719                        | DType::I64
720                        | DType::U32
721                        | DType::C64
722                ) {
723                    let n_elems = self.graph.node(id).shape.num_elements().unwrap_or(0);
724                    let n_bytes = n_elems * exec_dtype.size_bytes();
725                    let off = self.arena.byte_offset(id);
726                    let bytes = self.arena.raw_buf()[off..off + n_bytes].to_vec();
727                    return (bytes, declared);
728                }
729                let f32_vals = self.read_output(id);
730                if declared != exec_dtype {
731                    return (super::narrow_f32_to_bytes(&f32_vals, declared), declared);
732                }
733                let bytes = f32_vals.iter().flat_map(|v| v.to_le_bytes()).collect();
734                (bytes, declared)
735            })
736            .collect()
737    }
738}
739
740impl CpuExecutable {
741    /// Clear ephemeral (scratch) arena slots before each `run()`. Params are
742    /// written into their dedicated, never-aliased arena slots by `set_param`
743    /// and live for the whole execution, so they are NOT re-zeroed/rewritten
744    /// here — only the intermediate buffers (which carry stale data from the
745    /// previous pass) are zeroed. Compile-time constants are written once.
746    ///
747    /// This keeps the per-run cost O(scratch) instead of O(params): a previous
748    /// version cloned + rewrote the entire (multi-GB) weight region every run,
749    /// which made large models swap-thrash.
750    fn restore_arena_baseline(&mut self) {
751        // Persistent slots (params + constants) — never zeroed.
752        let persistent: std::collections::HashSet<NodeId> = {
753            let mut s: std::collections::HashSet<NodeId> =
754                self.param_ids.values().copied().collect();
755            for node in self.graph.nodes() {
756                if matches!(node.op, Op::Constant { .. }) {
757                    s.insert(node.id);
758                }
759            }
760            s
761        };
762
763        // Write compile-time constants into the arena once (a fresh arena is
764        // zero-initialized; params are already resident via set_param).
765        if !self.baseline_written {
766            let constants: Vec<(NodeId, DType, Vec<u8>)> = self
767                .graph
768                .nodes()
769                .iter()
770                .filter_map(|node| {
771                    if let Op::Constant { data } = &node.op
772                        && self.arena.has_buffer(node.id)
773                        && !data.is_empty()
774                    {
775                        Some((node.id, node.shape.dtype(), data.clone()))
776                    } else {
777                        None
778                    }
779                })
780                .collect();
781            for (id, dtype, data) in constants {
782                self.write_constant_to_arena(id, dtype, &data);
783            }
784            self.baseline_written = true;
785        }
786
787        // Zero everything EXCEPT the persistent (param + constant) byte ranges.
788        //
789        // We zero the *complement* of the persistent ranges rather than each
790        // scratch node's exact byte span. That covers inter-slot padding and
791        // arena gaps too — a kernel that over-reads its input into adjacent
792        // alignment padding (common in SIMD reductions) would otherwise pick up
793        // stale bytes from a previous run, since per-node zeroing only clears
794        // `num_elements` and leaves the padding dirty. The cost stays O(arena −
795        // params): for a 7B the params dominate the arena and are skipped, so
796        // the swept region is tiny.
797        let mut keep: Vec<(usize, usize)> = self
798            .graph
799            .nodes()
800            .iter()
801            .filter_map(|node| {
802                let id = node.id;
803                if !persistent.contains(&id) || !self.arena.has_buffer(id) {
804                    return None;
805                }
806                let dtype = self.node_dtypes.get(&id).copied().unwrap_or(DType::F32);
807                let nbytes = node.shape.num_elements().unwrap_or(0) * dtype.size_bytes();
808                let off = self.arena.byte_offset(id);
809                Some((off, off + nbytes))
810            })
811            .collect();
812        keep.sort_unstable();
813
814        let buf = self.arena.raw_buf_mut();
815        let len = buf.len();
816        let mut cursor = 0usize;
817        for (start, end) in keep {
818            let start = start.min(len);
819            if cursor < start {
820                buf[cursor..start].fill(0);
821            }
822            cursor = cursor.max(end.min(len));
823        }
824        if cursor < len {
825            buf[cursor..len].fill(0);
826        }
827    }
828
829    fn write_constant_to_arena(&mut self, id: NodeId, dtype: DType, data: &[u8]) {
830        match dtype {
831            DType::F64
832            | DType::F16
833            | DType::BF16
834            | DType::U8
835            | DType::I8
836            | DType::Bool
837            | DType::I64
838            | DType::I32
839            | DType::U32 => {
840                let off = self.arena.byte_offset(id);
841                let buf = self.arena.raw_buf_mut();
842                let n = buf.len().saturating_sub(off).min(data.len());
843                buf[off..off + n].copy_from_slice(&data[..n]);
844            }
845            _ => {
846                let buf = self.arena.slice_mut(id);
847                let n_floats = data.len() / 4;
848                let n = buf.len().min(n_floats);
849                for i in 0..n {
850                    let bytes = [
851                        data[i * 4],
852                        data[i * 4 + 1],
853                        data[i * 4 + 2],
854                        data[i * 4 + 3],
855                    ];
856                    buf[i] = f32::from_le_bytes(bytes);
857                }
858            }
859        }
860    }
861
862    /// Direct-byte param upload — copies caller's bytes into the
863    /// arena slot for the named param without any dtype conversion.
864    /// Used by `set_param_typed` for dtypes that f32-widening would
865    /// corrupt (F64). Caller is responsible for matching the param's
866    /// declared graph dtype.
867    fn set_param_bytes(&mut self, name: &str, data: &[u8], _dtype: rlx_ir::DType) {
868        // Byte-backed params also live solely in the arena (no CPU-side copy).
869        self.write_param_bytes_to_arena(name, data);
870    }
871
872    fn write_param_bytes_to_arena(&mut self, name: &str, data: &[u8]) {
873        if let Some(&id) = self.param_ids.get(name)
874            && self.arena.has_buffer(id)
875        {
876            let off = self.arena.byte_offset(id);
877            let buf = self.arena.raw_buf_mut();
878            debug_assert!(
879                off + data.len() <= buf.len(),
880                "set_param_bytes: '{name}' would overflow arena slot"
881            );
882            buf[off..off + data.len()].copy_from_slice(data);
883        }
884    }
885}