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