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}
37
38#[derive(Clone, Copy)]
39struct LinearJob {
40 x_q: *const i8,
41 x_scales: *const f32,
42 w_data: *const i8,
43 w_scales: *const f32,
44 bias: *const f32,
46 out: *mut f32,
47 m: usize,
48 n: usize,
49 k: usize,
50 tier: Int8Tier,
51 partitions: usize,
53}
54
55#[derive(Clone, Copy)]
61struct AttentionJob {
62 queries: *const f32,
63 keys: *const f32,
64 values: *const f32,
65 mask: *const f32,
66 query_positions: usize,
67 key_positions: usize,
68 q_heads: usize,
69 kv_heads: usize,
70 head_dim: usize,
71 out: *mut f32,
72 partitions: usize,
73}
74
75unsafe impl Send for Job {}
79unsafe impl Sync for Job {}
82
83struct Control {
84 generation: u64,
85 job: Option<Job>,
86 remaining: usize,
87 panicked: bool,
90}
91
92struct Shared {
93 control: Mutex<Control>,
94 go: Condvar,
95 done: Condvar,
96}
97
98pub struct Team {
100 shared: &'static Shared,
101 partitions: usize,
103 dispatch_gate: Mutex<()>,
104}
105
106thread_local! {
107 static TEAM_BYPASS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
108}
109
110pub fn bypass_team_on_this_thread() {
116 TEAM_BYPASS.with(|cell| cell.set(true));
117}
118
119#[must_use]
121pub fn thread_bypassed() -> bool {
122 TEAM_BYPASS.with(std::cell::Cell::get)
123}
124
125pub fn armed() -> Option<&'static Team> {
131 #[cfg(target_arch = "wasm32")]
134 {
135 return None;
136 }
137 #[cfg(not(target_arch = "wasm32"))]
138 armed_native()
139}
140
141#[cfg(not(target_arch = "wasm32"))]
142fn armed_native() -> Option<&'static Team> {
143 static TEAM: OnceLock<Option<Team>> = OnceLock::new();
144 TEAM.get_or_init(|| {
145 let ceiling = std::thread::available_parallelism().map_or(1, usize::from);
146 let requested: usize = std::env::var("FTTS_INT8_THREADS")
149 .ok()
150 .and_then(|value| value.parse().ok())
151 .unwrap_or(6);
152 let partitions = requested.min(ceiling);
153 if partitions <= 1 {
154 return None;
155 }
156 let shared: &'static Shared = Box::leak(Box::new(Shared {
157 control: Mutex::new(Control {
158 generation: 0,
159 job: None,
160 remaining: 0,
161 panicked: false,
162 }),
163 go: Condvar::new(),
164 done: Condvar::new(),
165 }));
166 for worker in 1..partitions {
169 std::thread::Builder::new()
170 .name(format!("ftts-int8-{worker}"))
171 .spawn(move || worker_loop(shared, worker))
172 .expect("spawn int8 worker");
173 }
174 Some(Team {
175 shared,
176 partitions,
177 dispatch_gate: Mutex::new(()),
178 })
179 })
180 .as_ref()
181}
182
183fn worker_loop(shared: &'static Shared, worker: usize) {
184 let mut seen = 0_u64;
185 loop {
186 let job = {
187 let mut control = lock_control(shared);
188 while control.generation == seen {
189 control = shared
190 .go
191 .wait(control)
192 .unwrap_or_else(std::sync::PoisonError::into_inner);
193 }
194 seen = control.generation;
195 control.job.expect("generation bumped without a job")
196 };
197 let outcome = std::panic::catch_unwind(|| run_partition(&job, worker));
201 let mut control = lock_control(shared);
202 if outcome.is_err() {
203 control.panicked = true;
204 }
205 control.remaining -= 1;
206 if control.remaining == 0 {
207 shared.done.notify_all();
208 }
209 }
210}
211
212fn lock_control(shared: &Shared) -> std::sync::MutexGuard<'_, Control> {
216 shared
217 .control
218 .lock()
219 .unwrap_or_else(std::sync::PoisonError::into_inner)
220}
221
222fn run_partition(job: &Job, worker: usize) {
225 #[cfg(test)]
226 if worker > 0 && tests::PANIC_INJECT.swap(false, std::sync::atomic::Ordering::SeqCst) {
227 panic!("injected worker panic for the hang-hardening test");
228 }
229 match job {
230 Job::Linear(job) => run_linear_partition(job, worker),
231 Job::Attention(job) => run_attention_partition(job, worker),
232 }
233}
234
235fn run_attention_partition(job: &AttentionJob, worker: usize) {
238 let chunk = job.q_heads.div_ceil(job.partitions);
239 let start = (worker * chunk).min(job.q_heads);
240 let end = ((worker + 1) * chunk).min(job.q_heads);
241 if start >= end {
242 return;
243 }
244 let (queries, keys, values, mask) = unsafe {
249 (
250 std::slice::from_raw_parts(
251 job.queries,
252 job.query_positions * job.q_heads * job.head_dim,
253 ),
254 std::slice::from_raw_parts(job.keys, job.key_positions * job.kv_heads * job.head_dim),
255 std::slice::from_raw_parts(job.values, job.key_positions * job.kv_heads * job.head_dim),
256 std::slice::from_raw_parts(job.mask, job.query_positions * job.key_positions),
257 )
258 };
259 unsafe {
263 crate::f32ref::gqa_attention_head_range_into(
264 queries,
265 keys,
266 values,
267 mask,
268 job.query_positions,
269 job.key_positions,
270 job.q_heads,
271 job.kv_heads,
272 job.head_dim,
273 crate::f32ref::F32SoftmaxArithmetic::ReciprocalMultiply,
274 crate::f32ref::F32LinearAccumulation::Scalar,
275 start..end,
276 job.out,
277 );
278 }
279}
280
281fn run_linear_partition(job: &LinearJob, worker: usize) {
282 let chunk = job.n.div_ceil(job.partitions);
283 let start = (worker * chunk).min(job.n);
284 let end = ((worker + 1) * chunk).min(job.n);
285 if start >= end {
286 return;
287 }
288 let (x_q, x_scales, w_data, w_scales, bias) = unsafe {
294 (
295 std::slice::from_raw_parts(job.x_q, job.m * job.k),
296 std::slice::from_raw_parts(job.x_scales, job.m),
297 std::slice::from_raw_parts(job.w_data, job.n * job.k),
298 std::slice::from_raw_parts(job.w_scales, job.n),
299 (!job.bias.is_null()).then(|| std::slice::from_raw_parts(job.bias, job.n)),
300 )
301 };
302 for col in start..end {
303 let w_row = &w_data[col * job.k..(col + 1) * job.k];
304 let w_scale = w_scales[col];
305 let bias_term = bias.map(|b| b[col]);
306 for row in 0..job.m {
307 let x_row = &x_q[row * job.k..(row + 1) * job.k];
308 let acc = dot_i32(x_row, w_row, job.tier);
309 let value = acc as f32 * (x_scales[row] * w_scale);
310 unsafe {
313 *job.out.add(row * job.n + col) = bias_term.map_or(value, |b| value + b);
314 }
315 }
316 }
317}
318
319impl Team {
320 #[allow(clippy::too_many_arguments)]
326 pub fn linear_q8(
327 &self,
328 x_q: &[i8],
329 x_scales: &[f32],
330 weight: &QuantizedMatrix,
331 bias: Option<&[f32]>,
332 m: usize,
333 out: &mut [f32],
334 tier: Int8Tier,
335 ) {
336 let (n, k) = (weight.n, weight.k);
337 assert_eq!(x_q.len(), m * k, "x_q must be [m, k]");
338 assert_eq!(x_scales.len(), m, "x_scales must be [m]");
339 assert_eq!(out.len(), m * n, "out must be [m, n]");
340 if let Some(bias) = bias {
341 assert_eq!(bias.len(), n, "bias must be [n]");
342 }
343
344 let job = Job::Linear(LinearJob {
345 x_q: x_q.as_ptr(),
346 x_scales: x_scales.as_ptr(),
347 w_data: weight.data.as_ptr(),
348 w_scales: weight.scales.as_ptr(),
349 bias: bias.map_or(std::ptr::null(), <[f32]>::as_ptr),
350 out: out.as_mut_ptr(),
351 m,
352 n,
353 k,
354 tier,
355 partitions: self.partitions,
356 });
357
358 self.dispatch(job);
359 }
360
361 #[allow(clippy::too_many_arguments)]
368 pub fn gqa_attention(
369 &self,
370 queries: &[f32],
371 keys: &[f32],
372 values: &[f32],
373 mask: &[f32],
374 query_positions: usize,
375 key_positions: usize,
376 q_heads: usize,
377 kv_heads: usize,
378 head_dim: usize,
379 out: &mut [f32],
380 ) {
381 assert!(
382 kv_heads > 0 && q_heads.is_multiple_of(kv_heads),
383 "GQA head geometry"
384 );
385 assert_eq!(
386 queries.len(),
387 query_positions * q_heads * head_dim,
388 "queries shape"
389 );
390 assert_eq!(
391 keys.len(),
392 key_positions * kv_heads * head_dim,
393 "keys shape"
394 );
395 assert_eq!(
396 values.len(),
397 key_positions * kv_heads * head_dim,
398 "values shape"
399 );
400 assert_eq!(mask.len(), query_positions * key_positions, "mask shape");
401 assert_eq!(out.len(), query_positions * q_heads * head_dim, "out shape");
402 let job = Job::Attention(AttentionJob {
403 queries: queries.as_ptr(),
404 keys: keys.as_ptr(),
405 values: values.as_ptr(),
406 mask: mask.as_ptr(),
407 query_positions,
408 key_positions,
409 q_heads,
410 kv_heads,
411 head_dim,
412 out: out.as_mut_ptr(),
413 partitions: self.partitions,
414 });
415 self.dispatch(job);
416 }
417
418 fn dispatch(&self, job: Job) {
420 let _gate = self
424 .dispatch_gate
425 .lock()
426 .unwrap_or_else(std::sync::PoisonError::into_inner);
427 {
428 let mut control = lock_control(self.shared);
429 control.job = Some(job);
430 control.generation += 1;
431 control.remaining = self.partitions - 1;
432 control.panicked = false;
433 self.shared.go.notify_all();
434 }
435
436 let caller_outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
440 run_partition(&job, 0);
441 }));
442
443 let mut control = lock_control(self.shared);
444 while control.remaining > 0 {
445 control = self
446 .shared
447 .done
448 .wait(control)
449 .unwrap_or_else(std::sync::PoisonError::into_inner);
450 }
451 control.job = None;
452 let worker_panicked = control.panicked;
453 drop(control);
454
455 if let Err(payload) = caller_outcome {
456 std::panic::resume_unwind(payload);
457 }
458 assert!(
459 !worker_panicked,
460 "a team worker panicked during this dispatch; the output buffer is not fully written"
461 );
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468 use crate::int8::linear_q8;
469
470 pub(super) static PANIC_INJECT: std::sync::atomic::AtomicBool =
472 std::sync::atomic::AtomicBool::new(false);
473
474 #[test]
475 fn a_panicking_worker_fails_the_dispatch_loudly_instead_of_hanging() {
476 let team = test_team(3);
477 let weight = matrix(64, 32, 5);
478 let x_q = vec![1_i8; 32];
479 let mut out = vec![0.0_f32; 64];
480 PANIC_INJECT.store(true, std::sync::atomic::Ordering::SeqCst);
481 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
482 team.linear_q8(&x_q, &[1.0], &weight, None, 1, &mut out, Int8Tier::Scalar);
483 }));
484 assert!(
485 outcome.is_err(),
486 "a worker panic must surface at the caller, not hang or pass"
487 );
488 team.linear_q8(&x_q, &[1.0], &weight, None, 1, &mut out, Int8Tier::Scalar);
490 assert!(out.iter().all(|value| value.is_finite()));
491 }
492
493 fn matrix(n: usize, k: usize, seed: u64) -> QuantizedMatrix {
494 let mut state = seed;
495 let data: Vec<i8> = (0..n * k)
496 .map(|_| {
497 state = state
498 .wrapping_mul(6_364_136_223_846_793_005)
499 .wrapping_add(1);
500 (((state >> 33) % 255) as i32 - 127) as i8
501 })
502 .collect();
503 let scales: Vec<f32> = (0..n).map(|row| 0.001 + (row % 7) as f32 * 0.01).collect();
504 QuantizedMatrix { data, scales, n, k }
505 }
506
507 fn test_team(partitions: usize) -> Team {
510 let shared: &'static Shared = Box::leak(Box::new(Shared {
511 control: Mutex::new(Control {
512 generation: 0,
513 job: None,
514 remaining: 0,
515 panicked: false,
516 }),
517 go: Condvar::new(),
518 done: Condvar::new(),
519 }));
520 for worker in 1..partitions {
521 std::thread::spawn(move || worker_loop(shared, worker));
522 }
523 Team {
524 shared,
525 partitions,
526 dispatch_gate: Mutex::new(()),
527 }
528 }
529
530 #[test]
531 fn every_partition_count_is_bit_identical_to_serial_at_model_shapes() {
532 for &(m, n, k) in &[
533 (1_usize, 2048_usize, 1024_usize),
534 (1, 1024, 3072),
535 (16, 3072, 1024),
536 (2, 517, 129), ] {
538 let weight = matrix(n, k, 42 ^ (n as u64) << 20);
539 let x_q: Vec<i8> = (0..m * k).map(|i| ((i * 31 + 7) % 255) as i8).collect();
540 let x_scales: Vec<f32> = (0..m).map(|row| 0.02 + row as f32 * 0.005).collect();
541 let mut serial = vec![0.0_f32; m * n];
542 linear_q8(
543 &x_q,
544 &x_scales,
545 &weight,
546 None,
547 m,
548 &mut serial,
549 Int8Tier::Scalar,
550 );
551 for partitions in [2_usize, 3, 4, 8] {
552 let team = test_team(partitions);
553 let mut parallel = vec![0.0_f32; m * n];
554 team.linear_q8(
555 &x_q,
556 &x_scales,
557 &weight,
558 None,
559 m,
560 &mut parallel,
561 Int8Tier::Scalar,
562 );
563 for (index, (a, b)) in serial.iter().zip(¶llel).enumerate() {
564 assert_eq!(
565 a.to_bits(),
566 b.to_bits(),
567 "partitions={partitions} m={m} n={n} k={k} element {index}"
568 );
569 }
570 }
571 }
572 }
573
574 #[test]
575 fn thousands_of_mixed_dispatches_complete_without_deadlock() {
576 let team = test_team(4);
579 let weight_a = matrix(256, 512, 7);
580 let weight_b = matrix(96, 128, 11);
581 let x_a: Vec<i8> = vec![3; 512];
582 let x_b: Vec<i8> = vec![-5; 2 * 128];
583 let mut out_a = vec![0.0_f32; 256];
584 let mut out_b = vec![0.0_f32; 2 * 96];
585 for _ in 0..2_000 {
586 team.linear_q8(
587 &x_a,
588 &[0.5],
589 &weight_a,
590 None,
591 1,
592 &mut out_a,
593 Int8Tier::Scalar,
594 );
595 team.linear_q8(
596 &x_b,
597 &[0.5, 0.25],
598 &weight_b,
599 None,
600 2,
601 &mut out_b,
602 Int8Tier::Scalar,
603 );
604 }
605 assert!(out_a.iter().all(|value| value.is_finite()));
606 assert!(out_b.iter().all(|value| value.is_finite()));
607 }
608
609 #[test]
610 fn attention_partitioning_is_bit_identical_to_serial_at_talker_geometry() {
611 for &(query_positions, key_positions) in &[(1_usize, 37_usize), (4, 24)] {
614 let (q_heads, kv_heads, head_dim) = (16_usize, 8_usize, 128_usize);
615 let queries = values_of(query_positions * q_heads * head_dim, 21);
616 let keys = values_of(key_positions * kv_heads * head_dim, 22);
617 let values = values_of(key_positions * kv_heads * head_dim, 23);
618 let mut mask = vec![0.0_f32; query_positions * key_positions];
619 for (index, slot) in mask.iter_mut().enumerate() {
620 if index % 11 == 3 {
621 *slot = f32::NEG_INFINITY;
622 }
623 }
624 let mut serial = vec![0.0_f32; queries.len()];
625 crate::f32ref::gqa_attention(
626 &queries,
627 &keys,
628 &values,
629 &mask,
630 query_positions,
631 key_positions,
632 q_heads,
633 kv_heads,
634 head_dim,
635 &mut serial,
636 );
637 for partitions in [2_usize, 5, 8] {
638 let team = test_team(partitions);
639 let mut parallel = vec![0.0_f32; queries.len()];
640 team.gqa_attention(
641 &queries,
642 &keys,
643 &values,
644 &mask,
645 query_positions,
646 key_positions,
647 q_heads,
648 kv_heads,
649 head_dim,
650 &mut parallel,
651 );
652 assert_eq!(
653 serial.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
654 parallel.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
655 "partitions={partitions} qp={query_positions}"
656 );
657 }
658 }
659 }
660
661 fn values_of(len: usize, seed: u64) -> Vec<f32> {
662 let mut state = seed;
663 (0..len)
664 .map(|_| {
665 state = state
666 .wrapping_mul(6_364_136_223_846_793_005)
667 .wrapping_add(1);
668 ((state >> 33) as f32 / (1u64 << 31) as f32) - 0.5
669 })
670 .collect()
671 }
672
673 #[test]
674 fn bias_reaches_every_partition() {
675 let (m, n, k) = (2_usize, 130_usize, 64_usize);
676 let weight = matrix(n, k, 99);
677 let bias: Vec<f32> = (0..n).map(|i| i as f32).collect();
678 let x_q: Vec<i8> = vec![1; m * k];
679 let x_scales = vec![1.0_f32; m];
680 let mut serial = vec![0.0_f32; m * n];
681 linear_q8(
682 &x_q,
683 &x_scales,
684 &weight,
685 Some(&bias),
686 m,
687 &mut serial,
688 Int8Tier::Scalar,
689 );
690 let team = test_team(3);
691 let mut parallel = vec![0.0_f32; m * n];
692 team.linear_q8(
693 &x_q,
694 &x_scales,
695 &weight,
696 Some(&bias),
697 m,
698 &mut parallel,
699 Int8Tier::Scalar,
700 );
701 assert_eq!(
702 serial.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
703 parallel.iter().map(|v| v.to_bits()).collect::<Vec<_>>()
704 );
705 }
706}