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 = std::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        let mut shown = 0usize;
404        for (i, node) in self.graph.nodes().iter().enumerate() {
405            if !self.arena.has_buffer(node.id) {
406                continue;
407            }
408            if matches!(
409                node.op,
410                rlx_ir::Op::Input { .. }
411                    | rlx_ir::Op::Param { .. }
412                    | rlx_ir::Op::Constant { .. }
413                    | rlx_ir::Op::Reshape { .. }
414                    | rlx_ir::Op::Cast { .. }
415            ) {
416                continue;
417            }
418            if self
419                .node_dtypes
420                .get(&node.id)
421                .copied()
422                .unwrap_or(DType::F32)
423                != DType::F32
424            {
425                continue;
426            }
427            let off = self.arena.byte_offset(node.id);
428            let n = node.shape.num_elements().unwrap_or(0);
429            let data: &[f32] =
430                unsafe { std::slice::from_raw_parts(buf.as_ptr().add(off) as *const f32, n) };
431            let max = data.iter().fold(0f32, |m, &v| m.max(v.abs()));
432            let nz = data.iter().filter(|&&v| v != 0.0).count();
433            let flat_s = if flat_probe < data.len() {
434                format!(" flat[{flat_probe}]={:.6}", data[flat_probe])
435            } else {
436                String::new()
437            };
438            eprintln!(
439                "  [{i:>3}] id={:?} name={:?} {:?} shape={:?} max={max:.6} nonzero={nz}/{}{flat_s}",
440                node.id,
441                node.name,
442                node.op,
443                node.shape.dims(),
444                data.len()
445            );
446            shown += 1;
447            if shown >= limit {
448                break;
449            }
450        }
451    }
452
453    /// Write a f32 input slice into the arena, casting to the node's dtype.
454    fn write_input(&mut self, id: NodeId, data: &[f32]) {
455        let dtype = self.node_dtypes.get(&id).copied().unwrap_or(DType::F32);
456        let off = self.arena.byte_offset(id);
457        let buf = self.arena.raw_buf_mut();
458        let elem_size = dtype.size_bytes();
459        let max_elems = (buf.len() - off) / elem_size;
460        unsafe {
461            write_typed_from_f32(buf.as_mut_ptr().add(off), dtype, data, max_elems);
462        }
463    }
464
465    /// Read a node's arena bytes back as Vec<f32>, casting from its dtype.
466    fn read_output(&self, id: NodeId) -> Vec<f32> {
467        let dtype = self.node_dtypes.get(&id).copied().unwrap_or(DType::F32);
468        let off = self.arena.byte_offset(id);
469        let buf = self.arena.raw_buf();
470        let n_elems = self.graph.node(id).shape.num_elements().unwrap_or(0);
471        unsafe { read_typed_to_f32(buf.as_ptr().add(off), dtype, n_elems) }
472    }
473}
474
475impl ExecutableGraph for CpuExecutable {
476    fn capabilities(&self) -> crate::ExecutableCapabilities {
477        crate::ExecutableCapabilities {
478            clone: true,
479            moe: true,
480            typed_io: true,
481            active_extent: true,
482            ..crate::ExecutableCapabilities::NONE
483        }
484    }
485
486    fn clone_box(&self) -> Box<dyn ExecutableGraph> {
487        Box::new(self.clone())
488    }
489    fn set_param(&mut self, name: &str, data: &[f32]) {
490        // Params live solely in the arena (dedicated, never-aliased slots, see
491        // the memory planner) — no redundant CPU-side copy is kept, which would
492        // double the weight footprint for multi-GB models.
493        // Cast f32 → arena dtype when the param has been rewritten to F16/BF16.
494        if let Some(&id) = self.param_ids.get(name)
495            && self.arena.has_buffer(id)
496        {
497            let dtype = self.node_dtypes.get(&id).copied().unwrap_or(DType::F32);
498            let off = self.arena.byte_offset(id);
499            let buf = self.arena.raw_buf_mut();
500            let elem_size = dtype.size_bytes();
501            let max_elems = (buf.len() - off) / elem_size;
502            unsafe {
503                write_typed_from_f32(buf.as_mut_ptr().add(off), dtype, data, max_elems);
504            }
505        }
506    }
507
508    fn run(&mut self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
509        self.restore_arena_baseline();
510        // 1. Apply persistent handles first — they act like default inputs.
511        //    Explicit `inputs` passed to run() override matching handle names.
512        let handle_names: Vec<String> = self.handles.keys().cloned().collect();
513        for name in &handle_names {
514            if let Some(&id) = self.input_ids.get(name)
515                && self.arena.has_buffer(id)
516            {
517                let data = self.handles.get(name).cloned().unwrap_or_default();
518                self.write_input(id, &data);
519            }
520        }
521        // 2. Explicit per-call inputs override handles.
522        for &(name, data) in inputs {
523            if let Some(&id) = self.input_ids.get(name)
524                && self.arena.has_buffer(id)
525            {
526                self.write_input(id, data);
527            }
528        }
529
530        // Active-extent fast-path (PLAN L1): if hinted AND every thunk
531        // in the schedule supports it, run scaled. Otherwise fall back
532        // to full-extent dispatch — preserves correctness when the
533        // schedule contains a thunk that hasn't yet been wired in.
534        let active_used = if let Some((actual, upper)) = self.active_extent {
535            thunk::execute_thunks_active(&self.schedule, self.arena.raw_buf_mut(), actual, upper)
536        } else {
537            false
538        };
539        if !active_used {
540            // Execute via pre-compiled thunks (zero per-node dispatch overhead)
541            thunk::execute_thunks(&self.schedule, self.arena.raw_buf_mut());
542        }
543
544        self.dump_nodes_if_requested();
545
546        // 3. Sync any handle whose name matches a graph OUTPUT —
547        //    KV-cache pattern: outputs flow back into the same-named
548        //    handle for the next iteration.
549        for (idx, &out_id) in self.graph.outputs.iter().enumerate() {
550            let name = format!("out{idx}");
551            if self.handles.contains_key(&name) {
552                let v = self.read_output(out_id);
553                self.handles.insert(name, v);
554            }
555        }
556
557        self.graph
558            .outputs
559            .iter()
560            .map(|&out_id| self.read_output(out_id))
561            .collect()
562    }
563
564    fn run_raw(&mut self, inputs: &[(&str, &[f32])]) -> Vec<(*const f32, usize)> {
565        self.restore_arena_baseline();
566        // Copy inputs by name (HashMap lookup), casting to arena dtype.
567        for &(name, data) in inputs {
568            if let Some(&id) = self.input_ids.get(name)
569                && self.arena.has_buffer(id)
570            {
571                self.write_input(id, data);
572            }
573        }
574        thunk::execute_thunks(&self.schedule, self.arena.raw_buf_mut());
575        // Note: pointers are raw arena bytes — for F16 outputs, callers
576        // must read 2 bytes/elem, not 4. run() is the safe path for
577        // mixed precision; run_raw() is only meaningful for F32.
578        self.graph
579            .outputs
580            .iter()
581            .map(|&out_id| {
582                let (ptr, len) = self.arena.raw_ptr(out_id);
583                (ptr as *const f32, len)
584            })
585            .collect()
586    }
587
588    /// Fastest path: inputs by index (matching input_slots order), zero-copy output.
589    /// No HashMap, no name matching, no Vec allocation. Casts f32 input
590    /// to F16/BF16 if the input slot's dtype was rewritten.
591    fn run_slots(&mut self, inputs: &[&[f32]]) -> &[(usize, usize)] {
592        self.restore_arena_baseline();
593        let buf = self.arena.raw_buf_mut();
594        for (i, &data) in inputs.iter().enumerate() {
595            if i < self.input_slots.len() {
596                let (_, off, max_len, dtype) = &self.input_slots[i];
597                unsafe {
598                    write_typed_from_f32(buf.as_mut_ptr().add(*off), *dtype, data, *max_len);
599                }
600            }
601        }
602        thunk::execute_thunks(&self.schedule, self.arena.raw_buf_mut());
603        &self.output_slots
604    }
605
606    fn arena_ptr(&self) -> *const u8 {
607        self.arena.raw_buf_mut_ptr()
608    }
609
610    fn bind_handle(&mut self, name: &str, data: &[f32]) -> bool {
611        // Persistent buffer: stored separately from arena, survives run().
612        // If the name matches a graph input, run() will use this data
613        // as the input. If the graph also writes back to this name (via
614        // an output binding pattern), read_handle returns the latest.
615        self.handles.insert(name.to_string(), data.to_vec());
616        true
617    }
618
619    fn read_handle(&self, name: &str) -> Option<Vec<f32>> {
620        self.handles.get(name).cloned()
621    }
622
623    fn set_active_extent(&mut self, extent: Option<(usize, usize)>) {
624        self.active_extent = extent;
625    }
626
627    fn release_scratch(&mut self) {
628        self.release_scratch_pages();
629    }
630
631    fn set_rng(&mut self, rng: rlx_ir::RngOptions) {
632        *self.schedule.rng.write().unwrap() = rng;
633    }
634
635    fn rng(&self) -> rlx_ir::RngOptions {
636        *self.schedule.rng.read().unwrap()
637    }
638
639    fn set_moe_resident_experts(&mut self, mask: &[bool]) {
640        self.moe_resident_layers = None;
641        self.schedule.moe_resident_layers = None;
642        self.moe_resident = Some(Arc::from(mask));
643        self.schedule.moe_resident = self.moe_resident.clone();
644    }
645
646    fn set_moe_resident_experts_per_layer(&mut self, masks: &[&[bool]]) {
647        self.moe_resident = None;
648        self.schedule.moe_resident = None;
649        let layers: Vec<Arc<[bool]>> = masks.iter().map(|m| Arc::from(*m)).collect();
650        let arc = Arc::new(layers);
651        self.moe_resident_layers = Some(arc.clone());
652        self.schedule.moe_resident_layers = Some(arc);
653    }
654
655    fn enable_moe_topk_capture(&mut self, num_experts: usize) -> bool {
656        let cap = rlx_cpu::moe_topk_capture::MoeTopkCapture::new(num_experts);
657        self.moe_topk_capture = Some(cap.clone());
658        self.schedule.moe_topk_capture = Some(cap);
659        true
660    }
661
662    fn take_moe_topk_capture(&mut self) -> Option<Vec<Vec<u32>>> {
663        let cap = self.moe_topk_capture.as_ref()?;
664        let layers = cap.take_layers();
665        if layers.is_empty() {
666            None
667        } else {
668            Some(layers)
669        }
670    }
671
672    fn take_moe_residency_stats(&mut self) -> Option<crate::MoeResidencyStats> {
673        rlx_cpu::moe_residency::take_last_forward_stats()
674    }
675
676    /// Typed param upload. F32 / F16 / BF16 go through the existing
677    /// widen-to-f32 path (the CPU arena is historically f32 with
678    /// optional half-precision rewrite). F64 (and any future
679    /// non-widenable dtype) lands directly in the arena as bytes —
680    /// the f32 path would lose precision.
681    fn set_param_typed(&mut self, name: &str, data: &[u8], dtype: rlx_ir::DType) {
682        if matches!(dtype, DType::F64 | DType::I64 | DType::I32 | DType::U32) {
683            self.set_param_bytes(name, data, dtype);
684            return;
685        }
686        // U8 / I8 raw byte tensors: opaque storage for the GGUF
687        // K-quant `Op::DequantMatMul` path (weights stay packed
688        // in the arena). One arena byte = one element.
689        if matches!(dtype, DType::U8 | DType::I8) {
690            self.set_param_bytes(name, data, dtype);
691            return;
692        }
693        if dtype == DType::F32 {
694            let n = data.len() / 4;
695            let s = unsafe { std::slice::from_raw_parts(data.as_ptr() as *const f32, n) };
696            self.set_param(name, s);
697        } else {
698            let f32_buf = super::widen_bytes_to_f32(data, dtype);
699            self.set_param(name, &f32_buf);
700        }
701    }
702
703    /// Typed run with mixed-dtype inputs/outputs.
704    ///
705    /// For each input: if its declared graph dtype matches the
706    /// caller's bytes, we write directly into the arena (zero
707    /// precision loss — F64 stays F64). For F32 with a half-precision
708    /// arena rewrite, we widen as before. F16/BF16 callers go
709    /// through the existing widen path.
710    ///
711    /// Outputs are read straight from the arena in the graph node's
712    /// declared dtype — F64 outputs come back as 8 bytes/element,
713    /// F32 as 4, etc.
714    fn run_typed(
715        &mut self,
716        inputs: &[(&str, &[u8], rlx_ir::DType)],
717    ) -> Vec<(Vec<u8>, rlx_ir::DType)> {
718        // Decide: are *all* inputs F64? If so, use the direct-byte
719        // path for everything and skip the f32 widening machinery
720        // entirely. Mixed dtype graphs (F32 + F64) take the
721        // per-input dispatch route below.
722        let all_f64 = !inputs.is_empty() && inputs.iter().all(|(_, _, dt)| *dt == DType::F64);
723
724        if all_f64 {
725            for (name, data, _) in inputs {
726                if let Some(&id) = self.input_ids.get(*name) {
727                    if !self.arena.has_buffer(id) {
728                        continue;
729                    }
730                    let off = self.arena.byte_offset(id);
731                    let buf = self.arena.raw_buf_mut();
732                    let n = data.len();
733                    debug_assert!(
734                        off + n <= buf.len(),
735                        "run_typed: input '{name}' overflows arena slot"
736                    );
737                    buf[off..off + n].copy_from_slice(data);
738                }
739            }
740            thunk::execute_thunks(&self.schedule, self.arena.raw_buf_mut());
741        } else {
742            // Mixed-dtype path: dtypes that survive untouched
743            // through the f32-aliased arena (F64, I32, I64, U32)
744            // go in as bytes; F32 and the half-precision family
745            // route through widen-to-f32 + run.
746            let mut f32_owned: Vec<(String, Vec<f32>)> = Vec::new();
747            for (name, data, dt) in inputs {
748                let direct = matches!(
749                    *dt,
750                    DType::F64 | DType::I32 | DType::I64 | DType::U32 | DType::C64 | DType::C128
751                );
752                if direct {
753                    if let Some(&id) = self.input_ids.get(*name) {
754                        if !self.arena.has_buffer(id) {
755                            continue;
756                        }
757                        let off = self.arena.byte_offset(id);
758                        let buf = self.arena.raw_buf_mut();
759                        buf[off..off + data.len()].copy_from_slice(data);
760                    }
761                } else {
762                    let v = super::widen_bytes_to_f32(data, *dt);
763                    f32_owned.push((name.to_string(), v));
764                }
765            }
766            for (name, data) in &f32_owned {
767                if let Some(&id) = self.input_ids.get(name.as_str()) {
768                    if self.arena.has_buffer(id) {
769                        self.write_input(id, data);
770                    }
771                }
772            }
773            let active_used = if let Some((actual, upper)) = self.active_extent {
774                thunk::execute_thunks_active(
775                    &self.schedule,
776                    self.arena.raw_buf_mut(),
777                    actual,
778                    upper,
779                )
780            } else {
781                false
782            };
783            if !active_used {
784                thunk::execute_thunks(&self.schedule, self.arena.raw_buf_mut());
785            }
786        }
787
788        self.dump_nodes_if_requested();
789
790        // Read outputs in declared boundary dtypes.
791        self.graph
792            .outputs
793            .iter()
794            .enumerate()
795            .map(|(idx, &id)| {
796                let exec_dtype = self.graph.node(id).shape.dtype();
797                let declared = self.io_manifest.output_dtype(idx, exec_dtype);
798                if matches!(
799                    exec_dtype,
800                    DType::F64
801                        | DType::F16
802                        | DType::BF16
803                        | DType::I32
804                        | DType::I64
805                        | DType::U32
806                        | DType::C64
807                        | DType::C128
808                ) {
809                    let n_elems = self.graph.node(id).shape.num_elements().unwrap_or(0);
810                    let n_bytes = n_elems * exec_dtype.size_bytes();
811                    let off = self.arena.byte_offset(id);
812                    let bytes = self.arena.raw_buf()[off..off + n_bytes].to_vec();
813                    return (bytes, declared);
814                }
815                let f32_vals = self.read_output(id);
816                if declared != exec_dtype {
817                    return (super::narrow_f32_to_bytes(&f32_vals, declared), declared);
818                }
819                let bytes = f32_vals.iter().flat_map(|v| v.to_le_bytes()).collect();
820                (bytes, declared)
821            })
822            .collect()
823    }
824}
825
826impl CpuExecutable {
827    /// Clear ephemeral (scratch) arena slots before each `run()`. Params are
828    /// written into their dedicated, never-aliased arena slots by `set_param`
829    /// and live for the whole execution, so they are NOT re-zeroed/rewritten
830    /// here — only the intermediate buffers (which carry stale data from the
831    /// previous pass) are zeroed. Compile-time constants are written once.
832    ///
833    /// This keeps the per-run cost O(scratch) instead of O(params): a previous
834    /// version cloned + rewrote the entire (multi-GB) weight region every run,
835    /// which made large models swap-thrash.
836    /// Sorted, merged persistent (param + constant) byte ranges in the arena.
837    /// These live for the whole execution; everything else is ephemeral scratch.
838    /// Used by `release_scratch_pages` (returns the complement's pages to the
839    /// OS), which is `cfg(unix)` — hence the same gate here.
840    /// `restore_arena_baseline` computes its own persistent set inline.
841    #[cfg(unix)]
842    fn persistent_keep_ranges(&self) -> Vec<(usize, usize)> {
843        let persistent: std::collections::HashSet<NodeId> = {
844            let mut s: std::collections::HashSet<NodeId> =
845                self.param_ids.values().copied().collect();
846            for node in self.graph.nodes() {
847                if matches!(node.op, Op::Constant { .. }) {
848                    s.insert(node.id);
849                }
850            }
851            s
852        };
853        let mut keep: Vec<(usize, usize)> = self
854            .graph
855            .nodes()
856            .iter()
857            .filter_map(|node| {
858                let id = node.id;
859                if !persistent.contains(&id) || !self.arena.has_buffer(id) {
860                    return None;
861                }
862                let dtype = self.node_dtypes.get(&id).copied().unwrap_or(DType::F32);
863                let nbytes = node.shape.num_elements().unwrap_or(0) * dtype.size_bytes();
864                let off = self.arena.byte_offset(id);
865                Some((off, off + nbytes))
866            })
867            .collect();
868        keep.sort_unstable();
869        keep
870    }
871
872    /// Return the ephemeral (scratch) pages of the arena to the OS via
873    /// `madvise(MADV_FREE)`, dropping resident RSS while this cached graph sits
874    /// idle between pipeline stages. The arena's virtual allocation and its param
875    /// bytes stay intact; only whole pages that fall ENTIRELY inside the scratch
876    /// complement of the param ranges are advised, so weight bytes are never
877    /// touched. The next run's `restore_arena_baseline` re-zeros scratch, so this
878    /// is purely advisory and cannot affect correctness. Call it only when the
879    /// graph won't run again immediately (e.g. a TTS pipeline finished with a
880    /// subgraph) — never between the steps of a hot loop, or the pages just fault
881    /// back in. No-op on non-unix.
882    #[allow(unused_variables)]
883    fn release_scratch_pages(&self) {
884        #[cfg(unix)]
885        {
886            // 16 KiB covers Apple-silicon pages and is a multiple of 4 KiB, so
887            // rounding to it yields valid page-aligned spans on 4 KiB hosts too.
888            const PAGE: usize = 16 * 1024;
889            let keep = self.persistent_keep_ranges();
890            let base = self.arena.raw_buf_mut_ptr() as usize;
891            let total = self.arena.size();
892            // Walk the complement of `keep` = the scratch spans.
893            let mut cursor = 0usize;
894            let mut spans: Vec<(usize, usize)> = Vec::new();
895            for &(s, e) in &keep {
896                let s = s.min(total);
897                if cursor < s {
898                    spans.push((cursor, s));
899                }
900                cursor = cursor.max(e.min(total));
901            }
902            if cursor < total {
903                spans.push((cursor, total));
904            }
905            for (s, e) in spans {
906                let a = (base + s).div_ceil(PAGE) * PAGE;
907                let b = (base + e) / PAGE * PAGE;
908                if b > a {
909                    // macOS MADV_FREE (5) is lazy — it flags pages freeable but does
910                    // NOT drop them from the physical footprint until memory
911                    // pressure. MADV_FREE_REUSABLE (7) returns them to the OS now and
912                    // decrements phys_footprint (the next access refaults). Linux
913                    // MADV_DONTNEED (4) drops anonymous pages immediately and
914                    // zero-fills on refault. Override with RLX_MADV to trade footprint
915                    // for refault latency (e.g. RLX_MADV=5 for lazy/pressure-only).
916                    #[cfg(target_os = "macos")]
917                    const ADVICE: i32 = 7;
918                    #[cfg(not(target_os = "macos"))]
919                    const ADVICE: i32 = 4;
920                    let advice = std::env::var("RLX_MADV")
921                        .ok()
922                        .and_then(|s| s.parse().ok())
923                        .unwrap_or(ADVICE);
924                    unsafe { madvise(a as *mut core::ffi::c_void, b - a, advice) };
925                }
926            }
927        }
928    }
929
930    fn restore_arena_baseline(&mut self) {
931        // Persistent slots (params + constants) — never zeroed.
932        let persistent: std::collections::HashSet<NodeId> = {
933            let mut s: std::collections::HashSet<NodeId> =
934                self.param_ids.values().copied().collect();
935            for node in self.graph.nodes() {
936                if matches!(node.op, Op::Constant { .. }) {
937                    s.insert(node.id);
938                }
939            }
940            s
941        };
942
943        // Write compile-time constants into the arena once (a fresh arena is
944        // zero-initialized; params are already resident via set_param).
945        if !self.baseline_written {
946            let constants: Vec<(NodeId, DType, Vec<u8>)> = self
947                .graph
948                .nodes()
949                .iter()
950                .filter_map(|node| {
951                    if let Op::Constant { data } = &node.op
952                        && self.arena.has_buffer(node.id)
953                        && !data.is_empty()
954                    {
955                        Some((node.id, node.shape.dtype(), data.clone()))
956                    } else {
957                        None
958                    }
959                })
960                .collect();
961            for (id, dtype, data) in constants {
962                self.write_constant_to_arena(id, dtype, &data);
963            }
964            self.baseline_written = true;
965        }
966
967        // Zero everything EXCEPT the persistent (param + constant) byte ranges.
968        //
969        // We zero the *complement* of the persistent ranges rather than each
970        // scratch node's exact byte span. That covers inter-slot padding and
971        // arena gaps too — a kernel that over-reads its input into adjacent
972        // alignment padding (common in SIMD reductions) would otherwise pick up
973        // stale bytes from a previous run, since per-node zeroing only clears
974        // `num_elements` and leaves the padding dirty. The cost stays O(arena −
975        // params): for a 7B the params dominate the arena and are skipped, so
976        // the swept region is tiny.
977        let mut keep: Vec<(usize, usize)> = self
978            .graph
979            .nodes()
980            .iter()
981            .filter_map(|node| {
982                let id = node.id;
983                if !persistent.contains(&id) || !self.arena.has_buffer(id) {
984                    return None;
985                }
986                let dtype = self.node_dtypes.get(&id).copied().unwrap_or(DType::F32);
987                let nbytes = node.shape.num_elements().unwrap_or(0) * dtype.size_bytes();
988                let off = self.arena.byte_offset(id);
989                Some((off, off + nbytes))
990            })
991            .collect();
992        keep.sort_unstable();
993
994        let buf = self.arena.raw_buf_mut();
995        let len = buf.len();
996        let mut cursor = 0usize;
997        for (start, end) in keep {
998            let start = start.min(len);
999            if cursor < start {
1000                buf[cursor..start].fill(0);
1001            }
1002            cursor = cursor.max(end.min(len));
1003        }
1004        if cursor < len {
1005            buf[cursor..len].fill(0);
1006        }
1007    }
1008
1009    fn write_constant_to_arena(&mut self, id: NodeId, dtype: DType, data: &[u8]) {
1010        match dtype {
1011            DType::F64
1012            | DType::F16
1013            | DType::BF16
1014            | DType::U8
1015            | DType::I8
1016            | DType::Bool
1017            | DType::I64
1018            | DType::I32
1019            | DType::U32 => {
1020                let off = self.arena.byte_offset(id);
1021                let buf = self.arena.raw_buf_mut();
1022                let n = buf.len().saturating_sub(off).min(data.len());
1023                buf[off..off + n].copy_from_slice(&data[..n]);
1024            }
1025            _ => {
1026                let buf = self.arena.slice_mut(id);
1027                let n_floats = data.len() / 4;
1028                let n = buf.len().min(n_floats);
1029                for i in 0..n {
1030                    let bytes = [
1031                        data[i * 4],
1032                        data[i * 4 + 1],
1033                        data[i * 4 + 2],
1034                        data[i * 4 + 3],
1035                    ];
1036                    buf[i] = f32::from_le_bytes(bytes);
1037                }
1038            }
1039        }
1040    }
1041
1042    /// Direct-byte param upload — copies caller's bytes into the
1043    /// arena slot for the named param without any dtype conversion.
1044    /// Used by `set_param_typed` for dtypes that f32-widening would
1045    /// corrupt (F64). Caller is responsible for matching the param's
1046    /// declared graph dtype.
1047    fn set_param_bytes(&mut self, name: &str, data: &[u8], _dtype: rlx_ir::DType) {
1048        // Byte-backed params also live solely in the arena (no CPU-side copy).
1049        self.write_param_bytes_to_arena(name, data);
1050    }
1051
1052    fn write_param_bytes_to_arena(&mut self, name: &str, data: &[u8]) {
1053        if let Some(&id) = self.param_ids.get(name)
1054            && self.arena.has_buffer(id)
1055        {
1056            let off = self.arena.byte_offset(id);
1057            let buf = self.arena.raw_buf_mut();
1058            debug_assert!(
1059                off + data.len() <= buf.len(),
1060                "set_param_bytes: '{name}' would overflow arena slot"
1061            );
1062            buf[off..off + data.len()].copy_from_slice(data);
1063        }
1064    }
1065}