1use std::sync::OnceLock;
25
26pub const Q8_MAX_ABS: i8 = 127;
28
29const TEAM_WORK_THRESHOLD_BYTES: usize = 512 * 1024;
34
35pub fn quantize_row_q8(row: &[f32], output: &mut [i8]) -> f32 {
49 assert_eq!(output.len(), row.len(), "quantize output length mismatch");
50 let mut maximum = 0.0_f32;
51 for (index, &value) in row.iter().enumerate() {
52 assert!(
53 value.is_finite(),
54 "non-finite value {value} at index {index} reached the Q8 quantizer"
55 );
56 maximum = maximum.max(value.abs());
57 }
58 if maximum == 0.0 {
59 output.fill(0);
60 return 1.0;
61 }
62 let scale = maximum / 127.0;
63 if scale == 0.0 {
64 output.fill(0);
67 return 1.0;
68 }
69 for (&value, slot) in row.iter().zip(output.iter_mut()) {
70 let rounded = (value / scale).clamp(-127.0, 127.0).round_ties_even();
71 *slot = rounded as i8;
74 }
75 scale
76}
77
78#[derive(Clone, Debug)]
83pub struct QuantizedMatrix {
84 pub data: Vec<i8>,
86 pub scales: Vec<f32>,
88 pub n: usize,
90 pub k: usize,
92}
93
94impl QuantizedMatrix {
95 #[must_use]
105 pub fn concat_rows(parts: &[&Self]) -> Self {
106 let k = parts.first().expect("at least one part").k;
107 assert!(parts.iter().all(|part| part.k == k), "parts must share k");
108 let n = parts.iter().map(|part| part.n).sum();
109 let mut data = Vec::with_capacity(n * k);
110 let mut scales = Vec::with_capacity(n);
111 for part in parts {
112 data.extend_from_slice(&part.data);
113 scales.extend_from_slice(&part.scales);
114 }
115 Self { data, scales, n, k }
116 }
117
118 #[must_use]
124 pub fn quantize(weight: &[f32], n: usize, k: usize) -> Self {
125 assert_eq!(weight.len(), n * k, "weight must be [n, k]");
126 let mut data = vec![0_i8; n * k];
127 let mut scales = vec![0.0_f32; n];
128 for ((weight_row, data_row), scale) in weight
129 .chunks_exact(k)
130 .zip(data.chunks_exact_mut(k))
131 .zip(scales.iter_mut())
132 {
133 *scale = quantize_row_q8(weight_row, data_row);
134 }
135 Self { data, scales, n, k }
136 }
137}
138
139#[derive(Clone, Copy, Debug, Eq, PartialEq)]
145pub enum Int8Tier {
146 Scalar,
148 Autovec,
153 NeonSdot,
155}
156
157impl Int8Tier {
158 #[must_use]
160 pub const fn as_str(self) -> &'static str {
161 match self {
162 Self::Scalar => "scalar",
163 Self::Autovec => "autovec",
164 Self::NeonSdot => "neon-sdot",
165 }
166 }
167
168 #[must_use]
170 pub fn available() -> Vec<Self> {
171 let mut tiers = vec![Self::Scalar, Self::Autovec];
172 if neon_sdot_available() {
173 tiers.push(Self::NeonSdot);
174 }
175 tiers
176 }
177
178 #[must_use]
188 pub fn dispatch() -> Self {
189 match std::env::var("FTTS_INT8_TIER").as_deref() {
190 Ok("scalar") => Self::Scalar,
191 Ok("autovec") => Self::Autovec,
192 Ok("neon-sdot") if neon_sdot_available() => Self::NeonSdot,
193 _ if neon_sdot_available() => Self::NeonSdot,
194 _ => Self::Scalar,
195 }
196 }
197}
198
199#[derive(Clone, Copy, Debug, Eq, PartialEq)]
209pub enum QuantLinearMode {
210 W8A8(Int8Tier),
212 W8A16,
214}
215
216impl QuantLinearMode {
217 #[must_use]
219 pub const fn as_str(self) -> &'static str {
220 match self {
221 Self::W8A8(_) => "w8a8",
222 Self::W8A16 => "w8a16",
223 }
224 }
225}
226
227pub fn linear_w8a16(
238 x: &[f32],
239 weight: &QuantizedMatrix,
240 bias: Option<&[f32]>,
241 m: usize,
242 out: &mut [f32],
243) {
244 let (n, k) = (weight.n, weight.k);
245 assert_eq!(x.len(), m * k, "x must be [m, k]");
246 assert_eq!(out.len(), m * n, "out must be [m, n]");
247 if let Some(bias) = bias {
248 assert_eq!(bias.len(), n, "bias must be [n]");
249 }
250 for col in 0..n {
251 let w_row = &weight.data[col * k..(col + 1) * k];
252 let w_scale = weight.scales[col];
253 let bias_term = bias.map(|b| b[col]);
254 for row in 0..m {
255 let x_row = &x[row * k..(row + 1) * k];
256 let acc = dot_w8a16(x_row, w_row);
257 let value = acc * w_scale;
258 out[row * n + col] = bias_term.map_or(value, |b| value + b);
259 }
260 }
261}
262
263fn dot_w8a16(x: &[f32], w: &[i8]) -> f32 {
265 const LANES: usize = 8;
266 let mut lanes = [0.0_f32; LANES];
267 let chunks = x.len() / LANES;
268 for chunk in 0..chunks {
269 let base = chunk * LANES;
270 for lane in 0..LANES {
271 lanes[lane] = f32::from(w[base + lane]).mul_add(x[base + lane], lanes[lane]);
272 }
273 }
274 let mut sum: f32 = lanes.iter().sum();
275 for index in chunks * LANES..x.len() {
276 sum = f32::from(w[index]).mul_add(x[index], sum);
277 }
278 sum
279}
280
281#[must_use]
287pub fn quant_mode_from_environment() -> QuantLinearMode {
288 match std::env::var("FTTS_INT8").as_deref() {
289 Ok("w8a16") => QuantLinearMode::W8A16,
290 _ => QuantLinearMode::W8A8(autotuned_plan().decode_gemv),
291 }
292}
293
294pub fn quant_linear(
296 mode: QuantLinearMode,
297 x: &[f32],
298 weight: &QuantizedMatrix,
299 bias: Option<&[f32]>,
300 m: usize,
301 out: &mut [f32],
302) {
303 match mode {
304 QuantLinearMode::W8A8(tier) => linear_q8_dynamic(x, weight, bias, m, out, tier),
305 QuantLinearMode::W8A16 => linear_w8a16(x, weight, bias, m, out),
306 }
307}
308
309#[derive(Clone, Copy, Debug, Eq, PartialEq)]
315pub struct KernelPlanV0 {
316 pub decode_gemv: Int8Tier,
318 pub batch_gemm: Int8Tier,
320}
321
322pub fn autotuned_plan() -> KernelPlanV0 {
328 static PLAN: OnceLock<KernelPlanV0> = OnceLock::new();
329 *PLAN.get_or_init(|| {
330 if std::env::var("FTTS_INT8_TIER").is_ok() {
331 let forced = Int8Tier::dispatch();
332 return KernelPlanV0 {
333 decode_gemv: forced,
334 batch_gemm: forced,
335 };
336 }
337 if let Some(cached) = load_persisted_plan() {
338 return cached;
339 }
340 let plan = KernelPlanV0 {
341 decode_gemv: fastest_tier(&[(1, 1024, 256), (1, 3072, 256)]),
345 batch_gemm: fastest_tier(&[(16, 1024, 128), (16, 3072, 64)]),
347 };
348 persist_plan(plan);
349 plan
350 })
351}
352
353fn plan_cache_path() -> Option<std::path::PathBuf> {
356 std::env::var_os("HOME")
357 .map(|home| std::path::PathBuf::from(home).join(".cache/franken_tts/kernel_plan_v0.txt"))
358}
359
360fn plan_cache_key() -> String {
362 let tiers: Vec<&str> = Int8Tier::available().iter().map(|t| t.as_str()).collect();
363 format!(
364 "v0|crate={}|tiers={}",
365 env!("CARGO_PKG_VERSION"),
366 tiers.join(",")
367 )
368}
369
370fn load_persisted_plan() -> Option<KernelPlanV0> {
371 let text = {
374 use std::io::Read as _;
375 let mut text = String::new();
376 let file = std::fs::File::open(plan_cache_path()?).ok()?;
377 file.take(512).read_to_string(&mut text).ok()?;
378 text
379 };
380 let mut lines = text.lines();
381 if lines.next()? != plan_cache_key() {
382 return None;
383 }
384 let parse = |line: &str| match line {
385 "scalar" => Some(Int8Tier::Scalar),
386 "autovec" => Some(Int8Tier::Autovec),
387 "neon-sdot" if neon_sdot_available() => Some(Int8Tier::NeonSdot),
388 _ => None,
389 };
390 Some(KernelPlanV0 {
391 decode_gemv: parse(lines.next()?)?,
392 batch_gemm: parse(lines.next()?)?,
393 })
394}
395
396fn persist_plan(plan: KernelPlanV0) {
397 let Some(path) = plan_cache_path() else {
398 return;
399 };
400 if let Some(parent) = path.parent() {
401 let _ = std::fs::create_dir_all(parent);
402 }
403 let _ = std::fs::write(
405 path,
406 format!(
407 "{}\n{}\n{}\n",
408 plan_cache_key(),
409 plan.decode_gemv.as_str(),
410 plan.batch_gemm.as_str()
411 ),
412 );
413}
414
415fn fastest_tier(probes: &[(usize, usize, usize)]) -> Int8Tier {
419 use std::time::Instant;
420 let tiers = Int8Tier::available();
421 let mut best = (tiers[0], f64::MAX);
422 for &tier in &tiers {
423 let mut total = 0.0_f64;
424 for &(m, k, n) in probes {
425 let x_q: Vec<i8> = (0..m * k)
428 .map(|i| (((i * 37 + 11) % 255) as i32 - 127) as i8)
429 .collect();
430 let x_scales = vec![1.0_f32; m];
431 let weight = QuantizedMatrix {
432 data: (0..n * k)
433 .map(|i| (((i * 29 + 5) % 255) as i32 - 127) as i8)
434 .collect(),
435 scales: vec![1.0_f32; n],
436 n,
437 k,
438 };
439 let mut out = vec![0.0_f32; m * n];
440 let mut rounds: Vec<f64> = (0..3)
441 .map(|_| {
442 let start = Instant::now();
443 linear_q8(&x_q, &x_scales, &weight, None, m, &mut out, tier);
444 start.elapsed().as_secs_f64()
445 })
446 .collect();
447 rounds.sort_by(f64::total_cmp);
448 total += rounds[1];
449 }
450 if total < best.1 {
451 best = (tier, total);
452 }
453 }
454 best.0
455}
456
457#[must_use]
459pub fn neon_sdot_available() -> bool {
460 #[cfg(all(target_arch = "aarch64", feature = "neon-dotprod"))]
461 {
462 neon_dotprod::available()
463 }
464 #[cfg(not(all(target_arch = "aarch64", feature = "neon-dotprod")))]
465 {
466 false
467 }
468}
469
470#[must_use]
476pub fn dot_i32(a: &[i8], b: &[i8], tier: Int8Tier) -> i32 {
477 assert_eq!(a.len(), b.len(), "int8 dot inputs must match");
478 match tier {
479 Int8Tier::Scalar => dot_i32_scalar(a, b),
480 Int8Tier::Autovec => dot_i32_autovec(a, b),
481 Int8Tier::NeonSdot => dot_i32_neon_or_panic(a, b),
482 }
483}
484
485#[cfg(all(target_arch = "aarch64", feature = "neon-dotprod"))]
486fn dot_i32_neon_or_panic(a: &[i8], b: &[i8]) -> i32 {
487 assert!(
488 neon_dotprod::available(),
489 "neon-sdot route selected without FEAT_DotProd"
490 );
491 neon_dotprod::dot_i32(a, b)
492}
493
494#[cfg(not(all(target_arch = "aarch64", feature = "neon-dotprod")))]
495fn dot_i32_neon_or_panic(_a: &[i8], _b: &[i8]) -> i32 {
496 panic!("neon-sdot route selected on a build without the island");
497}
498
499fn dot_i32_scalar(a: &[i8], b: &[i8]) -> i32 {
500 let mut sum = 0_i32;
501 for index in 0..a.len() {
502 sum += i32::from(a[index]) * i32::from(b[index]);
503 }
504 sum
505}
506
507fn dot_i32_autovec(a: &[i8], b: &[i8]) -> i32 {
511 const LANES: usize = 8;
512 let mut lanes = [0_i32; LANES];
513 let chunks = a.len() / LANES;
514 for chunk in 0..chunks {
515 let base = chunk * LANES;
516 for lane in 0..LANES {
517 lanes[lane] += i32::from(a[base + lane]) * i32::from(b[base + lane]);
518 }
519 }
520 let mut sum: i32 = lanes.iter().sum();
521 for index in chunks * LANES..a.len() {
522 sum += i32::from(a[index]) * i32::from(b[index]);
523 }
524 sum
525}
526
527#[cfg(all(target_arch = "aarch64", feature = "neon-dotprod"))]
528mod neon_dotprod {
529 use core::arch::aarch64::{vaddq_s32, vaddvq_s32, vdotq_s32, vdupq_n_s32, vld1q_s8};
534
535 #[must_use]
537 pub fn available() -> bool {
538 std::arch::is_aarch64_feature_detected!("dotprod")
539 }
540
541 #[must_use]
548 pub fn dot_i32(a: &[i8], b: &[i8]) -> i32 {
549 debug_assert!(available(), "SDOT island entered without FEAT_DotProd");
550 unsafe { dot_i32_sdot(a, b) }
553 }
554
555 #[target_feature(enable = "neon,dotprod")]
556 unsafe fn dot_i32_sdot(a: &[i8], b: &[i8]) -> i32 {
557 let len = a.len();
558 let a_ptr = a.as_ptr();
559 let b_ptr = b.as_ptr();
560 let mut acc0 = vdupq_n_s32(0);
561 let mut acc1 = vdupq_n_s32(0);
562 let mut acc2 = vdupq_n_s32(0);
563 let mut acc3 = vdupq_n_s32(0);
564 let mut index = 0_usize;
565 while index + 64 <= len {
566 unsafe {
570 acc0 = vdotq_s32(acc0, vld1q_s8(a_ptr.add(index)), vld1q_s8(b_ptr.add(index)));
571 acc1 = vdotq_s32(
572 acc1,
573 vld1q_s8(a_ptr.add(index + 16)),
574 vld1q_s8(b_ptr.add(index + 16)),
575 );
576 acc2 = vdotq_s32(
577 acc2,
578 vld1q_s8(a_ptr.add(index + 32)),
579 vld1q_s8(b_ptr.add(index + 32)),
580 );
581 acc3 = vdotq_s32(
582 acc3,
583 vld1q_s8(a_ptr.add(index + 48)),
584 vld1q_s8(b_ptr.add(index + 48)),
585 );
586 }
587 index += 64;
588 }
589 while index + 16 <= len {
590 unsafe {
592 acc0 = vdotq_s32(acc0, vld1q_s8(a_ptr.add(index)), vld1q_s8(b_ptr.add(index)));
593 }
594 index += 16;
595 }
596 let mut sum = vaddvq_s32(vaddq_s32(vaddq_s32(acc0, acc1), vaddq_s32(acc2, acc3)));
597 while index < len {
598 sum += i32::from(a[index]) * i32::from(b[index]);
599 index += 1;
600 }
601 sum
602 }
603}
604
605#[allow(clippy::too_many_arguments)]
617pub fn linear_q8(
618 x_q: &[i8],
619 x_scales: &[f32],
620 weight: &QuantizedMatrix,
621 bias: Option<&[f32]>,
622 m: usize,
623 out: &mut [f32],
624 tier: Int8Tier,
625) {
626 let (n, k) = (weight.n, weight.k);
627 assert_eq!(x_q.len(), m * k, "x_q must be [m, k]");
628 assert_eq!(x_scales.len(), m, "x_scales must be [m]");
629 assert_eq!(out.len(), m * n, "out must be [m, n]");
630 if let Some(bias) = bias {
631 assert_eq!(bias.len(), n, "bias must be [n]");
632 }
633 if n * k >= TEAM_WORK_THRESHOLD_BYTES
637 && !crate::team::thread_bypassed()
638 && let Some(team) = crate::team::armed()
639 {
640 team.linear_q8(x_q, x_scales, weight, bias, m, out, tier);
641 return;
642 }
643
644 for col in 0..n {
649 let w_row = &weight.data[col * k..(col + 1) * k];
650 let w_scale = weight.scales[col];
651 let bias_term = bias.map(|b| b[col]);
652 for row in 0..m {
653 let x_row = &x_q[row * k..(row + 1) * k];
654 let acc = dot_i32(x_row, w_row, tier);
655 let value = acc as f32 * (x_scales[row] * w_scale);
656 out[row * n + col] = bias_term.map_or(value, |b| value + b);
657 }
658 }
659}
660
661pub fn linear_q8_dynamic(
670 x: &[f32],
671 weight: &QuantizedMatrix,
672 bias: Option<&[f32]>,
673 m: usize,
674 out: &mut [f32],
675 tier: Int8Tier,
676) {
677 let k = weight.k;
678 assert_eq!(x.len(), m * k, "x must be [m, k]");
679 let mut x_q = vec![0_i8; m * k];
680 let mut x_scales = vec![0.0_f32; m];
681 for ((x_row, q_row), scale) in x
682 .chunks_exact(k)
683 .zip(x_q.chunks_exact_mut(k))
684 .zip(x_scales.iter_mut())
685 {
686 *scale = quantize_row_q8(x_row, q_row);
687 }
688 linear_q8(&x_q, &x_scales, weight, bias, m, out, tier);
689}
690
691#[cfg(test)]
692mod tests {
693 use super::*;
694
695 fn pseudo_random_q8(len: usize, seed: u64) -> Vec<i8> {
697 let mut state = seed;
698 (0..len)
699 .map(|_| {
700 state = state.wrapping_add(0x9e37_79b9_7f4a_7c15);
701 let mut z = state;
702 z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
703 z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
704 z ^= z >> 31;
705 ((z % 255) as i32 - 127) as i8
707 })
708 .collect()
709 }
710
711 const MODEL_SHAPES: &[(usize, usize)] = &[
713 (2048, 1024), (1024, 1024), (1024, 2048), (3072, 1024), (1024, 3072), ];
719
720 #[test]
721 fn every_tier_is_exactly_equal_in_i32_at_every_model_shape() {
722 for &(n, k) in MODEL_SHAPES {
723 let a = pseudo_random_q8(k, 0x5eed_0001 ^ (n as u64) << 20 ^ k as u64);
724 let w = pseudo_random_q8(n * k, 0x5eed_0002 ^ (n as u64) << 20 ^ k as u64);
725 for row in [0, n / 2, n - 1] {
726 let w_row = &w[row * k..(row + 1) * k];
727 let reference = dot_i32(&a, w_row, Int8Tier::Scalar);
728 for tier in Int8Tier::available() {
729 assert_eq!(
730 dot_i32(&a, w_row, tier),
731 reference,
732 "tier {} diverged at shape {n}x{k} row {row}",
733 tier.as_str()
734 );
735 }
736 }
737 }
738 }
739
740 #[test]
741 fn every_tier_survives_the_all_extreme_reduction_at_the_binding_census_k() {
742 for k in [2048_usize, 3072, 4608, 7168, 8192] {
744 let a = vec![127_i8; k];
745 let b = vec![127_i8; k];
746 let negative = vec![-127_i8; k];
747 let expected = 127_i64 * 127 * k as i64;
748 for tier in Int8Tier::available() {
749 assert_eq!(
750 i64::from(dot_i32(&a, &b, tier)),
751 expected,
752 "positive all-extreme diverged on {} at K={k}",
753 tier.as_str()
754 );
755 assert_eq!(
756 i64::from(dot_i32(&a, &negative, tier)),
757 -expected,
758 "negative all-extreme diverged on {} at K={k}",
759 tier.as_str()
760 );
761 }
762 }
763 }
764
765 #[test]
766 fn tail_lengths_that_defeat_block_boundaries_stay_exact() {
767 for len in [1_usize, 7, 15, 16, 17, 63, 64, 65, 100, 129] {
769 let a = pseudo_random_q8(len, tail_seed(len));
770 let b = pseudo_random_q8(len, tail_seed(len) ^ 1);
771 let reference = dot_i32(&a, &b, Int8Tier::Scalar);
772 for tier in Int8Tier::available() {
773 assert_eq!(
774 dot_i32(&a, &b, tier),
775 reference,
776 "len={len} {}",
777 tier.as_str()
778 );
779 }
780 }
781 }
782
783 #[test]
784 fn quantizer_matches_the_canonical_converter_semantics() {
785 let row = [
788 -127.0_f32, -126.5, -125.5, -1.5, -0.5, 0.5, 1.5, 125.5, 126.5, 127.0,
789 ];
790 let mut q = [0_i8; 10];
791 let scale = quantize_row_q8(&row, &mut q);
792 assert_eq!(scale.to_bits(), 1.0_f32.to_bits());
793 assert_eq!(q, [-127, -126, -126, -2, 0, 0, 2, 126, 126, 127]);
794
795 let zeros = [0.0_f32; 4];
796 let mut qz = [1_i8; 4];
797 assert_eq!(
798 quantize_row_q8(&zeros, &mut qz).to_bits(),
799 1.0_f32.to_bits()
800 );
801 assert_eq!(qz, [0, 0, 0, 0]);
802
803 let matrix = QuantizedMatrix::quantize(&[2.0, -1.0, 0.0, 3.0], 2, 2);
804 assert_eq!(matrix.scales[0].to_bits(), (2.0_f32 / 127.0).to_bits());
805 assert_eq!(matrix.scales[1].to_bits(), (3.0_f32 / 127.0).to_bits());
806 assert!(matrix.data.iter().all(|&b| b != -128));
807 }
808
809 #[test]
810 fn dynamic_w8a8_linear_tracks_the_f32_reference_within_quant_error() {
811 let (n, k) = (64_usize, 128_usize);
813 let mut weight = vec![0.0_f32; n * k];
814 let mut x = vec![0.0_f32; k];
815 let mut state = 0x1234_5678_u64;
816 let mut next = || {
817 state = state
818 .wrapping_mul(6_364_136_223_846_793_005)
819 .wrapping_add(1);
820 ((state >> 33) as f32 / (1u64 << 31) as f32) - 1.0
821 };
822 for value in weight.iter_mut() {
823 *value = next();
824 }
825 for value in x.iter_mut() {
826 *value = next();
827 }
828 let quantized = QuantizedMatrix::quantize(&weight, n, k);
829 let mut out_q8 = vec![0.0_f32; n];
830 linear_q8_dynamic(&x, &quantized, None, 1, &mut out_q8, Int8Tier::Autovec);
831
832 let mut out_f32 = vec![0.0_f32; n];
833 crate::f32ref::linear(&x, &weight, None, 1, k, n, &mut out_f32);
834
835 let dot = |a: &[f32], b: &[f32]| a.iter().zip(b).map(|(x, y)| x * y).sum::<f32>();
836 let cosine = dot(&out_q8, &out_f32)
837 / (dot(&out_q8, &out_q8).sqrt() * dot(&out_f32, &out_f32).sqrt());
838 assert!(
839 cosine > 0.999,
840 "W8A8 dequant plumbing is broken: cosine {cosine}"
841 );
842 }
843
844 #[test]
845 fn tiers_produce_bit_identical_f32_output_not_merely_close() {
846 let (n, k) = (256_usize, 1024_usize);
847 let weight: Vec<f32> = pseudo_random_q8(n * k, 77)
848 .iter()
849 .map(|&b| f32::from(b) / 64.0)
850 .collect();
851 let x: Vec<f32> = pseudo_random_q8(k, 78)
852 .iter()
853 .map(|&b| f32::from(b) / 64.0)
854 .collect();
855 let quantized = QuantizedMatrix::quantize(&weight, n, k);
856 let mut reference = vec![0.0_f32; n];
857 linear_q8_dynamic(&x, &quantized, None, 1, &mut reference, Int8Tier::Scalar);
858 for tier in Int8Tier::available() {
859 let mut out = vec![0.0_f32; n];
860 linear_q8_dynamic(&x, &quantized, None, 1, &mut out, tier);
861 for (index, (a, b)) in reference.iter().zip(&out).enumerate() {
862 assert_eq!(
863 a.to_bits(),
864 b.to_bits(),
865 "tier {} f32 output differs at {index}",
866 tier.as_str()
867 );
868 }
869 }
870 }
871
872 fn tail_seed(len: usize) -> u64 {
873 0x7a11_0000 ^ len as u64
874 }
875}