Skip to main content

rlx_runtime/
dist.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Ship-graph distributed execution — **build the worker once, run any model.**
17//!
18//! A worker is a *generic* executor: it compiles and runs a graph the
19//! coordinator ships at runtime, so no model is baked into the worker binary
20//! and there is **no per-node / per-model recompile**. Build one worker binary
21//! per architecture (`Node::from_env().connect()` + [`serve_stage`]), deploy it
22//! to every node, and drive any model from a coordinator.
23//!
24//! The coordinator — which owns the model's graph builders (e.g. a crate in
25//! `rlx-models`) — partitions the model into stages and ships each worker a
26//! [`StageSpec`]: the serialized subgraph, its I/O node names, where to fetch
27//! each weight, and a device directive. Weights are resolved **locally** by a
28//! caller-provided closure (GGUF / safetensors / HF — rlx core stays
29//! model-agnostic), so only specs (KB) and activations cross the wire — never
30//! the weights.
31//!
32//! ```no_run
33//! # use rlx_runtime::dist;
34//! # use rlx_driver::ProcessGroup; use std::sync::Arc;
35//! # fn demo(group: Arc<ProcessGroup>) -> Result<(), String> {
36//! // Generic worker binary — the SAME build runs any model:
37//! let mut stage = dist::recv_stage(&group, |uri| load_weights(uri))?;
38//! let x = dist::recv_activation(&group, group.rank() - 1)?;
39//! let y = stage.run(&x);
40//! dist::send_activation(&group, /*next*/ 0, &y)?;
41//! # Ok(()) }
42//! # fn load_weights(_uri: &str) -> Vec<f32> { vec![] }
43//! ```
44
45use crate::compiled::CompiledGraph;
46use crate::{Device, Session};
47use rlx_driver::ProcessGroup;
48use rlx_ir::Graph;
49use serde::{Deserialize, Serialize};
50use std::collections::HashMap;
51
52/// Wire tags for the ship-graph protocol (kept clear of the reserved
53/// collective range in `rlx-driver`).
54const TAG_SPEC: u32 = 40;
55const TAG_ACT: u32 = 41;
56
57/// Where one parameter's data comes from. The worker resolves the `uri`
58/// itself, so large weights stay node-local.
59#[derive(Serialize, Deserialize, Clone, Debug)]
60pub struct WeightRef {
61    pub name: String,
62    /// Opaque to rlx core — the caller's resolver interprets it (e.g.
63    /// `gguf:///models/x.gguf#blk.0.ffn`, `file:///…`, `hf://…`).
64    pub uri: String,
65    /// If set, load the **quantized bytes as-is** (U8 param) instead of
66    /// dequantizing to f32 — the graph's `DequantMatMul` decodes them at
67    /// matmul time, so the weight stays at native quant memory (e.g. Q4_K)
68    /// with no dequant round-trip. The coordinator sets this when it built a
69    /// `DequantMatMul` graph for the stage.
70    #[serde(default)]
71    pub packed: bool,
72}
73
74/// A self-describing unit of work for a worker: everything needed to run one
75/// stage of a model, with nothing implied by convention.
76#[derive(Serialize, Deserialize, Clone, Debug)]
77pub struct StageSpec {
78    pub graph: Graph,
79    /// Input node name the activation feeds.
80    pub input: String,
81    /// Output node name (informational; `run` returns the single output).
82    pub output: String,
83    pub weights: Vec<WeightRef>,
84    /// Device directive: `auto` (fastest available) or a device name
85    /// (`cuda`, `metal`, `cpu`, …). Honored against local availability.
86    pub device: String,
87}
88
89impl StageSpec {
90    /// A stage whose weights all come from `uri_for(param_name)`, on `device`.
91    pub fn new(
92        graph: Graph,
93        input: impl Into<String>,
94        output: impl Into<String>,
95        device: impl Into<String>,
96    ) -> Self {
97        Self {
98            graph,
99            input: input.into(),
100            output: output.into(),
101            weights: Vec::new(),
102            device: device.into(),
103        }
104    }
105    /// Add an f32 weight (dequantized on load).
106    pub fn weight(mut self, name: impl Into<String>, uri: impl Into<String>) -> Self {
107        self.weights.push(WeightRef {
108            name: name.into(),
109            uri: uri.into(),
110            packed: false,
111        });
112        self
113    }
114    /// Add a **quantized** weight loaded as packed bytes (for a `DequantMatMul`
115    /// graph) — native quant memory, no dequant round-trip.
116    pub fn weight_packed(mut self, name: impl Into<String>, uri: impl Into<String>) -> Self {
117        self.weights.push(WeightRef {
118            name: name.into(),
119            uri: uri.into(),
120            packed: true,
121        });
122        self
123    }
124}
125
126/// Coordinator: ship a stage spec to worker `rank`.
127pub fn ship_stage(group: &ProcessGroup, rank: u32, spec: &StageSpec) -> Result<(), String> {
128    let bytes = serde_json::to_vec(spec).map_err(|e| e.to_string())?;
129    group
130        .transport()
131        .send_bytes(rank, TAG_SPEC, &bytes)
132        .map_err(|e| format!("ship_stage: {e}"))
133}
134
135/// Send an activation tensor to `to` (a worker, or the next pipeline stage).
136pub fn send_activation(group: &ProcessGroup, to: u32, data: &[f32]) -> Result<(), String> {
137    group.send_f32(to, TAG_ACT, data).map_err(|e| e.to_string())
138}
139
140/// Receive an activation tensor from `from`.
141pub fn recv_activation(group: &ProcessGroup, from: u32) -> Result<Vec<f32>, String> {
142    group.recv_f32(from, TAG_ACT).map_err(|e| e.to_string())
143}
144
145/// A compiled stage sitting on this worker, ready to run activations.
146pub struct WorkerStage {
147    /// The backend this worker resolved the stage onto.
148    pub device: Device,
149    /// Node count of the received graph (handy for logging).
150    pub nodes: usize,
151    input: String,
152    compiled: CompiledGraph,
153}
154
155impl WorkerStage {
156    /// Run one activation through the stage; returns the (single) output.
157    pub fn run(&mut self, x: &[f32]) -> Vec<f32> {
158        self.compiled
159            .run(&[(self.input.as_str(), x)])
160            .into_iter()
161            .next()
162            .unwrap_or_default()
163    }
164    pub fn input_name(&self) -> &str {
165        &self.input
166    }
167}
168
169fn resolve_device(spec: &str) -> Device {
170    if spec.eq_ignore_ascii_case("auto") || spec.is_empty() {
171        return crate::fastest_device();
172    }
173    match crate::parse_device(spec) {
174        Ok(d) if crate::is_available(d) => d,
175        _ => crate::fastest_device(),
176    }
177}
178
179/// Worker: receive a [`StageSpec`] from the coordinator (rank 0), resolve its
180/// weights via `resolve` (`uri -> f32`), pick the directed device, and compile.
181/// This is the generic worker's setup — the same code runs any model's stage.
182///
183/// `resolve` is where the caller plugs in weight loading (GGUF / safetensors /
184/// HF), keeping rlx core model-agnostic. It runs on the worker, so the weights
185/// never cross the wire.
186pub fn recv_stage<F>(group: &ProcessGroup, mut fallback: F) -> Result<WorkerStage, String>
187where
188    F: FnMut(&str) -> Vec<f32>,
189{
190    let bytes = group
191        .transport()
192        .recv_bytes(0, TAG_SPEC)
193        .map_err(|e| format!("recv_stage: {e}"))?;
194    let spec: StageSpec = serde_json::from_slice(&bytes).map_err(|e| format!("StageSpec: {e}"))?;
195    let device = resolve_device(&spec.device);
196    let nodes = spec.graph.len();
197    let mut compiled = Session::new(device).compile(spec.graph);
198    // Parse-once, serve-many: one `WeightCache` across the stage's weights, so
199    // many tensors from the same GGUF are read after a single parse.
200    let mut cache = WeightCache::new();
201    for w in &spec.weights {
202        if w.packed {
203            // Quantized-on-device: raw packed bytes → U8 param. The graph's
204            // DequantMatMul decodes at matmul time (native quant memory).
205            let bytes = cache
206                .bytes(&w.uri)
207                .map_err(|e| format!("weight {}: {e}", w.name))?;
208            compiled.set_param_typed(&w.name, &bytes, rlx_ir::DType::U8);
209        } else {
210            // f32: built-in gguf:// / safetensors:// / file:// (cached), else
211            // the caller's `fallback` resolver (custom schemes like seed://).
212            let vals = cache.f32(&w.uri).unwrap_or_else(|_| fallback(&w.uri));
213            compiled.set_param(&w.name, &vals);
214        }
215    }
216    Ok(WorkerStage {
217        device,
218        nodes,
219        input: spec.input,
220        compiled,
221    })
222}
223
224/// Worker convenience: set up the stage, then run one activation cycle —
225/// receive from the previous rank (or an external feed for rank 1), run, and
226/// forward to the next rank (or back to the coordinator if last). Returns the
227/// device it ran on. For a serving loop, call [`recv_stage`] once and drive
228/// [`WorkerStage::run`] + [`recv_activation`]/[`send_activation`] yourself.
229pub fn serve_stage<F>(group: &ProcessGroup, resolve: F) -> Result<Device, String>
230where
231    F: FnMut(&str) -> Vec<f32>,
232{
233    let n = group.world_size();
234    let rank = group.rank();
235    let mut stage = recv_stage(group, resolve)?;
236    let input = recv_activation(group, rank - 1)?;
237    let out = stage.run(&input);
238    let next = if rank + 1 < n { rank + 1 } else { 0 };
239    send_activation(group, next, &out)?;
240    Ok(stage.device)
241}
242
243/// Same as [`serve_stage`] but with no custom resolver — a worker binary that
244/// serves GGUF / safetensors / raw-f32 models out of the box (`recv_stage`'s
245/// built-in [`WeightCache`] handles those schemes; anything else warns).
246pub fn serve_stage_uri(group: &ProcessGroup) -> Result<Device, String> {
247    serve_stage(group, |uri| {
248        eprintln!("rlx dist: no resolver for weight URI [{uri}]");
249        Vec::new()
250    })
251}
252
253// ── weight source URIs ─────────────────────────────────────────────────────
254//
255// A model-agnostic resolver for the common on-disk formats, so the generic
256// worker can load its own shard from a path the coordinator names — weights
257// stay node-local, only the URI crosses the wire.
258
259fn split_frag(rest: &str) -> Result<(&str, &str), String> {
260    rest.split_once('#')
261        .ok_or_else(|| format!("weight URI needs a #<tensor> fragment: {rest}"))
262}
263
264/// Resolve a weight tensor to f32 from its source URI. Supported schemes:
265///   `gguf://<path>#<tensor>`         — dequantize a GGUF tensor (any K-quant)
266///   `safetensors://<path>#<tensor>`  — read a safetensors tensor (F32/F16/BF16)
267///   `file://<path>`                  — raw little-endian f32 array
268///
269/// This is model-agnostic (formats, not models); callers can still pass their
270/// own closure to [`recv_stage`]/[`serve_stage`] for other schemes.
271pub fn resolve_weight_uri(uri: &str) -> Result<Vec<f32>, String> {
272    if let Some(rest) = uri.strip_prefix("gguf://") {
273        let (path, tensor) = split_frag(rest)?;
274        let mut f = std::fs::File::open(path).map_err(|e| format!("open {path}: {e}"))?;
275        let gguf = rlx_gguf::GgufFile::from_reader(&mut f).map_err(|e| e.to_string())?;
276        let (data, _dims) = gguf.dequant_f32(tensor).map_err(|e| e.to_string())?;
277        Ok(data)
278    } else if let Some(rest) = uri.strip_prefix("safetensors://") {
279        let (path, tensor) = split_frag(rest)?;
280        let bytes = std::fs::read(path).map_err(|e| format!("read {path}: {e}"))?;
281        read_safetensors_f32(&bytes, tensor)
282    } else if let Some(path) = uri.strip_prefix("file://") {
283        let b = std::fs::read(path).map_err(|e| format!("read {path}: {e}"))?;
284        Ok(b.chunks_exact(4)
285            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
286            .collect())
287    } else {
288        Err(format!("unsupported weight URI scheme: {uri}"))
289    }
290}
291
292/// Load the **quantized bytes** of a weight as-is (no dequant), for the
293/// on-device-quant path. `gguf://<path>#<tensor>` returns the packed block
294/// bytes; `file://<path>` returns the raw file. Feed to `set_param_typed(_, _,
295/// U8)` behind a `DequantMatMul` graph.
296pub fn resolve_weight_bytes(uri: &str) -> Result<Vec<u8>, String> {
297    if let Some(rest) = uri.strip_prefix("gguf://") {
298        let (path, tensor) = split_frag(rest)?;
299        let mut f = std::fs::File::open(path).map_err(|e| format!("open {path}: {e}"))?;
300        let gguf = rlx_gguf::GgufFile::from_reader(&mut f).map_err(|e| e.to_string())?;
301        let t = gguf
302            .get(tensor)
303            .ok_or_else(|| format!("tensor not found: {tensor}"))?;
304        Ok(gguf.tensor_bytes(t).map_err(|e| e.to_string())?.to_vec())
305    } else if let Some(path) = uri.strip_prefix("file://") {
306        std::fs::read(path).map_err(|e| format!("read {path}: {e}"))
307    } else {
308        Err(format!("packed load unsupported for scheme: {uri}"))
309    }
310}
311
312/// Parse-once, serve-many weight cache. A GGUF file is parsed a single time and
313/// then serves any number of its tensors (f32 or packed) — the right shape for
314/// a worker whose stage pulls many tensors (q/k/v/o/ffn) from one model file.
315/// Created per stage inside [`recv_stage`]; also usable standalone.
316#[derive(Default)]
317pub struct WeightCache {
318    gguf: HashMap<String, rlx_gguf::GgufFile>,
319}
320
321impl WeightCache {
322    pub fn new() -> Self {
323        Self::default()
324    }
325
326    fn gguf_file(&mut self, path: &str) -> Result<&rlx_gguf::GgufFile, String> {
327        if !self.gguf.contains_key(path) {
328            let mut f = std::fs::File::open(path).map_err(|e| format!("open {path}: {e}"))?;
329            let g = rlx_gguf::GgufFile::from_reader(&mut f).map_err(|e| e.to_string())?;
330            self.gguf.insert(path.to_string(), g);
331        }
332        Ok(&self.gguf[path])
333    }
334
335    /// Dequantized f32 for `uri`, reusing the parsed file for `gguf://`.
336    pub fn f32(&mut self, uri: &str) -> Result<Vec<f32>, String> {
337        if let Some(rest) = uri.strip_prefix("gguf://") {
338            let (path, tensor) = split_frag(rest)?;
339            let (data, _dims) = self
340                .gguf_file(path)?
341                .dequant_f32(tensor)
342                .map_err(|e| e.to_string())?;
343            Ok(data)
344        } else if uri.starts_with("safetensors://") || uri.starts_with("file://") {
345            resolve_weight_uri(uri) // header parse is cheap / file is the tensor
346        } else {
347            Err(format!("unsupported weight URI scheme: {uri}"))
348        }
349    }
350
351    /// Packed (quantized) bytes for `uri`, reusing the parsed file for `gguf://`.
352    pub fn bytes(&mut self, uri: &str) -> Result<Vec<u8>, String> {
353        if let Some(rest) = uri.strip_prefix("gguf://") {
354            let (path, tensor) = split_frag(rest)?;
355            let g = self.gguf_file(path)?;
356            let t = g
357                .get(tensor)
358                .ok_or_else(|| format!("tensor not found: {tensor}"))?;
359            Ok(g.tensor_bytes(t).map_err(|e| e.to_string())?.to_vec())
360        } else {
361            resolve_weight_bytes(uri)
362        }
363    }
364}
365
366/// Built-in resolver for [`serve_stage_uri`]: [`resolve_weight_uri`], logging
367/// and returning empty on failure so a missing shard surfaces loudly rather
368/// than panicking mid-collective.
369pub fn uri_resolver(uri: &str) -> Vec<f32> {
370    match resolve_weight_uri(uri) {
371        Ok(v) => v,
372        Err(e) => {
373            eprintln!("rlx dist: weight resolve failed [{uri}]: {e}");
374            Vec::new()
375        }
376    }
377}
378
379// bf16 is the top 16 bits of an f32.
380fn bf16_to_f32(b: u16) -> f32 {
381    f32::from_bits((b as u32) << 16)
382}
383
384// IEEE-754 half → f32 (handles subnormal / inf / nan).
385fn f16_to_f32(h: u16) -> f32 {
386    let sign = (h >> 15) & 1;
387    let exp = (h >> 10) & 0x1f;
388    let mant = h & 0x3ff;
389    let v = match exp {
390        0 => (mant as f32) * 2f32.powi(-24), // 0 or subnormal
391        0x1f if mant == 0 => f32::INFINITY,
392        0x1f => f32::NAN,
393        _ => (1.0 + mant as f32 / 1024.0) * 2f32.powi(exp as i32 - 15),
394    };
395    if sign == 1 { -v } else { v }
396}
397
398/// Minimal safetensors reader: `[u64 header_len][JSON header][data]`; returns
399/// the named tensor as f32 (F32 / F16 / BF16). No external crate needed.
400fn read_safetensors_f32(buf: &[u8], name: &str) -> Result<Vec<f32>, String> {
401    if buf.len() < 8 {
402        return Err("safetensors file too small".into());
403    }
404    let hlen = u64::from_le_bytes(buf[..8].try_into().unwrap()) as usize;
405    let header = buf
406        .get(8..8 + hlen)
407        .ok_or("safetensors: truncated header")?;
408    let data = &buf[8 + hlen..];
409    let v: serde_json::Value = serde_json::from_slice(header).map_err(|e| e.to_string())?;
410    let t = v
411        .get(name)
412        .ok_or_else(|| format!("tensor not found: {name}"))?;
413    let dtype = t.get("dtype").and_then(|d| d.as_str()).ok_or("no dtype")?;
414    let off = t
415        .get("data_offsets")
416        .and_then(|o| o.as_array())
417        .ok_or("no data_offsets")?;
418    let start = off[0].as_u64().ok_or("bad data_offsets")? as usize;
419    let end = off[1].as_u64().ok_or("bad data_offsets")? as usize;
420    let raw = data
421        .get(start..end)
422        .ok_or("safetensors: data_offsets out of range")?;
423    Ok(match dtype {
424        "F32" => raw
425            .chunks_exact(4)
426            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
427            .collect(),
428        "F16" => raw
429            .chunks_exact(2)
430            .map(|c| f16_to_f32(u16::from_le_bytes([c[0], c[1]])))
431            .collect(),
432        "BF16" => raw
433            .chunks_exact(2)
434            .map(|c| bf16_to_f32(u16::from_le_bytes([c[0], c[1]])))
435            .collect(),
436        other => return Err(format!("unsupported safetensors dtype: {other}")),
437    })
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn resolve_safetensors_f32() {
446        let vals = [1.0f32, 2.0, -3.5, 4.25];
447        let data: Vec<u8> = vals.iter().flat_map(|v| v.to_le_bytes()).collect();
448        let header = format!(
449            r#"{{"w":{{"dtype":"F32","shape":[4],"data_offsets":[0,{}]}}}}"#,
450            data.len()
451        );
452        let mut buf = (header.len() as u64).to_le_bytes().to_vec();
453        buf.extend_from_slice(header.as_bytes());
454        buf.extend_from_slice(&data);
455        let path =
456            std::env::temp_dir().join(format!("rlx_dist_{}.safetensors", std::process::id()));
457        std::fs::write(&path, &buf).unwrap();
458        let got = resolve_weight_uri(&format!("safetensors://{}#w", path.display())).unwrap();
459        std::fs::remove_file(&path).ok();
460        assert_eq!(got, vals);
461    }
462
463    #[test]
464    fn resolve_gguf_f32() {
465        use rlx_gguf::{GgmlType, GgufWriter};
466        let vals = [1.0f32, 2.0, -3.5, 4.25];
467        let bytes: Vec<u8> = vals.iter().flat_map(|v| v.to_le_bytes()).collect();
468        let mut w = GgufWriter::new();
469        w.add_tensor_bytes("w", vec![4], GgmlType::F32, bytes)
470            .unwrap();
471        let path = std::env::temp_dir().join(format!("rlx_dist_{}.gguf", std::process::id()));
472        w.write_to_path(&path).unwrap();
473        let got = resolve_weight_uri(&format!("gguf://{}#w", path.display())).unwrap();
474        std::fs::remove_file(&path).ok();
475        assert_eq!(got, vals);
476    }
477
478    #[test]
479    fn weight_cache_serves_many_tensors_from_one_file() {
480        use rlx_gguf::{GgmlType, GgufWriter};
481        let a = [1.0f32, 2.0, 3.0, 4.0];
482        let b = [10.0f32, 20.0];
483        let mut w = GgufWriter::new();
484        w.add_tensor_bytes(
485            "a",
486            vec![4],
487            GgmlType::F32,
488            a.iter().flat_map(|v| v.to_le_bytes()).collect(),
489        )
490        .unwrap();
491        w.add_tensor_bytes(
492            "b",
493            vec![2],
494            GgmlType::F32,
495            b.iter().flat_map(|v| v.to_le_bytes()).collect(),
496        )
497        .unwrap();
498        let path = std::env::temp_dir().join(format!("rlx_cache_{}.gguf", std::process::id()));
499        w.write_to_path(&path).unwrap();
500        let p = path.display();
501
502        // One cache, two tensors from the same parsed file.
503        let mut cache = WeightCache::new();
504        assert_eq!(cache.f32(&format!("gguf://{p}#a")).unwrap(), a);
505        assert_eq!(cache.f32(&format!("gguf://{p}#b")).unwrap(), b);
506        // packed bytes of the F32 tensor are exactly its little-endian f32 bytes.
507        let raw = cache.bytes(&format!("gguf://{p}#a")).unwrap();
508        let raw_f32: Vec<f32> = raw
509            .chunks_exact(4)
510            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
511            .collect();
512        assert_eq!(raw_f32, a);
513        std::fs::remove_file(&path).ok();
514    }
515
516    #[test]
517    fn dequant_matmul_gguf_end_to_end() {
518        // Quantize a weight to Q8_0, feed the PACKED bytes as a U8 param behind
519        // a DequantMatMul graph, and check the output matches dequant-then-
520        // matmul — the quantized-on-device path, weight at native quant memory.
521        use rlx_ir::quant::QuantScheme;
522        use rlx_ir::{DType, Graph, Shape};
523
524        let (m, k, n) = (2usize, 32usize, 4usize); // K multiple of 32 for Q8_0
525        let w_floats: Vec<f32> = (0..n * k).map(|i| ((i % 17) as f32 - 8.0) * 0.1).collect();
526        let packed = rlx_gguf::quantize(&w_floats, rlx_gguf::GgmlType::Q8_0).unwrap();
527        let x: Vec<f32> = (0..m * k).map(|i| ((i % 13) as f32) * 0.05).collect();
528
529        let mut g = Graph::new("dq");
530        let xin = g.input("x", Shape::new(&[m, k], DType::F32));
531        let wp = g.param("W", Shape::new(&[packed.len()], DType::U8));
532        let out = g.dequant_matmul_packed(
533            xin,
534            wp,
535            QuantScheme::GgufQ8_0,
536            Shape::new(&[m, n], DType::F32),
537        );
538        g.set_outputs(vec![out]);
539
540        let mut c = Session::new(Device::Cpu).compile(g);
541        c.set_param_typed("W", &packed, DType::U8);
542        let got = c.run(&[("x", x.as_slice())]).into_iter().next().unwrap();
543
544        // Reference: dequant the SAME bytes, then BT-matmul (W is [n, k]).
545        let w = rlx_gguf::dequant_q8_0(&packed, n * k).unwrap();
546        let mut expect = vec![0f32; m * n];
547        for i in 0..m {
548            for j in 0..n {
549                let mut acc = 0f32;
550                for c2 in 0..k {
551                    acc += x[i * k + c2] * w[j * k + c2];
552                }
553                expect[i * n + j] = acc;
554            }
555        }
556        let err = got
557            .iter()
558            .zip(&expect)
559            .map(|(a, b)| (a - b).abs())
560            .fold(0f32, f32::max);
561        assert!(err < 1e-3, "max_err={err} got={got:?} expect={expect:?}");
562    }
563}