Skip to main content

ferric_tensor/
sched.rs

1//! L7 — the heterogeneous scheduler. Presents whatever compute is present as one fabric and
2//! partitions work across it. Here the devices are a **GPU** (wgpu) and the **CPU** (plain Rust) —
3//! two genuinely different fabrics on any machine. Data crosses device boundaries as host buffers,
4//! which is exactly the format that would serialize to a cloud node or `postMessage` to a browser
5//! worker, so the same `Device`/`Fabric` abstraction extends to cloud+local+browser.
6//!
7//! Two scheduling modes, both validated to equal single-device execution:
8//!   • data-parallel — split a batched matmul across devices proportional to measured throughput,
9//!     run concurrently (GPU on the main thread, CPU on a worker), concatenate.
10//!   • pipeline — assign a model's layers to devices round-robin; activations hop across boundaries.
11
12use crate::Tensor;
13use ferric_core::Context;
14use std::io::{Read, Write};
15use std::net::{TcpListener, TcpStream};
16use std::sync::Arc;
17use std::time::Instant;
18
19/// A compute device in the fabric.
20pub enum Device {
21    Gpu(Arc<Context>),
22    Cpu,
23    /// A remote worker reached over TCP — a cloud node, or (with a WS bridge) a browser tab. Work
24    /// crosses the boundary as host buffers, exactly like the GPU/CPU hop, so it's the same fabric.
25    Remote(String),
26    /// A browser tab reached over a WebSocket, computing on the tab's WebGPU. Same op frames as
27    /// Remote, different transport — this is the fabric physically spanning cloud+local+browser.
28    BrowserWorker(Arc<crate::ws::WsConn>),
29    /// An NPU reached through an execution-provider backend (CoreML/ANE, DirectML-QNN, OpenVINO,
30    /// WebNN). WebGPU cannot target an NPU, so this only exists when a real backend is plugged in —
31    /// it never falls back to the GPU/CPU silently.
32    Npu(Arc<dyn NpuBackend>),
33}
34
35/// An NPU execution-provider backend. Implement over CoreML (Apple ANE), DirectML/QNN, OpenVINO, or
36/// WebNN to make that NPU a real device in the fabric. Ferric ships the abstraction + detection; the
37/// platform binding is the EP to wire — kept honest, no fake NPU dispatch on the GPU.
38pub trait NpuBackend: Send + Sync {
39    fn name(&self) -> String;
40    fn bmm(&self, a: &[f32], b: &[f32], batch: usize, m: usize, k: usize, n: usize) -> Vec<f32>;
41    fn linear_relu(&self, x: &[f32], rows: usize, w: &[f32], in_: usize, out: usize) -> Vec<f32> {
42        self.bmm(x, w, 1, rows, in_, out).iter().map(|v| v.max(0.0)).collect()
43    }
44}
45
46impl Device {
47    pub fn name(&self) -> String {
48        match self {
49            Device::Gpu(c) => format!("GPU:{}", c.adapter_name),
50            Device::Cpu => "CPU".into(),
51            Device::Remote(addr) => format!("Remote:{addr}"),
52            Device::BrowserWorker(_) => "BrowserWorker".into(),
53            Device::Npu(b) => format!("NPU:{}", b.name()),
54        }
55    }
56
57    /// Batched matmul [batch,m,k] · [k,n] → [batch,m,n] on host data.
58    pub fn bmm(&self, a: &[f32], b: &[f32], batch: usize, m: usize, k: usize, n: usize) -> Vec<f32> {
59        match self {
60            Device::Gpu(ctx) => {
61                let ta = Tensor::from_vec(ctx, a, &[batch, m, k]);
62                let tb = Tensor::from_vec(ctx, b, &[k, n]);
63                pollster::block_on(ta.matmul(&tb).to_vec())
64            }
65            Device::Remote(addr) => remote_call(addr, 0, &[batch as u32, m as u32, k as u32, n as u32], a, b),
66            Device::BrowserWorker(conn) => browser_call(conn, 0, &[batch as u32, m as u32, k as u32, n as u32], a, b),
67            Device::Npu(back) => back.bmm(a, b, batch, m, k, n),
68            Device::Cpu => cpu_bmm(a, b, batch, m, k, n), // parallel across all cores
69        }
70    }
71
72    /// One MLP layer: relu(x[rows,in] · w[in,out]).
73    pub fn linear_relu(&self, x: &[f32], rows: usize, w: &[f32], in_: usize, out: usize) -> Vec<f32> {
74        match self {
75            Device::Gpu(ctx) => {
76                let tx = Tensor::from_vec(ctx, x, &[rows, in_]);
77                let tw = Tensor::from_vec(ctx, w, &[in_, out]);
78                pollster::block_on(tx.matmul(&tw).relu().to_vec())
79            }
80            Device::Remote(addr) => remote_call(addr, 1, &[rows as u32, in_ as u32, out as u32, 0], x, w),
81            Device::BrowserWorker(conn) => browser_call(conn, 1, &[rows as u32, in_ as u32, out as u32, 0], x, w),
82            Device::Npu(back) => back.linear_relu(x, rows, w, in_, out),
83            Device::Cpu => cpu_bmm(x, w, 1, rows, in_, out).iter().map(|v| v.max(0.0)).collect(),
84        }
85    }
86}
87
88/// Multi-threaded CPU batched matmul — splits the batch across all available cores.
89fn cpu_bmm(a: &[f32], b: &[f32], batch: usize, m: usize, k: usize, n: usize) -> Vec<f32> {
90    let mut out = vec![0.0f32; batch * m * n];
91    let threads = std::thread::available_parallelism().map(|p| p.get()).unwrap_or(1).min(batch.max(1));
92    let chunk = batch.div_ceil(threads.max(1));
93    std::thread::scope(|s| {
94        for (ci, slab) in out.chunks_mut(chunk * m * n).enumerate() {
95            let lo = ci * chunk;
96            s.spawn(move || {
97                for bt_local in 0..slab.len() / (m * n) {
98                    let bt = lo + bt_local;
99                    for i in 0..m {
100                        for j in 0..n {
101                            let mut acc = 0.0;
102                            for l in 0..k { acc += a[bt * m * k + i * k + l] * b[l * n + j]; }
103                            slab[bt_local * m * n + i * n + j] = acc;
104                        }
105                    }
106                }
107            });
108        }
109    });
110    out
111}
112
113/// What an NPU probe found. Honest: `dispatchable` is true only when a real NPU backend is wired.
114/// WebGPU/wgpu CANNOT target an NPU — ANE/Intel/AMD/Qualcomm NPUs need CoreML/DirectML-QNN/OpenVINO/WebNN.
115pub struct NpuInfo {
116    pub present: bool,
117    pub name: String,
118    pub reachable_via: String,
119    pub dispatchable: bool,
120}
121
122/// Probe the platform for an NPU — reports presence + how it's reachable, never that we run on it.
123pub fn probe_npu() -> NpuInfo {
124    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
125    { return NpuInfo { present: true, name: "Apple Neural Engine (ANE)".into(), reachable_via: "CoreML".into(), dispatchable: false }; }
126    #[cfg(target_os = "windows")]
127    { return NpuInfo { present: false, name: "Windows NPU (if present)".into(), reachable_via: "DirectML / QNN / OpenVINO EP".into(), dispatchable: false }; }
128    #[allow(unreachable_code)]
129    NpuInfo { present: false, name: "none".into(), reachable_via: "n/a".into(), dispatchable: false }
130}
131
132/// Enumerate a Device for EVERY GPU adapter present (all backends) + the CPU, and probe the NPU.
133/// "Use CPU/GPU/NPU as available": every GPU is a device, CPU is always a device (all cores), NPU is
134/// reported (added as a compute device only when a backend is wired — never faked).
135#[cfg(not(target_arch = "wasm32"))]
136pub async fn detect_devices() -> (Vec<Device>, NpuInfo) {
137    let mut devices = Vec::new();
138    for (idx, (_name, _backend, dt)) in Context::enumerate().await.into_iter().enumerate() {
139        if dt != wgpu::DeviceType::Cpu {
140            if let Ok(ctx) = Context::for_adapter(idx).await { devices.push(Device::Gpu(Arc::new(ctx))); }
141        }
142    }
143    devices.push(Device::Cpu);
144    (devices, probe_npu())
145}
146
147/// The fabric: a set of devices + a scheduler over them.
148pub struct Fabric {
149    pub devices: Vec<Device>,
150}
151
152impl Fabric {
153    pub fn new(devices: Vec<Device>) -> Fabric { Fabric { devices } }
154
155    /// Measure each device's throughput on a probe matmul → relative weights (sum = 1).
156    pub fn probe(&self) -> Vec<f32> {
157        let (batch, m, k, n) = (64usize, 32, 64, 32);
158        let a = vec![0.01f32; batch * m * k];
159        let b = vec![0.02f32; k * n];
160        let mut rates = vec![];
161        for d in &self.devices {
162            let t0 = Instant::now();
163            let _ = d.bmm(&a, &b, batch, m, k, n);
164            let secs = t0.elapsed().as_secs_f32().max(1e-6);
165            rates.push((batch * m * k * n) as f32 / secs); // ~flops/s
166        }
167        let sum: f32 = rates.iter().sum();
168        rates.iter().map(|r| r / sum).collect()
169    }
170
171    /// Data-parallel: split the batch across devices by `weights`, run concurrently, concatenate.
172    pub fn data_parallel_bmm(&self, a: &[f32], b: &[f32], batch: usize, m: usize, k: usize, n: usize, weights: &[f32]) -> (Vec<f32>, Vec<usize>) {
173        // assign a contiguous batch range to each device, sized by weight
174        let mut counts: Vec<usize> = weights.iter().map(|w| (w * batch as f32).round() as usize).collect();
175        let assigned: isize = counts.iter().map(|&c| c as isize).sum();
176        counts[0] = (counts[0] as isize + (batch as isize - assigned)).max(0) as usize; // reconcile rounding
177        let mut ranges = vec![]; let mut off = 0;
178        for &c in &counts { ranges.push((off, off + c)); off += c; }
179
180        let mut out = vec![0.0f32; batch * m * n];
181        std::thread::scope(|s| {
182            let mut handles = vec![];
183            for (di, &(lo, hi)) in ranges.iter().enumerate().skip(1) {
184                if hi <= lo { continue; }
185                let dev = &self.devices[di];
186                let aslice = &a[lo * m * k..hi * m * k];
187                handles.push((di, lo, hi, s.spawn(move || dev.bmm(aslice, b, hi - lo, m, k, n))));
188            }
189            // device 0 runs on this thread (overlaps the workers)
190            let (lo0, hi0) = ranges[0];
191            if hi0 > lo0 {
192                let r = self.devices[0].bmm(&a[lo0 * m * k..hi0 * m * k], b, hi0 - lo0, m, k, n);
193                out[lo0 * m * n..hi0 * m * n].copy_from_slice(&r);
194            }
195            for (_di, lo, hi, h) in handles {
196                let r = h.join().unwrap();
197                out[lo * m * n..hi * m * n].copy_from_slice(&r);
198            }
199        });
200        (out, counts)
201    }
202
203    /// Pipeline: run an MLP whose layers are assigned to devices round-robin; activations cross
204    /// device boundaries as host buffers. `layers` = (weight[in*out], in, out).
205    pub fn pipeline_mlp(&self, x: &[f32], rows: usize, layers: &[(Vec<f32>, usize, usize)]) -> (Vec<f32>, Vec<String>) {
206        let mut act = x.to_vec();
207        let mut trace = vec![];
208        for (li, (w, in_, out)) in layers.iter().enumerate() {
209            let dev = &self.devices[li % self.devices.len()];
210            act = dev.linear_relu(&act, rows, w, *in_, *out);
211            trace.push(dev.name());
212        }
213        (act, trace)
214    }
215}
216
217// ---------- Remote transport: a tiny binary op-server over TCP ----------
218// Request:  op:u8 · dims[4]:u32 · lenA:u32 · A(f32 LE) · lenB:u32 · B(f32 LE)
219// Response: len:u32 · result(f32 LE)
220// The same frames would ride a WebSocket to a browser tab computing on WebGPU (Device::BrowserWorker).
221
222fn wr_u32(v: &mut Vec<u8>, x: u32) { v.extend_from_slice(&x.to_le_bytes()); }
223fn wr_f32s(v: &mut Vec<u8>, f: &[f32]) { wr_u32(v, f.len() as u32); v.extend_from_slice(bytemuck::cast_slice(f)); }
224fn rd_exact(s: &mut impl Read, n: usize) -> std::io::Result<Vec<u8>> {
225    let mut b = vec![0u8; n];
226    s.read_exact(&mut b)?;
227    Ok(b)
228}
229fn rd_u32(s: &mut impl Read) -> std::io::Result<u32> {
230    Ok(u32::from_le_bytes(rd_exact(s, 4)?.try_into().unwrap()))
231}
232fn rd_f32s(s: &mut impl Read) -> std::io::Result<Vec<f32>> {
233    let n = rd_u32(s)? as usize;
234    Ok(bytemuck::cast_slice(&rd_exact(s, n * 4)?).to_vec())
235}
236
237/// Build an op request frame (op · dims · A · B) — shared by the TCP and WebSocket transports.
238fn op_frame(op: u8, dims: &[u32; 4], a: &[f32], b: &[f32]) -> Vec<u8> {
239    let mut req = vec![op];
240    for &d in dims { wr_u32(&mut req, d); }
241    wr_f32s(&mut req, a);
242    wr_f32s(&mut req, b);
243    req
244}
245
246/// Client side: dispatch one op to a browser tab over the WebSocket; the tab computes on WebGPU.
247fn browser_call(conn: &crate::ws::WsConn, op: u8, dims: &[u32; 4], a: &[f32], b: &[f32]) -> Vec<f32> {
248    conn.send(&op_frame(op, dims, a, b)).expect("browser worker send");
249    let resp = conn.recv().expect("browser worker response");
250    bytemuck::cast_slice(&resp).to_vec()
251}
252
253/// Client side: send one op to a remote worker and block for the result.
254fn remote_call(addr: &str, op: u8, dims: &[u32; 4], a: &[f32], b: &[f32]) -> Vec<f32> {
255    let mut req = vec![op];
256    for &d in dims { wr_u32(&mut req, d); }
257    wr_f32s(&mut req, a);
258    wr_f32s(&mut req, b);
259    let mut s = TcpStream::connect(addr).expect("remote worker unreachable");
260    s.write_all(&req).unwrap();
261    rd_f32s(&mut s).expect("remote worker response")
262}
263
264/// Handle one request on the worker, computing with `backend` (a local GPU or CPU device).
265fn serve_one(s: &mut TcpStream, backend: &Device) -> std::io::Result<()> {
266    let op = rd_exact(s, 1)?[0];
267    let dims = [rd_u32(s)?, rd_u32(s)?, rd_u32(s)?, rd_u32(s)?];
268    let a = rd_f32s(s)?;
269    let b = rd_f32s(s)?;
270    let out = match op {
271        0 => backend.bmm(&a, &b, dims[0] as usize, dims[1] as usize, dims[2] as usize, dims[3] as usize),
272        _ => backend.linear_relu(&a, dims[0] as usize, &b, dims[1] as usize, dims[2] as usize),
273    };
274    let mut resp = Vec::new();
275    wr_f32s(&mut resp, &out);
276    s.write_all(&resp)
277}
278
279/// Spin up a worker on an ephemeral localhost port, backed by `backend`; returns its address.
280/// (Localhost stands in for a cloud node — same wire path across a real network.)
281pub fn spawn_worker(backend: Device) -> String {
282    let listener = TcpListener::bind("127.0.0.1:0").expect("bind worker");
283    let addr = listener.local_addr().unwrap().to_string();
284    std::thread::spawn(move || {
285        for stream in listener.incoming() {
286            if let Ok(mut s) = stream { let _ = serve_one(&mut s, &backend); }
287        }
288    });
289    addr
290}