rlx_runtime/compiled.rs
1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Compiled graph — the hot-path execution object.
6
7use crate::backend::ExecutableGraph;
8use rlx_driver::Device;
9use std::collections::{HashMap, HashSet};
10
11/// Param-invariant "prepare" stage attached to a compiled graph.
12///
13/// Holds a separately-compiled weight-only graph (`prepare`) whose outputs are
14/// the boundary tensors of the main graph. Run once (lazily, on the first
15/// forward / `finalize_params`), its outputs are bound into the main graph via
16/// [`CompiledGraph::bind_handle`] so weight-derived compute happens once instead
17/// of every forward. See [`rlx_compile::split_param_invariant`].
18struct Staging {
19 prepare: Box<CompiledGraph>,
20 prepared: bool,
21 /// Boundary input names on the main graph, in `prepare`-output order.
22 boundary: Vec<String>,
23 prepare_params: HashSet<String>,
24 main_params: HashSet<String>,
25 /// True once the prepared outputs were injected via persistent `bind_handle`
26 /// (CPU). When false (backends without persistent handles, e.g. CUDA), the
27 /// prepared values are fed as ordinary inputs each forward from `values`.
28 bound: bool,
29 /// Prepared boundary values, kept only for the feed-each-forward fallback.
30 values: Vec<Vec<f32>>,
31}
32
33/// A compiled graph ready for execution.
34///
35/// Created by [`crate::Session::compile`]. Holds the fused + memory-planned
36/// graph and all pre-allocated execution state. Call
37/// [`CompiledGraph::run`] repeatedly with different inputs — zero
38/// allocation per call.
39pub struct CompiledGraph {
40 inner: Box<dyn ExecutableGraph>,
41 device: Device,
42 /// Optional param-invariant prepare stage (None = ordinary graph).
43 staging: Option<Box<Staging>>,
44 /// Declared dtype of each named `Input` / `Param`, for boundaries that are
45 /// not `f32`.
46 ///
47 /// [`Self::run`] and [`Self::set_param`] take `&[f32]`, which is the right
48 /// shape for the overwhelmingly common case and wrong for an `F16`/`BF16`
49 /// tensor: the bytes used to go to the backend verbatim and be read as
50 /// pairs of halves. Feeding `[1,2,3,4]` to a BF16 input and adding zero
51 /// returned `[2.0038757, 4.007843, 0.0, 0.0]` — no error, no panic.
52 ///
53 /// Empty means "not known" (e.g. compiled straight from HIR), in which case
54 /// the f32 fast path is taken unchanged.
55 io_dtypes: HashMap<String, rlx_ir::DType>,
56}
57
58impl Clone for CompiledGraph {
59 /// Deep-clones the underlying executable via `ExecutableGraph::clone_box`.
60 /// Backends that don't support cloning will panic at this point.
61 fn clone(&self) -> Self {
62 Self {
63 inner: self.inner.clone_box(),
64 device: self.device,
65 io_dtypes: self.io_dtypes.clone(),
66 staging: self.staging.as_ref().map(|s| {
67 Box::new(Staging {
68 prepare: s.prepare.clone(),
69 prepared: s.prepared,
70 boundary: s.boundary.clone(),
71 prepare_params: s.prepare_params.clone(),
72 main_params: s.main_params.clone(),
73 bound: s.bound,
74 values: s.values.clone(),
75 })
76 }),
77 }
78 }
79}
80
81impl CompiledGraph {
82 pub(crate) fn new(inner: Box<dyn ExecutableGraph>, device: Device) -> Self {
83 Self {
84 inner,
85 device,
86 staging: None,
87 io_dtypes: HashMap::new(),
88 }
89 }
90
91 /// Record the declared dtype of each named `Input` / `Param`.
92 ///
93 /// Called by the compile paths that still have the graph in hand. Without
94 /// it the f32 entry points cannot tell a genuine `f32` boundary from an
95 /// `F16`/`BF16` one being fed as f32 — see [`Self::io_dtypes`].
96 pub(crate) fn set_io_dtypes(
97 &mut self,
98 dtypes: impl IntoIterator<Item = (String, rlx_ir::DType)>,
99 ) {
100 self.io_dtypes = dtypes.into_iter().collect();
101 }
102
103 /// Declared dtype of a named boundary tensor when it is a float NARROWER
104 /// than `f32` — the only case the f32 entry points get wrong.
105 ///
106 /// Deliberately not "any dtype that is not F32". `F64` boundaries (the SPD /
107 /// Riemannian-geometry graphs declare theirs that way) already round-trip
108 /// through the plain f32 path, and routing them here instead converted the
109 /// values and returned garbage — every `spd_host_parity` test failed with
110 /// max|Δ| in the single digits. Integer boundaries are likewise widened by
111 /// the backends already and must not be touched here.
112 fn narrow_boundary(&self, name: &str) -> Option<rlx_ir::DType> {
113 match self.io_dtypes.get(name) {
114 Some(&dt) if matches!(dt, rlx_ir::DType::F16 | rlx_ir::DType::BF16) => Some(dt),
115 _ => None,
116 }
117 }
118
119 /// Attach a param-invariant `prepare` stage to a compiled MAIN graph.
120 /// `prepare` and `boundary` come from [`rlx_compile::split_param_invariant`].
121 pub(crate) fn with_staging(
122 mut self,
123 prepare: CompiledGraph,
124 boundary: Vec<String>,
125 prepare_params: HashSet<String>,
126 main_params: HashSet<String>,
127 ) -> Self {
128 self.staging = Some(Box::new(Staging {
129 prepare: Box::new(prepare),
130 prepared: false,
131 boundary,
132 prepare_params,
133 main_params,
134 bound: false,
135 values: Vec::new(),
136 }));
137 self
138 }
139
140 /// Run the `prepare` stage once (lazily) and make its outputs available to
141 /// the main graph — via persistent `bind_handle` when supported (CPU), else
142 /// kept for feed-each-forward (CUDA). No-op after the first call.
143 fn ensure_prepared(&mut self) {
144 let Some(st) = self.staging.as_mut() else {
145 return;
146 };
147 if st.prepared {
148 return;
149 }
150 st.prepare.finalize_params();
151 let outs = st.prepare.run(&[]);
152 // Try persistent binding; it succeeds only when every boundary binds.
153 let mut all_bound = !outs.is_empty();
154 for (name, out) in st.boundary.iter().zip(outs.iter()) {
155 if !self.inner.bind_handle(name, out) {
156 all_bound = false;
157 }
158 }
159 st.bound = all_bound;
160 st.values = if all_bound { Vec::new() } else { outs };
161 st.prepared = true;
162 }
163
164 /// Which device this graph runs on.
165 pub fn device(&self) -> Device {
166 self.device
167 }
168
169 /// Set a named parameter (model weight).
170 /// Call once per parameter after compilation.
171 pub fn set_param(&mut self, name: &str, data: &[f32]) {
172 // A narrower-than-f32 weight needs converting, not reinterpreting:
173 // `set_param` writes the bytes as given, and 4 bytes of f32 read as two
174 // halves silently doubles the element count.
175 if let Some(dt) = self.narrow_boundary(name) {
176 let bytes = crate::backend::narrow_f32_to_bytes(data, dt);
177 self.set_param_typed(name, &bytes, dt);
178 return;
179 }
180 if let Some(st) = self.staging.as_mut() {
181 if st.prepare_params.contains(name) {
182 st.prepare.set_param(name, data);
183 st.prepared = false; // weight changed → re-run prepare next forward
184 }
185 if st.main_params.contains(name) {
186 self.inner.set_param(name, data);
187 }
188 } else {
189 self.inner.set_param(name, data);
190 }
191 }
192
193 /// Execute the graph with named inputs.
194 /// Returns one `Vec<f32>` per graph output (copies from arena).
195 pub fn run(&mut self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
196 // Any input declared narrower than f32 has to be converted before it
197 // reaches the backend. `run_typed` is the path that carries a dtype per
198 // input; nine backends implement it. Only taken when a narrow boundary
199 // is actually present, so the f32 case keeps its direct path.
200 if inputs
201 .iter()
202 .any(|(n, _)| self.narrow_boundary(n).is_some())
203 {
204 return self.run_narrowed(inputs);
205 }
206 self.ensure_prepared();
207 match self.staging.as_ref() {
208 Some(st) if !st.bound => {
209 let mut merged: Vec<(&str, &[f32])> =
210 Vec::with_capacity(inputs.len() + st.boundary.len());
211 merged.extend_from_slice(inputs);
212 for (name, vals) in st.boundary.iter().zip(st.values.iter()) {
213 merged.push((name.as_str(), vals.as_slice()));
214 }
215 self.inner.run(&merged)
216 }
217 _ => self.inner.run(inputs),
218 }
219 }
220
221 /// [`Self::run`] for graphs with an `F16`/`BF16` input boundary.
222 ///
223 /// Narrows each input to its declared dtype, runs the typed path, and
224 /// widens the outputs back to `f32` so the return type is unchanged. The
225 /// widening is lossless — `f32` holds every `F16`/`BF16` value exactly.
226 fn run_narrowed(&mut self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
227 self.ensure_prepared();
228 let owned: Vec<(&str, Vec<u8>, rlx_ir::DType)> = inputs
229 .iter()
230 .map(|(name, vals)| {
231 let dt = self.narrow_boundary(name).unwrap_or(rlx_ir::DType::F32);
232 (*name, crate::backend::narrow_f32_to_bytes(vals, dt), dt)
233 })
234 .collect();
235 let typed: Vec<(&str, &[u8], rlx_ir::DType)> = owned
236 .iter()
237 .map(|(n, b, d)| (*n, b.as_slice(), *d))
238 .collect();
239 self.inner
240 .run_typed(&typed)
241 .into_iter()
242 .map(|(bytes, dt)| crate::backend::widen_bytes_to_f32(&bytes, dt))
243 .collect()
244 }
245
246 /// Async WebGPU execution on wasm (non-blocking GPU→CPU readback).
247 /// WebGL and CPU backends fall back to synchronous [`Self::run`].
248 #[cfg(target_arch = "wasm32")]
249 pub async fn run_async(&mut self, inputs: &[(&str, &[f32])]) -> Result<Vec<Vec<f32>>, String> {
250 match self.device {
251 Device::WebGpu | Device::Gpu => self.inner.wgpu_run_async(inputs).await,
252 _ => Ok(self.run(inputs)),
253 }
254 }
255
256 /// Run and read back only selected outputs (logits-only decode on MLX).
257 pub fn run_read_outputs(
258 &mut self,
259 inputs: &[(&str, &[f32])],
260 read_indices: Option<&[usize]>,
261 ) -> Vec<Vec<f32>> {
262 self.ensure_prepared();
263 match self.staging.as_ref() {
264 Some(st) if !st.bound => {
265 let mut merged: Vec<(&str, &[f32])> =
266 Vec::with_capacity(inputs.len() + st.boundary.len());
267 merged.extend_from_slice(inputs);
268 for (name, vals) in st.boundary.iter().zip(st.values.iter()) {
269 merged.push((name.as_str(), vals.as_slice()));
270 }
271 self.inner.run_read_outputs(&merged, read_indices)
272 }
273 _ => self.inner.run_read_outputs(inputs, read_indices),
274 }
275 }
276
277 /// Read one row from a row-major output tensor after a forward pass.
278 pub fn read_output_row(
279 &self,
280 out_idx: usize,
281 row: usize,
282 row_inner: usize,
283 ) -> Option<Vec<f32>> {
284 self.inner.read_output_row(out_idx, row, row_inner)
285 }
286
287 /// Execute and return raw pointers to output data (zero-copy).
288 /// Data is valid until the next `run`/`run_raw` call.
289 ///
290 /// # Safety
291 /// The returned pointers point into the arena. Do not use after
292 /// the next call to run/run_raw (arena data will be overwritten).
293 pub fn run_raw(&mut self, inputs: &[(&str, &[f32])]) -> Vec<(*const f32, usize)> {
294 self.ensure_prepared();
295 self.inner.run_raw(inputs)
296 }
297
298 /// Fastest execution: inputs by slot index (order matches graph input declaration).
299 /// Returns output (offset, len) pairs. Read data via `arena_ptr().add(offset)`.
300 /// Zero HashMap lookup, zero Vec allocation, zero name matching.
301 pub fn run_slots(&mut self, inputs: &[&[f32]]) -> &[(usize, usize)] {
302 self.ensure_prepared();
303 self.inner.run_slots(inputs)
304 }
305
306 /// Arena pointer for reading output data after `run_slots`.
307 pub fn arena_ptr(&self) -> *const u8 {
308 self.inner.arena_ptr()
309 }
310
311 /// Bind a persistent buffer (KV-cache, optimizer state, etc.).
312 /// Stays alive across `run()` calls; the backend uses it as the
313 /// graph input with the matching name.
314 /// Returns true if the backend supports persistent handles.
315 pub fn bind_handle(&mut self, name: &str, data: &[f32]) -> bool {
316 self.inner.bind_handle(name, data)
317 }
318
319 /// Read the current contents of a persistent buffer.
320 pub fn read_handle(&self, name: &str) -> Option<Vec<f32>> {
321 self.inner.read_handle(name)
322 }
323
324 /// What this executable can actually do.
325 ///
326 /// The ground truth, as opposed to [`crate::advisory_capabilities`], which
327 /// answers the same question from a device enum *before* anything is
328 /// compiled — useful for planning, but hand-maintained and therefore able to
329 /// drift. `rlx-runtime/tests/capability_table_matches_backends.rs` pins the
330 /// two together.
331 pub fn capabilities(&self) -> crate::ExecutableCapabilities {
332 self.inner.capabilities()
333 }
334
335 /// GPU-resident MLX input (no-op on non-MLX backends).
336 pub fn bind_gpu_handle(&mut self, name: &str, data: &[f32]) -> bool {
337 self.inner.bind_gpu_handle(name, data)
338 }
339
340 /// ZERO-COPY in-place optimizer step over GPU-resident weights: the
341 /// optimizer reads gradients and writes params directly on the unified-memory
342 /// arena (no GPU→host→GPU roundtrip). `trainable[i] = (input_name, shape)`;
343 /// the gradient is read from output slot `1 + i` (backward outputs
344 /// `[loss, grad0, …]`). Pair with [`Self::bind_gpu_handle`] (weight resident)
345 /// and `run_read_outputs(_, Some(&[0]))` (read only the loss). Returns false
346 /// on backends without GPU-resident support — fall back to a host step.
347 ///
348 /// ```ignore
349 /// compiled.bind_gpu_handle("W", &w_init);
350 /// let mut muon = Muon::new(lr);
351 /// for _ in 0..steps {
352 /// compiled.run_read_outputs(&[("d_output", &[1.0])], Some(&[0]));
353 /// compiled.optimizer_step_resident(&trainable, &mut |n, s, p, g| muon.step(n, s, p, g));
354 /// }
355 /// ```
356 pub fn optimizer_step_resident(
357 &mut self,
358 trainable: &[(String, Vec<usize>)],
359 step: &mut dyn FnMut(&str, &[usize], &mut [f32], &[f32]),
360 ) -> bool {
361 self.inner.optimizer_step_resident(trainable, step)
362 }
363
364 pub fn has_gpu_handle(&self, name: &str) -> bool {
365 self.inner.has_gpu_handle(name)
366 }
367
368 pub fn set_gpu_handle_feed(&mut self, handle_name: &str, output_index: usize) -> bool {
369 self.inner.set_gpu_handle_feed(handle_name, output_index)
370 }
371
372 pub fn read_gpu_handle(&self, name: &str) -> Option<Vec<f32>> {
373 self.inner.read_gpu_handle(name)
374 }
375
376 /// Read one row from a resident GPU input handle without full-tensor D2H.
377 pub fn read_gpu_handle_row(
378 &self,
379 name: &str,
380 row: usize,
381 row_inner: usize,
382 ) -> Option<Vec<f32>> {
383 self.inner.read_gpu_handle_row(name, row, row_inner)
384 }
385
386 /// Register a targeted row feed for resident KV decode (graphs that emit the
387 /// new token at the last bucket-padded output row). No-op (false) on
388 /// backends without GPU-resident handle support. See [`Self::feed_kv_row`].
389 pub fn register_kv_row_feed(&mut self, handle_name: &str, output_index: usize) -> bool {
390 self.inner.register_kv_row_feed(handle_name, output_index)
391 }
392
393 /// Fold each registered row feed's new-token row (`src_row` of its output)
394 /// into the resident handle slot at `dst_row` (`row_elems` = kv_dim),
395 /// in-place on device. Returns false when unsupported.
396 pub fn feed_kv_row(&mut self, src_row: usize, dst_row: usize, row_elems: usize) -> bool {
397 self.inner.feed_kv_row(src_row, dst_row, row_elems)
398 }
399
400 /// Batch-major resident KV feed (`past[b, dst_row]` ← `new[b, 0]`).
401 pub fn feed_kv_batch_major(
402 &mut self,
403 dst_row: usize,
404 batch: usize,
405 seq_cap: usize,
406 row_elems: usize,
407 ) -> bool {
408 self.inner
409 .feed_kv_batch_major(dst_row, batch, seq_cap, row_elems)
410 }
411
412 /// Strided-source batch-major resident KV feed: fold the new token from a
413 /// `[B, src_seq_cap, row_elems]` output at `src_row` into the resident input
414 /// handle `[B, dst_seq_cap, row_elems]` at `dst_row`. For decode graphs that
415 /// emit the whole appended cache (new token at output row `upper`).
416 #[allow(clippy::too_many_arguments)]
417 pub fn feed_kv_batch_major_src(
418 &mut self,
419 src_row: usize,
420 src_seq_cap: usize,
421 dst_row: usize,
422 batch: usize,
423 dst_seq_cap: usize,
424 row_elems: usize,
425 ) -> bool {
426 self.inner.feed_kv_batch_major_src(
427 src_row,
428 src_seq_cap,
429 dst_row,
430 batch,
431 dst_seq_cap,
432 row_elems,
433 )
434 }
435
436 /// Ragged resident KV feed: each batch element folds the new token from a
437 /// `[B, src_seq_cap, row_elems]` output at `src_row` into its own resident
438 /// handle row `dst_rows[b]` (the sequence's `past_len`). For fused decode of
439 /// sequences at MIXED cache lengths.
440 pub fn feed_kv_batch_major_ragged(
441 &mut self,
442 src_row: usize,
443 src_seq_cap: usize,
444 dst_rows: &[usize],
445 dst_seq_cap: usize,
446 row_elems: usize,
447 ) -> bool {
448 self.inner.feed_kv_batch_major_ragged(
449 src_row,
450 src_seq_cap,
451 dst_rows,
452 dst_seq_cap,
453 row_elems,
454 )
455 }
456
457 /// Mark a graph input as device-resident without host staging.
458 pub fn prepare_resident_gpu_handle(&mut self, name: &str) -> bool {
459 self.inner.prepare_resident_gpu_handle(name)
460 }
461
462 /// Upload bound (non-resident) GPU handle mirrors into the arena.
463 pub fn stage_bound_gpu_handles_to_arena(&mut self) -> bool {
464 self.inner.stage_bound_gpu_handles_to_arena();
465 true
466 }
467
468 /// D2D copy resident KV rows `[from_row..to_row)` from another compiled graph.
469 pub fn seed_resident_kv_prefix_from(
470 &mut self,
471 src: &CompiledGraph,
472 prefix_tokens: usize,
473 outgoing_upper: usize,
474 kv_dim: usize,
475 n_layers: usize,
476 ) -> bool {
477 if self.device != src.device {
478 return false;
479 }
480 self.inner.seed_resident_kv_prefix_from(
481 src.inner.as_ref(),
482 prefix_tokens,
483 outgoing_upper,
484 kv_dim,
485 n_layers,
486 )
487 }
488
489 /// D2D copy resident KV rows `[from_row..to_row)` from another compiled graph.
490 pub fn copy_resident_kv_rows_from(
491 &mut self,
492 src: &CompiledGraph,
493 from_row: usize,
494 to_row: usize,
495 outgoing_upper: usize,
496 kv_dim: usize,
497 n_layers: usize,
498 ) -> bool {
499 if self.device != src.device {
500 return false;
501 }
502 self.inner.copy_resident_kv_rows_from(
503 src.inner.as_ref(),
504 from_row,
505 to_row,
506 outgoing_upper,
507 kv_dim,
508 n_layers,
509 )
510 }
511
512 /// Copy parameter buffers from `src` when layouts match (same device/backend).
513 pub fn copy_params_from(&mut self, src: &CompiledGraph) -> bool {
514 if self.device != src.device {
515 return false;
516 }
517 self.inner.copy_params_from(src.inner.as_ref())
518 }
519
520 /// Share `src`'s packed weight buffer (retain, not copy) instead of uploading
521 /// our own, when the weight layout matches EXACTLY — one GPU copy backs both.
522 /// Returns false (caller must upload) on device mismatch or differing layout.
523 pub fn share_params_from(&mut self, src: &CompiledGraph) -> bool {
524 if self.device != src.device {
525 return false;
526 }
527 self.inner.share_params_from(src.inner.as_ref())
528 }
529
530 /// Run, refresh GPU handle from output, return that output vector.
531 pub fn run_feed_gpu_handle(
532 &mut self,
533 inputs: &[(&str, &[f32])],
534 handle_name: &str,
535 output_index: usize,
536 ) -> Option<Vec<f32>> {
537 self.inner
538 .run_feed_gpu_handle(inputs, handle_name, output_index)
539 }
540
541 /// Hint subsequent `run` calls to process only the first `actual`
542 /// rows along the bucket axis (out of `upper`, the compile extent).
543 /// Backends that support per-kernel active-extent dispatch honor
544 /// this; others ignore it. Pass `None` to clear.
545 ///
546 /// See `BucketedCompileCache::run_padded` for the canonical caller.
547 pub fn set_active_extent(&mut self, extent: Option<(usize, usize)>) {
548 #[cfg(feature = "cpu")]
549 if let Some((actual, _)) = extent {
550 crate::onnx_active::set_active_token_count(Some(actual))
551 }
552 self.inner.set_active_extent(extent);
553 }
554
555 /// Return this graph's idle scratch (activation) pages to the OS while it stays
556 /// cached — for multi-subgraph pipelines whose stages run sequentially. Weights
557 /// stay resident; the next run refaults + re-zeros scratch transparently. Call
558 /// only between stages, never inside a hot loop. Advisory no-op off CPU.
559 pub fn release_scratch(&mut self) {
560 self.inner.release_scratch();
561 }
562
563 /// TIDE merged MoE placement (`mask[expert]` device-resident if any layer has it).
564 pub fn set_moe_resident_experts(&mut self, mask: &[bool]) {
565 self.inner.set_moe_resident_experts(mask);
566 }
567
568 /// Per MoE layer placement (forward order). Preferred on CPU over merged mask.
569 pub fn set_moe_resident_experts_per_layer(&mut self, masks: &[&[bool]]) {
570 self.inner.set_moe_resident_experts_per_layer(masks);
571 }
572
573 /// Capture MoE router TopK on next forward (CPU). Returns false if unsupported.
574 pub fn enable_moe_topk_capture(&mut self, num_experts: usize) -> bool {
575 self.inner.enable_moe_topk_capture(num_experts)
576 }
577
578 /// Per-layer expert indices from the last forward (MoE router TopK order).
579 pub fn take_moe_topk_capture(&mut self) -> Option<Vec<Vec<u32>>> {
580 self.inner.take_moe_topk_capture()
581 }
582
583 /// GroupedMatMul GPU/CPU token accounting from the last forward (CPU).
584 pub fn take_moe_residency_stats(&mut self) -> Option<crate::MoeResidencyStats> {
585 self.inner.take_moe_residency_stats()
586 }
587
588 // ── Pipelined / async execution (Phase C) ─────────────────────────
589
590 /// Encode + commit a forward pass without waiting for the device.
591 ///
592 /// Outputs of intermediate calls are stomped — use `run_pipelined`
593 /// when you need each call's outputs back. Pair with `sync_pending`
594 /// to drain. CPU is synchronous, so this falls back to `run`.
595 pub fn commit_no_wait(&mut self, inputs: &[(&str, &[f32])]) {
596 self.inner.commit_no_wait(inputs);
597 }
598
599 /// Wait for every command queued by `commit_no_wait`. CPU is a no-op.
600 pub fn sync_pending(&mut self) {
601 self.inner.sync_pending();
602 }
603
604 /// Pipelined batch run. Issues one commit per input set, syncs once
605 /// at the end. On Metal, each commit gets its own output snapshot
606 /// (allocated + blit-copied), so subsequent commits stomping the
607 /// shared arena don't corrupt earlier runs' outputs.
608 /// Returns `out[run_idx][output_idx][element_idx]`.
609 pub fn run_pipelined(&mut self, input_sets: &[Vec<(&str, &[f32])>]) -> Vec<Vec<Vec<f32>>> {
610 self.inner.run_pipelined(input_sets)
611 }
612
613 /// Set a named parameter from raw bytes in the given dtype. The
614 /// backend handles the widen-to-f32 (or zero-widen, when supported
615 /// natively) on the way in. Lets callers feed F16/BF16 weights
616 /// without a host-side cast.
617 pub fn set_param_typed(&mut self, name: &str, data: &[u8], dtype: rlx_ir::DType) {
618 if let Some(st) = self.staging.as_mut() {
619 if st.prepare_params.contains(name) {
620 st.prepare.set_param_typed(name, data, dtype);
621 st.prepared = false;
622 }
623 if st.main_params.contains(name) {
624 self.inner.set_param_typed(name, data, dtype);
625 }
626 } else {
627 self.inner.set_param_typed(name, data, dtype);
628 }
629 }
630
631 /// Incrementally write raw `data` into a named param starting `byte_offset`
632 /// bytes into its storage. Returns true if the backend did the partial write;
633 /// false → caller must fall back to a whole-buffer [`Self::set_param_typed`].
634 /// Used by paged-MoE residency to upload only the changed expert slot.
635 pub fn set_param_range(&mut self, name: &str, byte_offset: usize, data: &[u8]) -> bool {
636 if let Some(st) = self.staging.as_mut() {
637 let mut done = false;
638 if st.prepare_params.contains(name) {
639 done |= st.prepare.set_param_range(name, byte_offset, data);
640 st.prepared = false;
641 }
642 if st.main_params.contains(name) {
643 done |= self.inner.set_param_range(name, byte_offset, data);
644 }
645 done
646 } else {
647 self.inner.set_param_range(name, byte_offset, data)
648 }
649 }
650
651 /// Finish param upload — warms backend caches when supported.
652 pub fn finalize_params(&mut self) {
653 self.ensure_prepared();
654 self.inner.finalize_params();
655 }
656
657 /// Execute with typed inputs and return outputs in their declared
658 /// graph dtype, byte-encoded. Mirrors the wgpu / MLX zero-widen
659 /// semantics on f32-arena backends (CPU + Metal) by widening at
660 /// the boundary.
661 pub fn run_typed(
662 &mut self,
663 inputs: &[(&str, &[u8], rlx_ir::DType)],
664 ) -> Vec<(Vec<u8>, rlx_ir::DType)> {
665 self.ensure_prepared();
666 self.inner.run_typed(inputs)
667 }
668
669 /// Override RNG policy for in-graph random ops without recompiling.
670 pub fn set_rng(&mut self, rng: rlx_ir::RngOptions) {
671 self.inner.set_rng(rng);
672 }
673
674 /// Current RNG compile/execute policy.
675 pub fn rng(&self) -> rlx_ir::RngOptions {
676 self.inner.rng()
677 }
678}
679
680#[cfg(test)]
681mod tests {
682 use crate::*;
683
684 #[test]
685 #[cfg(feature = "cpu")]
686 fn end_to_end_session() {
687 let mut g = Graph::new("matmul_bias_gelu");
688 let x = g.input("x", Shape::new(&[2, 4], DType::F32));
689 let w = g.param("w", Shape::new(&[4, 3], DType::F32));
690 let b = g.param("b", Shape::new(&[3], DType::F32));
691 let mm = g.matmul(x, w, Shape::new(&[2, 3], DType::F32));
692 let add = g.binary(op::BinaryOp::Add, mm, b, Shape::new(&[2, 3], DType::F32));
693 let out = g.activation(op::Activation::Gelu, add, Shape::new(&[2, 3], DType::F32));
694 g.set_outputs(vec![out]);
695
696 // Compile
697 let session = Session::new(Device::Cpu);
698 let mut compiled = session.compile(g);
699
700 // Set weights
701 // w = identity-ish [4, 3]: first 3 rows are I, last row is 0
702 compiled.set_param(
703 "w",
704 &[1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0],
705 );
706 compiled.set_param("b", &[0.5, -0.5, 0.0]);
707
708 // Run
709 let x_data = vec![
710 1.0, 0.0, 0.0, 0.0, // row 0: [1,0,0,0] @ w = [1,0,0] + bias = [1.5,-0.5,0]
711 0.0, 1.0, 0.0, 0.0, // row 1: [0,1,0,0] @ w = [0,1,0] + bias = [0.5, 0.5,0]
712 ];
713 let outputs = compiled.run(&[("x", &x_data)]);
714
715 assert_eq!(outputs.len(), 1);
716 let result = &outputs[0];
717 assert_eq!(result.len(), 6); // [2, 3]
718
719 // gelu(1.5) ≈ 1.399, gelu(-0.5) ≈ -0.154, gelu(0) = 0
720 assert!(
721 (result[0] - 1.399).abs() < 0.01,
722 "gelu(1.5) = {}",
723 result[0]
724 );
725 assert!(
726 (result[1] - -0.154).abs() < 0.01,
727 "gelu(-0.5) = {}",
728 result[1]
729 );
730 assert!((result[2]).abs() < 0.01, "gelu(0) = {}", result[2]);
731
732 // gelu(0.5) ≈ 0.346, gelu(0.5) ≈ 0.346, gelu(0) = 0
733 assert!(
734 (result[3] - 0.346).abs() < 0.01,
735 "gelu(0.5) = {}",
736 result[3]
737 );
738 assert!(
739 (result[4] - 0.346).abs() < 0.01,
740 "gelu(0.5) = {}",
741 result[4]
742 );
743
744 // Run again with different input — zero allocation
745 let x2 = vec![0.0f32; 8];
746 let outputs2 = compiled.run(&[("x", &x2)]);
747 // All zeros input → gelu(bias) for each output
748 let r2 = &outputs2[0];
749 assert!((r2[0] - 0.346).abs() < 0.01, "gelu(0.5) = {}", r2[0]); // gelu(0+0.5)
750 }
751
752 #[test]
753 #[cfg(feature = "cpu")]
754 fn device_display() {
755 use crate::device_ext::is_available;
756 assert!(format!("{}", Device::Cpu).starts_with("CPU"));
757 assert!(is_available(Device::Cpu));
758 // Backend availability is feature-gated; only assert
759 // unavailable when the corresponding feature is off.
760 #[cfg(not(feature = "gpu"))]
761 assert!(!is_available(Device::Gpu));
762 #[cfg(not(feature = "cuda"))]
763 assert!(!is_available(Device::Cuda));
764 #[cfg(not(feature = "rocm"))]
765 assert!(!is_available(Device::Rocm));
766 #[cfg(not(feature = "tpu"))]
767 assert!(!is_available(Device::Tpu));
768 }
769}