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