Skip to main content

ferric_core/
lib.rs

1//! Ferric core — L0 (portable device/fabric abstraction) + L1 (kernel dispatch), on `wgpu`.
2//!
3//! `wgpu` gives us one API over WebGPU (browser), Vulkan, Metal, DX12, and GL. This crate wraps it
4//! into a tiny `Context` and the first real kernel (matmul), written ONCE in WGSL and run on any
5//! fabric. The same source compiles to native (Metal/Vulkan/DX12) and to `wasm32` for the browser.
6//! Numerics are validated against a plain-Rust CPU reference so "runs everywhere" also means
7//! "computes the same everywhere".
8
9use std::borrow::Cow;
10
11pub type Result<T> = std::result::Result<T, String>;
12
13mod kernels;
14pub use kernels::cpu; // CPU references for validation
15pub mod demo; // a small deterministic Llama-style LM, same code native + browser
16
17/// A compute context bound to one GPU adapter on whatever fabric is available.
18pub struct Context {
19    pub device: wgpu::Device,
20    pub queue: wgpu::Queue,
21    pub backend: wgpu::Backend,
22    pub adapter_name: String,
23}
24
25/// An f32 tensor living in GPU memory. Ops chain Tensor→Tensor with no host readback until `to_vec`,
26/// so a whole model runs on-device — this is the L2/L3 substrate (the graph executor works on these).
27pub struct Tensor {
28    pub buf: wgpu::Buffer,
29    pub shape: Vec<usize>,
30}
31impl Tensor {
32    pub fn len(&self) -> usize { self.shape.iter().product() }
33    pub fn is_empty(&self) -> bool { self.len() == 0 }
34}
35
36impl Context {
37    /// Acquire the best available compute device on this fabric (native GPU or browser WebGPU).
38    pub async fn new() -> Result<Self> {
39        let instance = wgpu::Instance::default();
40        let adapter = instance
41            .request_adapter(&wgpu::RequestAdapterOptions {
42                power_preference: wgpu::PowerPreference::HighPerformance,
43                ..Default::default()
44            })
45            .await
46            .map_err(|e| format!("no compute adapter: {e:?}"))?;
47        let info = adapter.get_info();
48        let (device, queue) = adapter
49            .request_device(&wgpu::DeviceDescriptor {
50                label: Some("ferric"),
51                required_features: wgpu::Features::empty(),
52                // downlevel defaults keep us inside the WebGPU baseline → the same limits hold in a browser
53                required_limits: wgpu::Limits::downlevel_defaults(),
54                memory_hints: wgpu::MemoryHints::Performance,
55                ..Default::default()
56            })
57            .await
58            .map_err(|e| format!("no compute device: {e:?}"))?;
59        Ok(Self { device, queue, backend: info.backend, adapter_name: info.name })
60    }
61
62    /// Enumerate EVERY compute adapter present (all GPUs across all backends + software/CPU adapters),
63    /// so the scheduler can use all of them, not just one. Returns (name, backend, device_type).
64    #[cfg(not(target_arch = "wasm32"))]
65    pub async fn enumerate() -> Vec<(String, wgpu::Backend, wgpu::DeviceType)> {
66        let instance = wgpu::Instance::default();
67        instance.enumerate_adapters(wgpu::Backends::all()).await.iter()
68            .map(|a| { let i = a.get_info(); (i.name, i.backend, i.device_type) })
69            .collect()
70    }
71
72    /// Build a Context on a specific enumerated adapter (by index into `enumerate()`), so each GPU in
73    /// the machine can be its own device in the fabric.
74    #[cfg(not(target_arch = "wasm32"))]
75    pub async fn for_adapter(idx: usize) -> Result<Self> {
76        let instance = wgpu::Instance::default();
77        let adapters = instance.enumerate_adapters(wgpu::Backends::all()).await;
78        let adapter = adapters.into_iter().nth(idx).ok_or_else(|| format!("no adapter at index {idx}"))?;
79        let info = adapter.get_info();
80        let (device, queue) = adapter
81            .request_device(&wgpu::DeviceDescriptor {
82                label: Some("ferric"),
83                required_features: wgpu::Features::empty(),
84                required_limits: wgpu::Limits::downlevel_defaults(),
85                memory_hints: wgpu::MemoryHints::Performance,
86                ..Default::default()
87            })
88            .await
89            .map_err(|e| format!("no compute device: {e:?}"))?;
90        Ok(Self { device, queue, backend: info.backend, adapter_name: info.name })
91    }
92
93    pub(crate) fn storage(&self, label: &str, data: &[f32]) -> wgpu::Buffer {
94        use wgpu::util::DeviceExt;
95        self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
96            label: Some(label),
97            contents: bytemuck::cast_slice(data),
98            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
99        })
100    }
101    pub(crate) fn storage_u32(&self, label: &str, data: &[u32]) -> wgpu::Buffer {
102        use wgpu::util::DeviceExt;
103        self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
104            label: Some(label),
105            contents: bytemuck::cast_slice(data),
106            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
107        })
108    }
109    /// Copy a buffer into a fresh one of the same byte length (used for Reshape/Cast — data unchanged).
110    pub(crate) fn copy_buf(&self, src: &wgpu::Buffer, len: usize) -> wgpu::Buffer {
111        let dst = self.device.create_buffer(&wgpu::BufferDescriptor {
112            label: Some("dup"),
113            size: (len * 4) as u64,
114            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
115            mapped_at_creation: false,
116        });
117        let mut enc = self.device.create_command_encoder(&Default::default());
118        enc.copy_buffer_to_buffer(src, 0, &dst, 0, (len * 4) as u64);
119        self.queue.submit([enc.finish()]);
120        dst
121    }
122    pub(crate) fn uniform_u32(&self, label: &str, data: &[u32]) -> wgpu::Buffer {
123        use wgpu::util::DeviceExt;
124        self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
125            label: Some(label),
126            contents: bytemuck::cast_slice(data),
127            usage: wgpu::BufferUsages::UNIFORM,
128        })
129    }
130    /// An empty output storage buffer of `len` f32s (readable back via `readback`).
131    pub(crate) fn out_buffer(&self, len: usize) -> wgpu::Buffer {
132        self.device.create_buffer(&wgpu::BufferDescriptor {
133            label: Some("out"),
134            size: (len * 4) as u64,
135            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
136            mapped_at_creation: false,
137        })
138    }
139    /// Compile a WGSL compute pipeline (entry `main`, auto bind-group layout).
140    pub(crate) fn pipeline(&self, label: &str, wgsl: &str) -> wgpu::ComputePipeline {
141        let module = self.device.create_shader_module(wgpu::ShaderModuleDescriptor {
142            label: Some(label),
143            source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Owned(wgsl.to_string())),
144        });
145        self.device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
146            label: Some(label),
147            layout: None,
148            module: &module,
149            entry_point: Some("main"),
150            compilation_options: Default::default(),
151            cache: None,
152        })
153    }
154    /// Dispatch a compute pipeline over `binds` with `groups` workgroups (queue-ordered before readback).
155    pub(crate) fn dispatch(&self, pipeline: &wgpu::ComputePipeline, binds: &[&wgpu::Buffer], groups: (u32, u32, u32)) {
156        let entries: Vec<wgpu::BindGroupEntry> = binds.iter().enumerate()
157            .map(|(i, b)| wgpu::BindGroupEntry { binding: i as u32, resource: b.as_entire_binding() })
158            .collect();
159        let bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
160            label: Some("bg"),
161            layout: &pipeline.get_bind_group_layout(0),
162            entries: &entries,
163        });
164        let mut enc = self.device.create_command_encoder(&Default::default());
165        {
166            let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: None, timestamp_writes: None });
167            pass.set_pipeline(pipeline);
168            pass.set_bind_group(0, &bg, &[]);
169            pass.dispatch_workgroups(groups.0, groups.1, groups.2);
170        }
171        self.queue.submit([enc.finish()]);
172    }
173    /// Read `len` f32s back from a storage buffer (works native + wasm).
174    pub(crate) async fn readback(&self, buf: &wgpu::Buffer, len: usize) -> Result<Vec<f32>> {
175        let bytes = (len * 4) as u64;
176        let staging = self.device.create_buffer(&wgpu::BufferDescriptor {
177            label: Some("staging"),
178            size: bytes,
179            usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
180            mapped_at_creation: false,
181        });
182        let mut enc = self.device.create_command_encoder(&Default::default());
183        enc.copy_buffer_to_buffer(buf, 0, &staging, 0, bytes);
184        self.queue.submit([enc.finish()]);
185        let (tx, rx) = flume::bounded(1);
186        staging.slice(..).map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); });
187        let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
188        rx.recv_async().await.map_err(|e| format!("recv: {e:?}"))?.map_err(|e| format!("map: {e:?}"))?;
189        let data = staging.slice(..).get_mapped_range().map_err(|e| format!("map range: {e:?}"))?;
190        let out: Vec<f32> = bytemuck::cast_slice(&data).to_vec();
191        drop(data);
192        staging.unmap();
193        Ok(out)
194    }
195
196    /// C = A(m×k) · B(k×n), row-major, f32. One kernel, any fabric.
197    pub async fn matmul(&self, a: &[f32], b: &[f32], m: u32, k: u32, n: u32) -> Result<Vec<f32>> {
198        assert_eq!(a.len(), (m * k) as usize);
199        assert_eq!(b.len(), (k * n) as usize);
200        let out_len = (m * n) as usize;
201        let out_bytes = (out_len * 4) as u64;
202
203        let a_buf = self.storage("a", a);
204        let b_buf = self.storage("b", b);
205        let dims_buf = self.uniform_u32("dims", &[m, k, n, 0]);
206        let out_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
207            label: Some("out"),
208            size: out_bytes,
209            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
210            mapped_at_creation: false,
211        });
212
213        let shader = self.device.create_shader_module(wgpu::ShaderModuleDescriptor {
214            label: Some("matmul"),
215            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(MATMUL_WGSL)),
216        });
217        let pipeline = self.device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
218            label: Some("matmul"),
219            layout: None,
220            module: &shader,
221            entry_point: Some("main"),
222            compilation_options: Default::default(),
223            cache: None,
224        });
225        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
226            label: Some("matmul-bg"),
227            layout: &pipeline.get_bind_group_layout(0),
228            entries: &[
229                wgpu::BindGroupEntry { binding: 0, resource: a_buf.as_entire_binding() },
230                wgpu::BindGroupEntry { binding: 1, resource: b_buf.as_entire_binding() },
231                wgpu::BindGroupEntry { binding: 2, resource: out_buf.as_entire_binding() },
232                wgpu::BindGroupEntry { binding: 3, resource: dims_buf.as_entire_binding() },
233            ],
234        });
235
236        let mut enc = self.device.create_command_encoder(&Default::default());
237        {
238            let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
239                label: Some("matmul"),
240                timestamp_writes: None,
241            });
242            pass.set_pipeline(&pipeline);
243            pass.set_bind_group(0, &bind_group, &[]);
244            let gx = (m + 15) / 16;
245            let gy = (n + 15) / 16;
246            pass.dispatch_workgroups(gx, gy, 1);
247        }
248        // copy to a mappable staging buffer for readback
249        let staging = self.device.create_buffer(&wgpu::BufferDescriptor {
250            label: Some("staging"),
251            size: out_bytes,
252            usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
253            mapped_at_creation: false,
254        });
255        enc.copy_buffer_to_buffer(&out_buf, 0, &staging, 0, out_bytes);
256        self.queue.submit([enc.finish()]);
257
258        // async readback that works on native (poll blocks) and wasm (browser drives the queue)
259        let (tx, rx) = flume::bounded(1);
260        staging.slice(..).map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); });
261        let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
262        rx.recv_async().await.map_err(|e| format!("recv: {e:?}"))?.map_err(|e| format!("map: {e:?}"))?;
263        let data = staging.slice(..).get_mapped_range().map_err(|e| format!("map range: {e:?}"))?;
264        let out: Vec<f32> = bytemuck::cast_slice(&data).to_vec();
265        drop(data);
266        staging.unmap();
267        Ok(out)
268    }
269}
270
271pub(crate) const MATMUL_WGSL: &str = r#"
272@group(0) @binding(0) var<storage, read>       a: array<f32>;
273@group(0) @binding(1) var<storage, read>       b: array<f32>;
274@group(0) @binding(2) var<storage, read_write> out: array<f32>;
275@group(0) @binding(3) var<uniform>             dims: vec4<u32>; // m, k, n, _
276
277@compute @workgroup_size(16, 16, 1)
278fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
279    let m = dims.x; let k = dims.y; let n = dims.z;
280    let row = gid.x; let col = gid.y;
281    if (row >= m || col >= n) { return; }
282    var acc: f32 = 0.0;
283    for (var i: u32 = 0u; i < k; i = i + 1u) {
284        acc = acc + a[row * k + i] * b[i * n + col];
285    }
286    out[row * n + col] = acc;
287}
288"#;
289
290/// Plain-Rust CPU reference (the source of truth for validation).
291pub fn matmul_cpu(a: &[f32], b: &[f32], m: usize, k: usize, n: usize) -> Vec<f32> {
292    let mut c = vec![0.0f32; m * n];
293    for row in 0..m {
294        for col in 0..n {
295            let mut acc = 0.0f32;
296            for i in 0..k {
297                acc += a[row * k + i] * b[i * n + col];
298            }
299            c[row * n + col] = acc;
300        }
301    }
302    c
303}
304
305/// Max absolute difference between two equal-length vectors.
306pub fn max_abs_diff(x: &[f32], y: &[f32]) -> f32 {
307    x.iter().zip(y).map(|(a, b)| (a - b).abs()).fold(0.0, f32::max)
308}