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//!
26//! ## Relation to the plan's "sense-reversing barrier"
27//!
28//! The doctrine text describes the steady-state rendezvous as a sense-reversing atomic
29//! barrier. What ships here is a mutex/condvar **generation-counter** barrier: same protocol
30//! shape (a monotone epoch replaces the flipped sense; workers wait for the epoch to advance,
31//! the dispatcher waits for `remaining` to reach zero), but the ordering guarantees come from
32//! the mutex, not from raw atomics — so an audit of this module should trace the lock, not
33//! look for `AtomicBool` sense flags. The atomic flavor remains a candidate once dispatch
34//! overhead itself shows up on a profile.
35
36use crate::int8::{Int8Tier, QuantizedMatrix, dot_i32, dot_w8a16};
37use std::sync::{Condvar, Mutex, OnceLock};
38
39/// One dispatched operation, shared read-only with every worker.
40#[derive(Clone, Copy)]
41enum Job {
42    /// W8A8 linear partitioned over output columns.
43    Linear(LinearJob),
44    /// W8A16 (weight-only) linear partitioned over output columns.
45    W8A16Linear(W8A16LinearJob),
46    /// f32 GQA attention partitioned over query heads.
47    Attention(AttentionJob),
48    /// f32 dense linear (the packed GEMM) partitioned over output columns.
49    F32Linear(F32LinearJob),
50}
51
52/// The codec's dense route, partitioned over output columns.
53///
54/// This is the codec's whole arithmetic budget: every convolution (via im2col), every ConvNeXt
55/// pointwise pair, and every transformer projection reaches one function, and that function
56/// measured 92% of browser frame time while running on a single thread.
57///
58/// Column partitioning is exact here for the same reason it is for the int8 job: no reduction
59/// crosses a column, so a stripe computed in isolation has the bits the whole call would have
60/// written (`packed_gemm::column_partitions_are_bit_identical_to_the_whole`).
61#[derive(Clone, Copy)]
62struct F32LinearJob {
63    x: *const f32,
64    weight: *const f32,
65    /// Null when the projection is bias-free.
66    bias: *const f32,
67    out: *mut f32,
68    m: usize,
69    k: usize,
70    n: usize,
71    partitions: usize,
72}
73
74#[derive(Clone, Copy)]
75struct LinearJob {
76    x_q: *const i8,
77    x_scales: *const f32,
78    w_data: *const i8,
79    w_scales: *const f32,
80    /// Null when the projection is bias-free.
81    bias: *const f32,
82    out: *mut f32,
83    m: usize,
84    n: usize,
85    k: usize,
86    tier: Int8Tier,
87    /// Total partitions this dispatch, including the caller's partition 0.
88    partitions: usize,
89}
90
91/// The weight-only quantized route (`FTTS_INT8=w8a16`), partitioned over output columns.
92///
93/// Column partitioning is exact for the same reason as the W8A8 job: no reduction crosses a
94/// column, and every element is one `dot_w8a16` over the same span in the same order the
95/// serial loop would run.
96#[derive(Clone, Copy)]
97struct W8A16LinearJob {
98    x: *const f32,
99    w_data: *const i8,
100    w_scales: *const f32,
101    /// Null when the projection is bias-free.
102    bias: *const f32,
103    out: *mut f32,
104    m: usize,
105    n: usize,
106    k: usize,
107    /// Total partitions this dispatch, including the caller's partition 0.
108    partitions: usize,
109}
110
111/// The default-arithmetic GQA attention, partitioned over query heads.
112///
113/// Head independence is the whole safety-and-exactness story: no reduction crosses a head, and
114/// each head writes only its own `head_dim` span of every output row, so any head partition is
115/// bit-identical to the serial full-range call.
116#[derive(Clone, Copy)]
117struct AttentionJob {
118    queries: *const f32,
119    keys: *const f32,
120    values: *const f32,
121    mask: *const f32,
122    query_positions: usize,
123    key_positions: usize,
124    q_heads: usize,
125    kv_heads: usize,
126    head_dim: usize,
127    out: *mut f32,
128    partitions: usize,
129}
130
131// SAFETY: the pointers a Job carries are dereferenced only between dispatch and join (module
132// docs, fact 1), reads are shared-immutable and writes disjoint (fact 2). Sending the
133// descriptor to parked threads is exactly the mechanism those facts govern.
134unsafe impl Send for Job {}
135// SAFETY: workers only read the descriptor fields; interior data races are excluded by the
136// disjoint-write partition argument above.
137unsafe impl Sync for Job {}
138
139struct Control {
140    generation: u64,
141    job: Option<Job>,
142    remaining: usize,
143    /// Set when any partition panicked during the current dispatch, so the caller can
144    /// propagate a loud failure instead of hanging on a worker that will never report done.
145    panicked: bool,
146}
147
148struct Shared {
149    control: Mutex<Control>,
150    go: Condvar,
151    done: Condvar,
152}
153
154/// The process-wide team. Armed by default at min(6, cores) partitions on native
155/// (`FTTS_INT8_THREADS` overrides; 1 disarms), and explicitly by the host on wasm.
156pub struct Team {
157    shared: &'static Shared,
158    /// Total partitions per dispatch: spawned workers + the calling thread.
159    partitions: usize,
160    dispatch_gate: Mutex<()>,
161}
162
163thread_local! {
164    static TEAM_BYPASS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
165}
166
167/// Partitions the armed team runs with, or 1 when execution is serial.
168///
169/// Exposed so a host with no environment variables — a browser — can report whether threading
170/// actually engaged, instead of running serially and looking identical.
171#[must_use]
172pub fn partitions() -> usize {
173    armed().map_or(1, |team| team.partitions)
174}
175
176/// Makes THIS thread run its int8 linears serially, never dispatching to the team.
177///
178/// The codec pipeline worker sets this: its work is meant to overlap with the generator's
179/// team dispatches on spare cores, and routing it through the shared team would merely
180/// interleave the two through the dispatch gate instead of running them concurrently.
181pub fn bypass_team_on_this_thread() {
182    TEAM_BYPASS.with(|cell| cell.set(true));
183}
184
185/// Runs `body` with team dispatch bypassed on this thread, restoring the previous state after.
186///
187/// For callers that need the SERIAL kernel for a bounded stretch — the int8 autotuner probes
188/// tier cost, and a probe routed through the team would time dispatch overhead plus whatever
189/// the workers are doing, not the tier — without permanently opting the thread out the way
190/// [`bypass_team_on_this_thread`] (meant for worker threads) does.
191pub fn with_team_bypassed<R>(body: impl FnOnce() -> R) -> R {
192    let previous = TEAM_BYPASS.with(std::cell::Cell::get);
193    TEAM_BYPASS.with(|cell| cell.set(true));
194    let result = body();
195    TEAM_BYPASS.with(|cell| cell.set(previous));
196    result
197}
198
199/// Whether the current thread opted out of team dispatch.
200#[must_use]
201pub fn thread_bypassed() -> bool {
202    TEAM_BYPASS.with(std::cell::Cell::get)
203}
204
205/// The team for this process, if parallel execution is enabled.
206///
207/// `FTTS_INT8_THREADS` sets the total partition count (caller included); `1` or unset means
208/// serial (no threads spawned, no team). Values are clamped to the machine's available
209/// parallelism. Read once.
210pub fn armed() -> Option<&'static Team> {
211    // wasm32 cannot spawn its own threads: `wasm32-unknown-unknown` has no `std::thread::spawn`,
212    // because only the host can create the Workers that share this module's linear memory. So the
213    // team is *installed* from JS once its Workers are up (see `install_wasm_team`) instead of
214    // being created on first use, and stays `None` until then — which is also the correct answer
215    // for any browser without `SharedArrayBuffer`.
216    #[cfg(target_arch = "wasm32")]
217    {
218        WASM_TEAM.get().and_then(Option::as_ref)
219    }
220    #[cfg(not(target_arch = "wasm32"))]
221    armed_native()
222}
223
224/// The team installed by the host, once its Workers exist.
225#[cfg(target_arch = "wasm32")]
226static WASM_TEAM: OnceLock<Option<Team>> = OnceLock::new();
227
228/// The shared control block Workers park on, published before any of them starts.
229#[cfg(target_arch = "wasm32")]
230static WASM_SHARED: OnceLock<&'static Shared> = OnceLock::new();
231
232/// Publishes the shared control block Workers park on, without sizing the team.
233///
234/// Split from [`arm_wasm_team`] deliberately. A Worker must be able to park *before* the team
235/// exists, because the team's width has to be the number of Workers that actually started — and
236/// that is not known until they report. Publishing first, arming second, is what makes a Worker
237/// that fails to boot cost a partition rather than a hang.
238///
239/// # Why any of this runs in a Worker
240///
241/// The dispatcher is partition 0 and blocks on a condvar until the others report done.
242/// `atomic.wait` **traps on a browser's main thread**, so the owning thread must itself be a
243/// Worker — here, the engine Worker that already runs synthesis. Arming from the main thread
244/// would not merely be slow, it would abort.
245#[cfg(target_arch = "wasm32")]
246pub fn publish_wasm_block() {
247    let _ = WASM_SHARED.get_or_init(|| {
248        Box::leak(Box::new(Shared {
249            control: Mutex::new(Control {
250                generation: 0,
251                job: None,
252                remaining: 0,
253                panicked: false,
254            }),
255            go: Condvar::new(),
256            done: Condvar::new(),
257        }))
258    });
259}
260
261/// Arms a `partitions`-way team over the already-published control block.
262///
263/// Call this only after `partitions - 1` Workers have confirmed they are parked in
264/// [`wasm_worker_loop`]. Sizing the team before they report would be a deadlock waiting to
265/// happen: the dispatcher decrements `remaining` down from `partitions - 1` and blocks until it
266/// reaches zero, so a partition that never started is a partition that never reports done.
267///
268/// `partitions <= 1` arms nothing, which is the serial fallback a browser without
269/// `SharedArrayBuffer` — or one where every Worker failed to start — correctly lands on.
270#[cfg(target_arch = "wasm32")]
271pub fn arm_wasm_team(partitions: usize) {
272    if partitions <= 1 {
273        let _ = WASM_TEAM.set(None);
274        return;
275    }
276    publish_wasm_block();
277    let shared = *WASM_SHARED.get().expect("just published");
278    let _ = WASM_TEAM.set(Some(Team {
279        shared,
280        partitions,
281        dispatch_gate: Mutex::new(()),
282    }));
283}
284
285/// The body every spawned Worker runs, forever.
286///
287/// # Panics
288///
289/// Panics if called before [`install_wasm_team`] published the control block — a Worker that
290/// started before its team is a host wiring bug, and parking on a block that does not exist yet
291/// would hang instead of saying so.
292#[cfg(target_arch = "wasm32")]
293pub fn wasm_worker_loop(worker: usize) {
294    let shared = *WASM_SHARED
295        .get()
296        .expect("worker started before install_wasm_team published the control block");
297    worker_loop(shared, worker)
298}
299
300#[cfg(not(target_arch = "wasm32"))]
301fn armed_native() -> Option<&'static Team> {
302    static TEAM: OnceLock<Option<Team>> = OnceLock::new();
303    TEAM.get_or_init(|| {
304        let ceiling = std::thread::available_parallelism().map_or(1, usize::from);
305        // Default six ways: the measured knee on M4 Pro (memory-bound beyond it). Partitioning
306        // never changes output bits, so the default applies everywhere, reference route included.
307        let requested: usize = std::env::var("FTTS_INT8_THREADS")
308            .ok()
309            .and_then(|value| value.parse().ok())
310            .unwrap_or(6);
311        let partitions = requested.min(ceiling);
312        if partitions <= 1 {
313            return None;
314        }
315        let shared: &'static Shared = Box::leak(Box::new(Shared {
316            control: Mutex::new(Control {
317                generation: 0,
318                job: None,
319                remaining: 0,
320                panicked: false,
321            }),
322            go: Condvar::new(),
323            done: Condvar::new(),
324        }));
325        // Workers 1..partitions; the caller is partition 0. Threads live for the process and
326        // park on the condvar between dispatches, so leaking their handles is deliberate.
327        for worker in 1..partitions {
328            std::thread::Builder::new()
329                .name(format!("ftts-int8-{worker}"))
330                .spawn(move || {
331                    // On Apple platforms a thread without an elevated QoS class is fair
332                    // game for the efficiency cores. The team barrier waits for its
333                    // slowest member, so one demoted worker sets the pace of every
334                    // dispatch; ask for the same class the caller's UI work runs at.
335                    #[cfg(target_vendor = "apple")]
336                    // SAFETY: setting this thread's own QoS class; no memory contract.
337                    #[allow(unsafe_code)]
338                    unsafe {
339                        libc::pthread_set_qos_class_self_np(
340                            libc::qos_class_t::QOS_CLASS_USER_INITIATED,
341                            0,
342                        );
343                    }
344                    worker_loop(shared, worker)
345                })
346                .expect("spawn int8 worker");
347        }
348        Some(Team {
349            shared,
350            partitions,
351            dispatch_gate: Mutex::new(()),
352        })
353    })
354    .as_ref()
355}
356
357fn worker_loop(shared: &'static Shared, worker: usize) {
358    let mut seen = 0_u64;
359    loop {
360        let job = {
361            let mut control = lock_control(shared);
362            while control.generation == seen {
363                control = shared
364                    .go
365                    .wait(control)
366                    .unwrap_or_else(std::sync::PoisonError::into_inner);
367            }
368            seen = control.generation;
369            control.job.expect("generation bumped without a job")
370        };
371        // A panicking partition must still report done, or the caller hangs forever waiting
372        // for a decrement that will never come. The panic is recorded and re-raised loudly on
373        // the caller's thread instead.
374        #[cfg(test)]
375        let injected = worker > 0 && tests::panic_injected_for(shared);
376        #[cfg(not(test))]
377        let injected = false;
378        let outcome = std::panic::catch_unwind(|| {
379            if injected {
380                panic!("injected worker panic for the hang-hardening test");
381            }
382            run_partition(&job, worker)
383        });
384        let mut control = lock_control(shared);
385        if outcome.is_err() {
386            control.panicked = true;
387        }
388        control.remaining -= 1;
389        if control.remaining == 0 {
390            shared.done.notify_all();
391        }
392    }
393}
394
395/// Locks team control, tolerating poison: every dispatch re-establishes the full invariant
396/// (job, generation, remaining) from scratch, so a lock poisoned by an earlier panic carries
397/// no state that could mislead the next dispatch.
398fn lock_control(shared: &Shared) -> std::sync::MutexGuard<'_, Control> {
399    shared
400        .control
401        .lock()
402        .unwrap_or_else(std::sync::PoisonError::into_inner)
403}
404
405/// Computes one worker's contiguous column range. Identical arithmetic to the serial
406/// weight-stationary loop in [`crate::int8::linear_q8`], restricted to `[start, end)`.
407fn run_partition(job: &Job, worker: usize) {
408    let _ = worker;
409    match job {
410        Job::Linear(job) => run_linear_partition(job, worker),
411        Job::W8A16Linear(job) => run_w8a16_linear_partition(job, worker),
412        Job::Attention(job) => run_attention_partition(job, worker),
413        Job::F32Linear(job) => run_f32_linear_partition(job, worker),
414    }
415}
416
417/// One worker's query-head range of an attention job. Same extracted loop the serial reference
418/// runs (`f32ref::gqa_attention_head_range_with_arithmetic` with the default arithmetic).
419fn run_attention_partition(job: &AttentionJob, worker: usize) {
420    let chunk = job.q_heads.div_ceil(job.partitions);
421    let start = (worker * chunk).min(job.q_heads);
422    let end = ((worker + 1) * chunk).min(job.q_heads);
423    if start >= end {
424        return;
425    }
426    // SAFETY: same three facts as the linear job (module docs) — the caller joins before its
427    // slices can die, and reads are shared-immutable for the dispatch. The output stays a raw
428    // pointer: every worker turning it into a whole-buffer `&mut` would put several live `&mut`
429    // on one allocation, which is undefined behaviour even though the writes are disjoint.
430    let (queries, keys, values, mask) = unsafe {
431        (
432            std::slice::from_raw_parts(
433                job.queries,
434                job.query_positions * job.q_heads * job.head_dim,
435            ),
436            std::slice::from_raw_parts(job.keys, job.key_positions * job.kv_heads * job.head_dim),
437            std::slice::from_raw_parts(job.values, job.key_positions * job.kv_heads * job.head_dim),
438            std::slice::from_raw_parts(job.mask, job.query_positions * job.key_positions),
439        )
440    };
441    // SAFETY: `out` is valid for the full [query_positions, q_heads, head_dim] span for this
442    // dispatch, and this worker's `start..end` head range is disjoint from every other
443    // partition's, so no two live borrows ever overlap.
444    unsafe {
445        crate::f32ref::gqa_attention_head_range_into(
446            queries,
447            keys,
448            values,
449            mask,
450            job.query_positions,
451            job.key_positions,
452            job.q_heads,
453            job.kv_heads,
454            job.head_dim,
455            crate::f32ref::F32SoftmaxArithmetic::ReciprocalMultiply,
456            crate::f32ref::F32LinearAccumulation::Scalar,
457            start..end,
458            job.out,
459        );
460    }
461}
462
463fn run_linear_partition(job: &LinearJob, worker: usize) {
464    let chunk = job.n.div_ceil(job.partitions);
465    let start = (worker * chunk).min(job.n);
466    let end = ((worker + 1) * chunk).min(job.n);
467    if start >= end {
468        return;
469    }
470    // SAFETY: module-docs facts 1-3 — pointers outlive the dispatch, reads are shared-immutable,
471    // and this worker writes only columns in its own [start, end) range. The output deliberately
472    // stays a raw pointer: a whole-buffer `&mut` per worker would be several live `&mut` on one
473    // allocation, which is undefined behaviour regardless of the writes being disjoint, and
474    // `rustc` marks `&mut` `noalias` so the optimizer is entitled to act on it.
475    let (x_q, x_scales, w_data, w_scales, bias) = unsafe {
476        (
477            std::slice::from_raw_parts(job.x_q, job.m * job.k),
478            std::slice::from_raw_parts(job.x_scales, job.m),
479            std::slice::from_raw_parts(job.w_data, job.n * job.k),
480            std::slice::from_raw_parts(job.w_scales, job.n),
481            (!job.bias.is_null()).then(|| std::slice::from_raw_parts(job.bias, job.n)),
482        )
483    };
484    for col in start..end {
485        let w_row = &w_data[col * job.k..(col + 1) * job.k];
486        let w_scale = w_scales[col];
487        let bias_term = bias.map(|b| b[col]);
488        for row in 0..job.m {
489            let x_row = &x_q[row * job.k..(row + 1) * job.k];
490            let acc = dot_i32(x_row, w_row, job.tier);
491            let value = acc as f32 * (x_scales[row] * w_scale);
492            // SAFETY: `col` is inside this partition's exclusive range and `row < m`, so this
493            // address is written by no other partition for the duration of the dispatch.
494            unsafe {
495                *job.out.add(row * job.n + col) = bias_term.map_or(value, |b| value + b);
496            }
497        }
498    }
499}
500
501/// One worker's column stripe of a W8A16 job — the same loop `int8::linear_w8a16` runs
502/// serially, sharing `dot_w8a16`, so each element's f32 operation order is unchanged.
503fn run_w8a16_linear_partition(job: &W8A16LinearJob, worker: usize) {
504    let chunk = job.n.div_ceil(job.partitions);
505    let start = (worker * chunk).min(job.n);
506    let end = ((worker + 1) * chunk).min(job.n);
507    if start >= end {
508        return;
509    }
510    // SAFETY: module-docs facts 1-3, the same aliasing story as the W8A8 job — pointers outlive
511    // the dispatch, reads are shared-immutable, writes land only in this worker's column range,
512    // and the output stays a raw pointer because several live whole-buffer `&mut` would be UB
513    // even with disjoint writes.
514    let (x, w_data, w_scales, bias) = unsafe {
515        (
516            std::slice::from_raw_parts(job.x, job.m * job.k),
517            std::slice::from_raw_parts(job.w_data, job.n * job.k),
518            std::slice::from_raw_parts(job.w_scales, job.n),
519            (!job.bias.is_null()).then(|| std::slice::from_raw_parts(job.bias, job.n)),
520        )
521    };
522    for col in start..end {
523        let w_row = &w_data[col * job.k..(col + 1) * job.k];
524        let w_scale = w_scales[col];
525        let bias_term = bias.map(|b| b[col]);
526        for row in 0..job.m {
527            let x_row = &x[row * job.k..(row + 1) * job.k];
528            let acc = dot_w8a16(x_row, w_row);
529            let value = acc * w_scale;
530            // SAFETY: `col` is inside this partition's exclusive range and `row < m`, so this
531            // address is written by no other partition for the duration of the dispatch.
532            unsafe {
533                *job.out.add(row * job.n + col) = bias_term.map_or(value, |b| value + b);
534            }
535        }
536    }
537}
538
539/// Computes this worker's column stripe of an f32 dense linear.
540fn run_f32_linear_partition(job: &F32LinearJob, worker: usize) {
541    // Stripes are NR-aligned so every partition stays on the packed register-tiled path; a ragged
542    // boundary would push one partition onto the scalar remainder loop for no reason.
543    const NR: usize = 8;
544    let chunk = job.n.div_ceil(job.partitions).next_multiple_of(NR);
545    let start = (worker * chunk).min(job.n);
546    let end = ((worker + 1) * chunk).min(job.n);
547    if start >= end {
548        return;
549    }
550    // SAFETY: module-docs facts 1-3. The pointers outlive the dispatch (the caller blocks until
551    // every partition reports done), the reads are shared-immutable for its duration, and this
552    // worker writes only columns in its own [start, end) stripe. `out` stays a raw pointer for the
553    // same reason as the int8 job: several whole-buffer `&mut` would be UB even with disjoint
554    // writes, because `rustc` marks `&mut` `noalias`.
555    unsafe {
556        let x = std::slice::from_raw_parts(job.x, job.m * job.k);
557        let weight = std::slice::from_raw_parts(job.weight, job.n * job.k);
558        let bias = (!job.bias.is_null()).then(|| std::slice::from_raw_parts(job.bias, job.n));
559        crate::packed_gemm::linear_packed_range(
560            x, weight, bias, job.m, job.k, job.n, start, end, job.out,
561        );
562    }
563}
564
565impl Team {
566    /// Runs one f32 dense linear across the team, bit-identically to the serial packed kernel.
567    ///
568    /// # Panics
569    ///
570    /// Panics on shape mismatches, exactly as the serial kernel does.
571    ///
572    /// The argument count mirrors the serial kernel's signature exactly, which is the point: a
573    /// caller swaps one call for the other with no reshaping, so any divergence would be a
574    /// compile error rather than a silent behavioural difference.
575    #[allow(clippy::too_many_arguments)]
576    pub fn linear_f32(
577        &self,
578        x: &[f32],
579        weight: &[f32],
580        bias: Option<&[f32]>,
581        m: usize,
582        k: usize,
583        n: usize,
584        out: &mut [f32],
585    ) {
586        assert_eq!(x.len(), m * k, "x must be [m, k]");
587        assert_eq!(weight.len(), n * k, "weight must be [n, k]");
588        assert_eq!(out.len(), m * n, "out must be [m, n]");
589        if let Some(bias) = bias {
590            assert_eq!(bias.len(), n, "bias must be [n]");
591        }
592        let job = Job::F32Linear(F32LinearJob {
593            x: x.as_ptr(),
594            weight: weight.as_ptr(),
595            bias: bias.map_or(std::ptr::null(), <[f32]>::as_ptr),
596            out: out.as_mut_ptr(),
597            m,
598            k,
599            n,
600            partitions: self.partitions,
601        });
602        self.dispatch(job);
603    }
604
605    /// Runs one W8A8 linear across the team. Bit-identical to the serial path per element.
606    ///
607    /// # Panics
608    ///
609    /// Panics on shape mismatches, exactly as the serial kernel does.
610    #[allow(clippy::too_many_arguments)]
611    pub fn linear_q8(
612        &self,
613        x_q: &[i8],
614        x_scales: &[f32],
615        weight: &QuantizedMatrix,
616        bias: Option<&[f32]>,
617        m: usize,
618        out: &mut [f32],
619        tier: Int8Tier,
620    ) {
621        let (n, k) = (weight.n, weight.k);
622        assert_eq!(x_q.len(), m * k, "x_q must be [m, k]");
623        assert_eq!(x_scales.len(), m, "x_scales must be [m]");
624        assert_eq!(out.len(), m * n, "out must be [m, n]");
625        if let Some(bias) = bias {
626            assert_eq!(bias.len(), n, "bias must be [n]");
627        }
628
629        let job = Job::Linear(LinearJob {
630            x_q: x_q.as_ptr(),
631            x_scales: x_scales.as_ptr(),
632            w_data: weight.data.as_ptr(),
633            w_scales: weight.scales.as_ptr(),
634            bias: bias.map_or(std::ptr::null(), <[f32]>::as_ptr),
635            out: out.as_mut_ptr(),
636            m,
637            n,
638            k,
639            tier,
640            partitions: self.partitions,
641        });
642
643        self.dispatch(job);
644    }
645
646    /// Runs one W8A16 (weight-only) linear across the team. Bit-identical to the serial path
647    /// per element — every output element is the same `dot_w8a16` reduction.
648    ///
649    /// # Panics
650    ///
651    /// Panics on shape mismatches, exactly as the serial kernel does.
652    pub fn linear_w8a16(
653        &self,
654        x: &[f32],
655        weight: &QuantizedMatrix,
656        bias: Option<&[f32]>,
657        m: usize,
658        out: &mut [f32],
659    ) {
660        let (n, k) = (weight.n, weight.k);
661        assert_eq!(x.len(), m * k, "x must be [m, k]");
662        assert_eq!(out.len(), m * n, "out must be [m, n]");
663        if let Some(bias) = bias {
664            assert_eq!(bias.len(), n, "bias must be [n]");
665        }
666        let job = Job::W8A16Linear(W8A16LinearJob {
667            x: x.as_ptr(),
668            w_data: weight.data.as_ptr(),
669            w_scales: weight.scales.as_ptr(),
670            bias: bias.map_or(std::ptr::null(), <[f32]>::as_ptr),
671            out: out.as_mut_ptr(),
672            m,
673            n,
674            k,
675            partitions: self.partitions,
676        });
677        self.dispatch(job);
678    }
679
680    /// Runs the default-arithmetic GQA attention across the team, partitioned over query
681    /// heads. Bit-identical to the serial `f32ref::gqa_attention` (same extracted loop).
682    ///
683    /// # Panics
684    ///
685    /// Panics on shape mismatches, exactly as the serial reference does.
686    #[allow(clippy::too_many_arguments)]
687    pub fn gqa_attention(
688        &self,
689        queries: &[f32],
690        keys: &[f32],
691        values: &[f32],
692        mask: &[f32],
693        query_positions: usize,
694        key_positions: usize,
695        q_heads: usize,
696        kv_heads: usize,
697        head_dim: usize,
698        out: &mut [f32],
699    ) {
700        assert!(
701            kv_heads > 0 && q_heads.is_multiple_of(kv_heads),
702            "GQA head geometry"
703        );
704        assert_eq!(
705            queries.len(),
706            query_positions * q_heads * head_dim,
707            "queries shape"
708        );
709        assert_eq!(
710            keys.len(),
711            key_positions * kv_heads * head_dim,
712            "keys shape"
713        );
714        assert_eq!(
715            values.len(),
716            key_positions * kv_heads * head_dim,
717            "values shape"
718        );
719        assert_eq!(mask.len(), query_positions * key_positions, "mask shape");
720        assert_eq!(out.len(), query_positions * q_heads * head_dim, "out shape");
721        let job = Job::Attention(AttentionJob {
722            queries: queries.as_ptr(),
723            keys: keys.as_ptr(),
724            values: values.as_ptr(),
725            mask: mask.as_ptr(),
726            query_positions,
727            key_positions,
728            q_heads,
729            kv_heads,
730            head_dim,
731            out: out.as_mut_ptr(),
732            partitions: self.partitions,
733        });
734        self.dispatch(job);
735    }
736
737    /// The shared dispatch/work/join cycle (module-docs facts 1-3).
738    fn dispatch(&self, job: Job) {
739        // One dispatch at a time, held through the join (module-docs fact 3). Poison
740        // tolerance: a prior caller's panic leaves no dispatch state behind — everything is
741        // re-established below.
742        let _gate = self
743            .dispatch_gate
744            .lock()
745            .unwrap_or_else(std::sync::PoisonError::into_inner);
746        {
747            let mut control = lock_control(self.shared);
748            control.job = Some(job);
749            control.generation += 1;
750            control.remaining = self.partitions - 1;
751            control.panicked = false;
752            self.shared.go.notify_all();
753        }
754
755        // The caller is partition 0: it works instead of idling. Its own panic must still
756        // wait out the workers (they hold live pointers into the caller's slices), so the
757        // join below runs before any unwind continues.
758        let caller_outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
759            run_partition(&job, 0);
760        }));
761
762        let mut control = lock_control(self.shared);
763        while control.remaining > 0 {
764            control = self
765                .shared
766                .done
767                .wait(control)
768                .unwrap_or_else(std::sync::PoisonError::into_inner);
769        }
770        control.job = None;
771        let worker_panicked = control.panicked;
772        drop(control);
773
774        if let Err(payload) = caller_outcome {
775            std::panic::resume_unwind(payload);
776        }
777        assert!(
778            !worker_panicked,
779            "a team worker panicked during this dispatch; the output buffer is not fully written"
780        );
781    }
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787    use crate::int8::{linear_q8, linear_w8a16};
788
789    /// One-shot fuse consumed by a worker of ONE SPECIFIC team.
790    ///
791    /// Targeted by the `Shared` block's address rather than a bare bool: the test binary
792    /// runs concurrently, other tests (and, since the attention wiring, plain f32ref calls)
793    /// dispatch on the default-armed GLOBAL team, and an untargeted fuse was consumed by
794    /// whichever team's worker happened to run first — failing this test and panicking an
795    /// innocent one.
796    pub(super) static PANIC_INJECT_TARGET: std::sync::atomic::AtomicUsize =
797        std::sync::atomic::AtomicUsize::new(0);
798
799    /// Consumes the fuse iff it targets `shared`'s team.
800    pub(super) fn panic_injected_for(shared: &Shared) -> bool {
801        let target = std::ptr::from_ref(shared) as usize;
802        PANIC_INJECT_TARGET
803            .compare_exchange(
804                target,
805                0,
806                std::sync::atomic::Ordering::SeqCst,
807                std::sync::atomic::Ordering::SeqCst,
808            )
809            .is_ok()
810    }
811
812    #[test]
813    fn a_panicking_worker_fails_the_dispatch_loudly_instead_of_hanging() {
814        let team = test_team(3);
815        let weight = matrix(64, 32, 5);
816        let x_q = vec![1_i8; 32];
817        let mut out = vec![0.0_f32; 64];
818        PANIC_INJECT_TARGET.store(
819            std::ptr::from_ref(team.shared) as usize,
820            std::sync::atomic::Ordering::SeqCst,
821        );
822        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
823            team.linear_q8(&x_q, &[1.0], &weight, None, 1, &mut out, Int8Tier::Scalar);
824        }));
825        assert!(
826            outcome.is_err(),
827            "a worker panic must surface at the caller, not hang or pass"
828        );
829        // And the team must still be usable afterwards.
830        team.linear_q8(&x_q, &[1.0], &weight, None, 1, &mut out, Int8Tier::Scalar);
831        assert!(out.iter().all(|value| value.is_finite()));
832    }
833
834    fn matrix(n: usize, k: usize, seed: u64) -> QuantizedMatrix {
835        let mut state = seed;
836        let data: Vec<i8> = (0..n * k)
837            .map(|_| {
838                state = state
839                    .wrapping_mul(6_364_136_223_846_793_005)
840                    .wrapping_add(1);
841                (((state >> 33) % 255) as i32 - 127) as i8
842            })
843            .collect();
844        let scales: Vec<f32> = (0..n).map(|row| 0.001 + (row % 7) as f32 * 0.01).collect();
845        QuantizedMatrix { data, scales, n, k }
846    }
847
848    /// A directly constructed team, so the test controls the partition count regardless of the
849    /// process environment.
850    fn test_team(partitions: usize) -> Team {
851        let shared: &'static Shared = Box::leak(Box::new(Shared {
852            control: Mutex::new(Control {
853                generation: 0,
854                job: None,
855                remaining: 0,
856                panicked: false,
857            }),
858            go: Condvar::new(),
859            done: Condvar::new(),
860        }));
861        for worker in 1..partitions {
862            std::thread::spawn(move || worker_loop(shared, worker));
863        }
864        Team {
865            shared,
866            partitions,
867            dispatch_gate: Mutex::new(()),
868        }
869    }
870
871    #[test]
872    fn every_partition_count_is_bit_identical_to_serial_at_model_shapes() {
873        for &(m, n, k) in &[
874            (1_usize, 2048_usize, 1024_usize),
875            (1, 1024, 3072),
876            (16, 3072, 1024),
877            (2, 517, 129), // deliberately ragged: tail partitions and odd K
878        ] {
879            let weight = matrix(n, k, 42 ^ (n as u64) << 20);
880            let x_q: Vec<i8> = (0..m * k).map(|i| ((i * 31 + 7) % 255) as i8).collect();
881            let x_scales: Vec<f32> = (0..m).map(|row| 0.02 + row as f32 * 0.005).collect();
882            let mut serial = vec![0.0_f32; m * n];
883            linear_q8(
884                &x_q,
885                &x_scales,
886                &weight,
887                None,
888                m,
889                &mut serial,
890                Int8Tier::Scalar,
891            );
892            for partitions in [2_usize, 3, 4, 8] {
893                let team = test_team(partitions);
894                let mut parallel = vec![0.0_f32; m * n];
895                team.linear_q8(
896                    &x_q,
897                    &x_scales,
898                    &weight,
899                    None,
900                    m,
901                    &mut parallel,
902                    Int8Tier::Scalar,
903                );
904                for (index, (a, b)) in serial.iter().zip(&parallel).enumerate() {
905                    assert_eq!(
906                        a.to_bits(),
907                        b.to_bits(),
908                        "partitions={partitions} m={m} n={n} k={k} element {index}"
909                    );
910                }
911            }
912        }
913    }
914
915    #[test]
916    fn w8a16_partitioning_is_bit_identical_to_serial_at_model_shapes() {
917        for &(m, n, k) in &[
918            (1_usize, 2048_usize, 1024_usize),
919            (1, 1024, 3072),
920            (16, 3072, 1024),
921            (2, 517, 129), // deliberately ragged: tail partitions and odd K
922        ] {
923            let weight = matrix(n, k, 97 ^ (n as u64) << 20);
924            let x: Vec<f32> = (0..m * k)
925                .map(|i| ((i * 37 + 11) % 255) as f32 / 64.0 - 1.5)
926                .collect();
927            let bias: Vec<f32> = (0..n).map(|col| (col % 13) as f32 * 0.25 - 1.0).collect();
928            // A genuinely serial reference: the bypass keeps the entry point off the
929            // process-wide team even though these shapes clear its fan-out threshold.
930            let mut serial = vec![0.0_f32; m * n];
931            with_team_bypassed(|| {
932                linear_w8a16(&x, &weight, Some(&bias), m, &mut serial);
933            });
934            for partitions in [2_usize, 3, 4, 8] {
935                let team = test_team(partitions);
936                let mut parallel = vec![0.0_f32; m * n];
937                team.linear_w8a16(&x, &weight, Some(&bias), m, &mut parallel);
938                for (index, (a, b)) in serial.iter().zip(&parallel).enumerate() {
939                    assert_eq!(
940                        a.to_bits(),
941                        b.to_bits(),
942                        "w8a16 partitions={partitions} m={m} n={n} k={k} element {index}"
943                    );
944                }
945            }
946        }
947    }
948
949    #[test]
950    fn thousands_of_mixed_dispatches_complete_without_deadlock() {
951        // The many_utterances_without_deadlock policy at kernel scale: hammer one team with
952        // mixed shapes; a hang here fails by test-harness timeout rather than passing silently.
953        let team = test_team(4);
954        let weight_a = matrix(256, 512, 7);
955        let weight_b = matrix(96, 128, 11);
956        let x_a: Vec<i8> = vec![3; 512];
957        let x_b: Vec<i8> = vec![-5; 2 * 128];
958        let x_c: Vec<f32> = vec![0.75; 512];
959        let mut out_a = vec![0.0_f32; 256];
960        let mut out_b = vec![0.0_f32; 2 * 96];
961        let mut out_c = vec![0.0_f32; 256];
962        for _ in 0..2_000 {
963            team.linear_q8(
964                &x_a,
965                &[0.5],
966                &weight_a,
967                None,
968                1,
969                &mut out_a,
970                Int8Tier::Scalar,
971            );
972            team.linear_q8(
973                &x_b,
974                &[0.5, 0.25],
975                &weight_b,
976                None,
977                2,
978                &mut out_b,
979                Int8Tier::Scalar,
980            );
981            team.linear_w8a16(&x_c, &weight_a, None, 1, &mut out_c);
982        }
983        assert!(out_a.iter().all(|value| value.is_finite()));
984        assert!(out_b.iter().all(|value| value.is_finite()));
985        assert!(out_c.iter().all(|value| value.is_finite()));
986    }
987
988    #[test]
989    fn attention_partitioning_is_bit_identical_to_serial_at_talker_geometry() {
990        // Talker decode shape: 16 query heads / 8 KV heads / head_dim 128, growing KV; plus a
991        // prefill-like seq>1 case and a ragged 5-partition split of 16 heads.
992        for &(query_positions, key_positions) in &[(1_usize, 37_usize), (4, 24)] {
993            let (q_heads, kv_heads, head_dim) = (16_usize, 8_usize, 128_usize);
994            let queries = values_of(query_positions * q_heads * head_dim, 21);
995            let keys = values_of(key_positions * kv_heads * head_dim, 22);
996            let values = values_of(key_positions * kv_heads * head_dim, 23);
997            let mut mask = vec![0.0_f32; query_positions * key_positions];
998            for (index, slot) in mask.iter_mut().enumerate() {
999                if index % 11 == 3 {
1000                    *slot = f32::NEG_INFINITY;
1001                }
1002            }
1003            let mut serial = vec![0.0_f32; queries.len()];
1004            crate::f32ref::gqa_attention(
1005                &queries,
1006                &keys,
1007                &values,
1008                &mask,
1009                query_positions,
1010                key_positions,
1011                q_heads,
1012                kv_heads,
1013                head_dim,
1014                &mut serial,
1015            );
1016            for partitions in [2_usize, 5, 8] {
1017                let team = test_team(partitions);
1018                let mut parallel = vec![0.0_f32; queries.len()];
1019                team.gqa_attention(
1020                    &queries,
1021                    &keys,
1022                    &values,
1023                    &mask,
1024                    query_positions,
1025                    key_positions,
1026                    q_heads,
1027                    kv_heads,
1028                    head_dim,
1029                    &mut parallel,
1030                );
1031                assert_eq!(
1032                    serial.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
1033                    parallel.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
1034                    "partitions={partitions} qp={query_positions}"
1035                );
1036            }
1037        }
1038    }
1039
1040    fn values_of(len: usize, seed: u64) -> Vec<f32> {
1041        let mut state = seed;
1042        (0..len)
1043            .map(|_| {
1044                state = state
1045                    .wrapping_mul(6_364_136_223_846_793_005)
1046                    .wrapping_add(1);
1047                ((state >> 33) as f32 / (1u64 << 31) as f32) - 0.5
1048            })
1049            .collect()
1050    }
1051
1052    #[test]
1053    fn bias_reaches_every_partition() {
1054        let (m, n, k) = (2_usize, 130_usize, 64_usize);
1055        let weight = matrix(n, k, 99);
1056        let bias: Vec<f32> = (0..n).map(|i| i as f32).collect();
1057        let x_q: Vec<i8> = vec![1; m * k];
1058        let x_scales = vec![1.0_f32; m];
1059        let mut serial = vec![0.0_f32; m * n];
1060        linear_q8(
1061            &x_q,
1062            &x_scales,
1063            &weight,
1064            Some(&bias),
1065            m,
1066            &mut serial,
1067            Int8Tier::Scalar,
1068        );
1069        let team = test_team(3);
1070        let mut parallel = vec![0.0_f32; m * n];
1071        team.linear_q8(
1072            &x_q,
1073            &x_scales,
1074            &weight,
1075            Some(&bias),
1076            m,
1077            &mut parallel,
1078            Int8Tier::Scalar,
1079        );
1080        assert_eq!(
1081            serial.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
1082            parallel.iter().map(|v| v.to_bits()).collect::<Vec<_>>()
1083        );
1084    }
1085}