1use crate::int8::{Int8Tier, QuantizedMatrix, dot_i32};
27use std::sync::{Condvar, Mutex, OnceLock};
28
29#[derive(Clone, Copy)]
31enum Job {
32 Linear(LinearJob),
34 Attention(AttentionJob),
36 F32Linear(F32LinearJob),
38}
39
40#[derive(Clone, Copy)]
50struct F32LinearJob {
51 x: *const f32,
52 weight: *const f32,
53 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 bias: *const f32,
70 out: *mut f32,
71 m: usize,
72 n: usize,
73 k: usize,
74 tier: Int8Tier,
75 partitions: usize,
77}
78
79#[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
99unsafe impl Send for Job {}
103unsafe impl Sync for Job {}
106
107struct Control {
108 generation: u64,
109 job: Option<Job>,
110 remaining: usize,
111 panicked: bool,
114}
115
116struct Shared {
117 control: Mutex<Control>,
118 go: Condvar,
119 done: Condvar,
120}
121
122pub struct Team {
124 shared: &'static Shared,
125 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#[must_use]
139pub fn partitions() -> usize {
140 armed().map_or(1, |team| team.partitions)
141}
142
143pub fn bypass_team_on_this_thread() {
149 TEAM_BYPASS.with(|cell| cell.set(true));
150}
151
152#[must_use]
154pub fn thread_bypassed() -> bool {
155 TEAM_BYPASS.with(std::cell::Cell::get)
156}
157
158pub fn armed() -> Option<&'static Team> {
164 #[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#[cfg(target_arch = "wasm32")]
179static WASM_TEAM: OnceLock<Option<Team>> = OnceLock::new();
180
181#[cfg(target_arch = "wasm32")]
183static WASM_SHARED: OnceLock<&'static Shared> = OnceLock::new();
184
185#[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#[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#[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 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 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 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
324fn 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
334fn 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
348fn 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 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 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 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 unsafe {
426 *job.out.add(row * job.n + col) = bias_term.map_or(value, |b| value + b);
427 }
428 }
429 }
430}
431
432fn run_f32_linear_partition(job: &F32LinearJob, worker: usize) {
434 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 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 #[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 #[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 #[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 fn dispatch(&self, job: Job) {
598 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 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 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 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 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), ] {
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(¶llel).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 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 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}