Skip to main content

ftts_kernels/
team.rs

1//! KernelTeam v0: the persistent worker pool for int8 GEMV/GEMM output-column partitions.
2//!
3//! This is the doctrine's "persistent, dispatch-free steady state" in its first shippable form:
4//! workers are spawned once per process, parked on a condvar between operations (no busy wait,
5//! no work stealing, no task submission), and each dispatch hands every worker a disjoint
6//! contiguous range of output columns. Integer accumulation makes the parallel result *exactly*
7//! the serial result per element — partitioning never changes a single output bit, so thread
8//! count is a pure speed knob.
9//!
10//! ## The safety argument, in full
11//!
12//! A [`Job`] carries raw pointers into the caller's slices. Three facts make that sound:
13//!
14//! 1. **Lifetime**: [`Team::linear_q8`] does not return until every worker has decremented
15//!    `remaining` to zero, so the pointers outlive every access.
16//! 2. **Aliasing**: workers write only `out[row * n + col]` for `col` inside their own disjoint
17//!    column range; reads (`x_q`, scales, weights, bias) are shared and immutable for the whole
18//!    dispatch because the caller holds the only `&mut` (to `out`) and blocks.
19//! 3. **One parallel owner**: `dispatch_gate` serializes whole dispatches, so a second engine
20//!    thread cannot overwrite the job while workers are mid-partition, and workers themselves
21//!    never dispatch (their compute is a leaf loop).
22//!
23//! A stress test drives thousands of mixed-shape dispatches and a watchdog test bounds wall
24//! time, per the `many_utterances_without_deadlock` policy.
25
26use crate::int8::{Int8Tier, QuantizedMatrix, dot_i32};
27use std::sync::{Condvar, Mutex, OnceLock};
28
29/// One dispatched operation, shared read-only with every worker.
30#[derive(Clone, Copy)]
31struct Job {
32    x_q: *const i8,
33    x_scales: *const f32,
34    w_data: *const i8,
35    w_scales: *const f32,
36    /// Null when the projection is bias-free.
37    bias: *const f32,
38    out: *mut f32,
39    m: usize,
40    n: usize,
41    k: usize,
42    tier: Int8Tier,
43    /// Total partitions this dispatch, including the caller's partition 0.
44    partitions: usize,
45}
46
47// SAFETY: the pointers a Job carries are dereferenced only between dispatch and join (module
48// docs, fact 1), reads are shared-immutable and writes disjoint (fact 2). Sending the
49// descriptor to parked threads is exactly the mechanism those facts govern.
50unsafe impl Send for Job {}
51// SAFETY: workers only read the descriptor fields; interior data races are excluded by the
52// disjoint-write partition argument above.
53unsafe impl Sync for Job {}
54
55struct Control {
56    generation: u64,
57    job: Option<Job>,
58    remaining: usize,
59}
60
61struct Shared {
62    control: Mutex<Control>,
63    go: Condvar,
64    done: Condvar,
65}
66
67/// The process-wide team. Exists only when `FTTS_INT8_THREADS` requests more than one thread.
68pub struct Team {
69    shared: &'static Shared,
70    /// Total partitions per dispatch: spawned workers + the calling thread.
71    partitions: usize,
72    dispatch_gate: Mutex<()>,
73}
74
75/// The team for this process, if parallel execution is enabled.
76///
77/// `FTTS_INT8_THREADS` sets the total partition count (caller included); `1` or unset means
78/// serial (no threads spawned, no team). Values are clamped to the machine's available
79/// parallelism. Read once.
80pub fn armed() -> Option<&'static Team> {
81    static TEAM: OnceLock<Option<Team>> = OnceLock::new();
82    TEAM.get_or_init(|| {
83        let ceiling = std::thread::available_parallelism().map_or(1, usize::from);
84        // Default six ways: the measured knee on M4 Pro (memory-bound beyond it). Partitioning
85        // never changes output bits, so the default applies everywhere, reference route included.
86        let requested: usize = std::env::var("FTTS_INT8_THREADS")
87            .ok()
88            .and_then(|value| value.parse().ok())
89            .unwrap_or(6);
90        let partitions = requested.min(ceiling);
91        if partitions <= 1 {
92            return None;
93        }
94        let shared: &'static Shared = Box::leak(Box::new(Shared {
95            control: Mutex::new(Control {
96                generation: 0,
97                job: None,
98                remaining: 0,
99            }),
100            go: Condvar::new(),
101            done: Condvar::new(),
102        }));
103        // Workers 1..partitions; the caller is partition 0. Threads live for the process and
104        // park on the condvar between dispatches, so leaking their handles is deliberate.
105        for worker in 1..partitions {
106            std::thread::Builder::new()
107                .name(format!("ftts-int8-{worker}"))
108                .spawn(move || worker_loop(shared, worker))
109                .expect("spawn int8 worker");
110        }
111        Some(Team {
112            shared,
113            partitions,
114            dispatch_gate: Mutex::new(()),
115        })
116    })
117    .as_ref()
118}
119
120fn worker_loop(shared: &'static Shared, worker: usize) {
121    let mut seen = 0_u64;
122    loop {
123        let job = {
124            let mut control = shared.control.lock().expect("team control poisoned");
125            while control.generation == seen {
126                control = shared.go.wait(control).expect("team control poisoned");
127            }
128            seen = control.generation;
129            control.job.expect("generation bumped without a job")
130        };
131        run_partition(&job, worker);
132        let mut control = shared.control.lock().expect("team control poisoned");
133        control.remaining -= 1;
134        if control.remaining == 0 {
135            shared.done.notify_all();
136        }
137    }
138}
139
140/// Computes one worker's contiguous column range. Identical arithmetic to the serial
141/// weight-stationary loop in [`crate::int8::linear_q8`], restricted to `[start, end)`.
142fn run_partition(job: &Job, worker: usize) {
143    let chunk = job.n.div_ceil(job.partitions);
144    let start = (worker * chunk).min(job.n);
145    let end = ((worker + 1) * chunk).min(job.n);
146    if start >= end {
147        return;
148    }
149    // SAFETY: module-docs facts 1-3 — pointers outlive the dispatch, reads are shared-immutable,
150    // and this worker writes only columns in its own [start, end) range.
151    let (x_q, x_scales, w_data, w_scales, bias, out) = unsafe {
152        (
153            std::slice::from_raw_parts(job.x_q, job.m * job.k),
154            std::slice::from_raw_parts(job.x_scales, job.m),
155            std::slice::from_raw_parts(job.w_data, job.n * job.k),
156            std::slice::from_raw_parts(job.w_scales, job.n),
157            (!job.bias.is_null()).then(|| std::slice::from_raw_parts(job.bias, job.n)),
158            std::slice::from_raw_parts_mut(job.out, job.m * job.n),
159        )
160    };
161    for col in start..end {
162        let w_row = &w_data[col * job.k..(col + 1) * job.k];
163        let w_scale = w_scales[col];
164        let bias_term = bias.map(|b| b[col]);
165        for row in 0..job.m {
166            let x_row = &x_q[row * job.k..(row + 1) * job.k];
167            let acc = dot_i32(x_row, w_row, job.tier);
168            let value = acc as f32 * (x_scales[row] * w_scale);
169            out[row * job.n + col] = bias_term.map_or(value, |b| value + b);
170        }
171    }
172}
173
174impl Team {
175    /// Runs one W8A8 linear across the team. Bit-identical to the serial path per element.
176    ///
177    /// # Panics
178    ///
179    /// Panics on shape mismatches, exactly as the serial kernel does.
180    #[allow(clippy::too_many_arguments)]
181    pub fn linear_q8(
182        &self,
183        x_q: &[i8],
184        x_scales: &[f32],
185        weight: &QuantizedMatrix,
186        bias: Option<&[f32]>,
187        m: usize,
188        out: &mut [f32],
189        tier: Int8Tier,
190    ) {
191        let (n, k) = (weight.n, weight.k);
192        assert_eq!(x_q.len(), m * k, "x_q must be [m, k]");
193        assert_eq!(x_scales.len(), m, "x_scales must be [m]");
194        assert_eq!(out.len(), m * n, "out must be [m, n]");
195        if let Some(bias) = bias {
196            assert_eq!(bias.len(), n, "bias must be [n]");
197        }
198
199        let job = Job {
200            x_q: x_q.as_ptr(),
201            x_scales: x_scales.as_ptr(),
202            w_data: weight.data.as_ptr(),
203            w_scales: weight.scales.as_ptr(),
204            bias: bias.map_or(std::ptr::null(), <[f32]>::as_ptr),
205            out: out.as_mut_ptr(),
206            m,
207            n,
208            k,
209            tier,
210            partitions: self.partitions,
211        };
212
213        // One dispatch at a time, held through the join (module-docs fact 3).
214        let _gate = self.dispatch_gate.lock().expect("dispatch gate poisoned");
215        {
216            let mut control = self.shared.control.lock().expect("team control poisoned");
217            control.job = Some(job);
218            control.generation += 1;
219            control.remaining = self.partitions - 1;
220            self.shared.go.notify_all();
221        }
222
223        // The caller is partition 0: it works instead of idling.
224        run_partition(&job, 0);
225
226        let mut control = self.shared.control.lock().expect("team control poisoned");
227        while control.remaining > 0 {
228            control = self
229                .shared
230                .done
231                .wait(control)
232                .expect("team control poisoned");
233        }
234        control.job = None;
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use crate::int8::linear_q8;
242
243    fn matrix(n: usize, k: usize, seed: u64) -> QuantizedMatrix {
244        let mut state = seed;
245        let data: Vec<i8> = (0..n * k)
246            .map(|_| {
247                state = state
248                    .wrapping_mul(6_364_136_223_846_793_005)
249                    .wrapping_add(1);
250                (((state >> 33) % 255) as i32 - 127) as i8
251            })
252            .collect();
253        let scales: Vec<f32> = (0..n).map(|row| 0.001 + (row % 7) as f32 * 0.01).collect();
254        QuantizedMatrix { data, scales, n, k }
255    }
256
257    /// A directly constructed team, so the test controls the partition count regardless of the
258    /// process environment.
259    fn test_team(partitions: usize) -> Team {
260        let shared: &'static Shared = Box::leak(Box::new(Shared {
261            control: Mutex::new(Control {
262                generation: 0,
263                job: None,
264                remaining: 0,
265            }),
266            go: Condvar::new(),
267            done: Condvar::new(),
268        }));
269        for worker in 1..partitions {
270            std::thread::spawn(move || worker_loop(shared, worker));
271        }
272        Team {
273            shared,
274            partitions,
275            dispatch_gate: Mutex::new(()),
276        }
277    }
278
279    #[test]
280    fn every_partition_count_is_bit_identical_to_serial_at_model_shapes() {
281        for &(m, n, k) in &[
282            (1_usize, 2048_usize, 1024_usize),
283            (1, 1024, 3072),
284            (16, 3072, 1024),
285            (2, 517, 129), // deliberately ragged: tail partitions and odd K
286        ] {
287            let weight = matrix(n, k, 42 ^ (n as u64) << 20);
288            let x_q: Vec<i8> = (0..m * k).map(|i| ((i * 31 + 7) % 255) as i8).collect();
289            let x_scales: Vec<f32> = (0..m).map(|row| 0.02 + row as f32 * 0.005).collect();
290            let mut serial = vec![0.0_f32; m * n];
291            linear_q8(
292                &x_q,
293                &x_scales,
294                &weight,
295                None,
296                m,
297                &mut serial,
298                Int8Tier::Scalar,
299            );
300            for partitions in [2_usize, 3, 4, 8] {
301                let team = test_team(partitions);
302                let mut parallel = vec![0.0_f32; m * n];
303                team.linear_q8(
304                    &x_q,
305                    &x_scales,
306                    &weight,
307                    None,
308                    m,
309                    &mut parallel,
310                    Int8Tier::Scalar,
311                );
312                for (index, (a, b)) in serial.iter().zip(&parallel).enumerate() {
313                    assert_eq!(
314                        a.to_bits(),
315                        b.to_bits(),
316                        "partitions={partitions} m={m} n={n} k={k} element {index}"
317                    );
318                }
319            }
320        }
321    }
322
323    #[test]
324    fn thousands_of_mixed_dispatches_complete_without_deadlock() {
325        // The many_utterances_without_deadlock policy at kernel scale: hammer one team with
326        // mixed shapes; a hang here fails by test-harness timeout rather than passing silently.
327        let team = test_team(4);
328        let weight_a = matrix(256, 512, 7);
329        let weight_b = matrix(96, 128, 11);
330        let x_a: Vec<i8> = vec![3; 512];
331        let x_b: Vec<i8> = vec![-5; 2 * 128];
332        let mut out_a = vec![0.0_f32; 256];
333        let mut out_b = vec![0.0_f32; 2 * 96];
334        for _ in 0..2_000 {
335            team.linear_q8(
336                &x_a,
337                &[0.5],
338                &weight_a,
339                None,
340                1,
341                &mut out_a,
342                Int8Tier::Scalar,
343            );
344            team.linear_q8(
345                &x_b,
346                &[0.5, 0.25],
347                &weight_b,
348                None,
349                2,
350                &mut out_b,
351                Int8Tier::Scalar,
352            );
353        }
354        assert!(out_a.iter().all(|value| value.is_finite()));
355        assert!(out_b.iter().all(|value| value.is_finite()));
356    }
357
358    #[test]
359    fn bias_reaches_every_partition() {
360        let (m, n, k) = (2_usize, 130_usize, 64_usize);
361        let weight = matrix(n, k, 99);
362        let bias: Vec<f32> = (0..n).map(|i| i as f32).collect();
363        let x_q: Vec<i8> = vec![1; m * k];
364        let x_scales = vec![1.0_f32; m];
365        let mut serial = vec![0.0_f32; m * n];
366        linear_q8(
367            &x_q,
368            &x_scales,
369            &weight,
370            Some(&bias),
371            m,
372            &mut serial,
373            Int8Tier::Scalar,
374        );
375        let team = test_team(3);
376        let mut parallel = vec![0.0_f32; m * n];
377        team.linear_q8(
378            &x_q,
379            &x_scales,
380            &weight,
381            Some(&bias),
382            m,
383            &mut parallel,
384            Int8Tier::Scalar,
385        );
386        assert_eq!(
387            serial.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
388            parallel.iter().map(|v| v.to_bits()).collect::<Vec<_>>()
389        );
390    }
391}