/home/noah/src/realizar/src/quantize/parallel_k.rs
Line | Count | Source |
1 | | //! Parallel and tiled matrix-vector operations for K-quantization (PMAT-802) |
2 | | //! |
3 | | //! Implements L2-aware tiled and parallel matvec operations: |
4 | | //! - `fused_q4k_tiled_matvec` - L2-aware tiled matmul |
5 | | //! - `fused_q4k_parallel_matvec`, `fused_q4k_parallel_matvec_into` - Parallel Q4_K |
6 | | //! - `fused_q5k_parallel_matvec`, `fused_q5k_parallel_matvec_into` - Parallel Q5_K |
7 | | //! - `fused_q6k_parallel_matvec`, `fused_q6k_parallel_matvec_into` - Parallel Q6_K |
8 | | //! |
9 | | //! Per Goto & Van Geijn "Anatomy of High-Performance Matrix Multiplication": |
10 | | //! - GEBP (General Block Panel) tiling maximizes cache reuse |
11 | | //! - Tile size should fit in L2 cache (~256KB-512KB typically) |
12 | | |
13 | | use crate::error::{RealizarError, Result}; |
14 | | use super::fused_k::{fused_q4k_dot_simd, fused_q4k_q8k_dot_simd, fused_q4k_q8k_dot_4rows_avx512vnni}; |
15 | | use super::fused_q5k_q6k::{fused_q5k_dot_simd, fused_q6k_dot_simd}; |
16 | | use super::types::QK_K; |
17 | | |
18 | | // ============================================================================ |
19 | | |
20 | | /// Default tile size for L2-aware tiled matmul |
21 | | /// |
22 | | /// Chosen to fit in L2 cache while maximizing parallelism: |
23 | | /// - Typical L2 size: 256KB-512KB |
24 | | /// - Q4_K row size for hidden_dim=2560: ~1440 bytes |
25 | | /// - 64 rows = ~92KB of weight data, plus activations |
26 | | const DEFAULT_OUTPUT_TILE_SIZE: usize = 64; |
27 | | |
28 | | /// Fused Q4_K matrix-vector multiply with L2-aware tiling |
29 | | /// |
30 | | /// Processes outputs in tiles to maximize L2 cache reuse. |
31 | | /// Each tile loads weight data once and computes multiple outputs. |
32 | | /// |
33 | | /// # Arguments |
34 | | /// |
35 | | /// * `weight_data` - Raw Q4_K quantized weight data |
36 | | /// * `activations` - Input activations [in_dim] |
37 | | /// * `in_dim` - Input dimension (must be multiple of 256 for Q4_K) |
38 | | /// * `out_dim` - Output dimension |
39 | | /// * `tile_size` - Number of outputs to process per tile (default: 64) |
40 | | /// |
41 | | /// # Returns |
42 | | /// |
43 | | /// Output vector [out_dim] |
44 | | /// |
45 | | /// # Errors |
46 | | /// |
47 | | /// Returns error if dimensions don't match weight data |
48 | | /// |
49 | | /// # Performance |
50 | | /// |
51 | | /// - **L2-aware**: Tiles fit in L2 cache, reducing DRAM traffic |
52 | | /// - **Fused**: Dequantize inline with dot product (8x bandwidth reduction) |
53 | | /// - **SIMD**: Uses AVX2 when available for 4-8x compute speedup |
54 | | #[allow(clippy::similar_names)] |
55 | 1.23k | pub fn fused_q4k_tiled_matvec( |
56 | 1.23k | weight_data: &[u8], |
57 | 1.23k | activations: &[f32], |
58 | 1.23k | in_dim: usize, |
59 | 1.23k | out_dim: usize, |
60 | 1.23k | tile_size: Option<usize>, |
61 | 1.23k | ) -> Result<Vec<f32>> { |
62 | 1.23k | let tile_size = tile_size.unwrap_or(DEFAULT_OUTPUT_TILE_SIZE); |
63 | | |
64 | | // Calculate bytes per output row |
65 | 1.23k | let super_blocks_per_row = in_dim.div_ceil(QK_K); |
66 | 1.23k | let bytes_per_row = super_blocks_per_row * 144; // Q4_K: 144 bytes per super-block |
67 | | |
68 | | // Validate dimensions |
69 | 1.23k | let expected_weight_bytes = out_dim * bytes_per_row; |
70 | 1.23k | if weight_data.len() < expected_weight_bytes { |
71 | 3 | return Err(RealizarError::InvalidShape { |
72 | 3 | reason: format!( |
73 | 3 | "Q4_K weight data too small: need {} bytes for {}x{}, have {}", |
74 | 3 | expected_weight_bytes, |
75 | 3 | out_dim, |
76 | 3 | in_dim, |
77 | 3 | weight_data.len() |
78 | 3 | ), |
79 | 3 | }); |
80 | 1.23k | } |
81 | | |
82 | 1.23k | if activations.len() != in_dim { |
83 | 2 | return Err(RealizarError::InvalidShape { |
84 | 2 | reason: format!( |
85 | 2 | "Activation length {} doesn't match in_dim {}", |
86 | 2 | activations.len(), |
87 | 2 | in_dim |
88 | 2 | ), |
89 | 2 | }); |
90 | 1.22k | } |
91 | | |
92 | 1.22k | let mut output = vec![0.0f32; out_dim]; |
93 | | |
94 | | // Process outputs in tiles for L2 cache efficiency |
95 | 1.22k | let num_tiles = out_dim.div_ceil(tile_size); |
96 | | |
97 | 16.3k | for tile_idx in 0..num_tiles1.22k { |
98 | 16.3k | let tile_start = tile_idx * tile_size; |
99 | 16.3k | let tile_end = (tile_start + tile_size).min(out_dim); |
100 | | |
101 | | // Prefetch next tile's weight data (if available) |
102 | | #[cfg(target_arch = "x86_64")] |
103 | 16.3k | if tile_idx + 1 < num_tiles { |
104 | 15.0k | let next_tile_start = (tile_idx + 1) * tile_size; |
105 | 15.0k | let next_row_start = next_tile_start * bytes_per_row; |
106 | 15.0k | if next_row_start < weight_data.len() { |
107 | | // SAFETY: Prefetch is a hint, no memory safety requirements |
108 | | unsafe { |
109 | | use std::arch::x86_64::_mm_prefetch; |
110 | | use std::arch::x86_64::_MM_HINT_T0; |
111 | 15.0k | let ptr = weight_data.as_ptr().add(next_row_start); |
112 | 15.0k | _mm_prefetch(ptr.cast::<i8>(), _MM_HINT_T0); |
113 | | } |
114 | 0 | } |
115 | 1.22k | } |
116 | | |
117 | | // Process tile: compute dot products for tile_start..tile_end |
118 | 1.03M | for (idx, out_slot) in output16.3k [tile_start..tile_end].iter_mut16.3k ().enumerate16.3k () { |
119 | 1.03M | let o = tile_start + idx; |
120 | 1.03M | let row_start = o * bytes_per_row; |
121 | 1.03M | let row_end = row_start + bytes_per_row; |
122 | 1.03M | let row_data = &weight_data[row_start..row_end]; |
123 | | |
124 | | // Fused dequant + dot product |
125 | 1.03M | *out_slot = fused_q4k_dot_simd(row_data, activations)?0 ; |
126 | | } |
127 | | } |
128 | | |
129 | 1.22k | Ok(output) |
130 | 1.23k | } |
131 | | |
132 | | // ============================================================================ |
133 | | // PARALLEL TILED MATRIX-VECTOR MULTIPLICATION (Phase 2 + 3) |
134 | | // ============================================================================ |
135 | | // |
136 | | // Per Blumofe & Leiserson [6] "Scheduling Multithreaded Computations by Work Stealing": |
137 | | // - Work-stealing schedulers like rayon maximize CPU utilization |
138 | | // - Each output row is independent → trivially parallelizable |
139 | | // - Expected speedup: ~Nx on N-core systems for memory-bound workloads |
140 | | // ============================================================================ |
141 | | |
142 | | /// Parallel fused Q4_K matrix-vector multiply with L2-aware tiling |
143 | | /// |
144 | | /// Uses rayon parallel iterators for multi-core acceleration. |
145 | | /// Per Valiant's BSP model [14], synchronization happens at tile boundaries. |
146 | | /// |
147 | | /// # Performance |
148 | | /// |
149 | | /// - **Multi-core**: Linear speedup up to memory bandwidth saturation |
150 | | /// - **L2-aware**: Tiles fit in L2 cache |
151 | | /// - **Fused**: 8x memory bandwidth reduction |
152 | | /// - **SIMD**: AVX2 when available |
153 | | /// - **Adaptive parallelism**: Sequential for small matrices, parallel for large (IMP-103) |
154 | | /// |
155 | | /// # Errors |
156 | | /// |
157 | | /// Returns error if: |
158 | | /// - Weight data is too small for the given dimensions |
159 | | /// - Activation length doesn't match input dimension |
160 | | #[allow(clippy::similar_names)] |
161 | 3.69k | pub fn fused_q4k_parallel_matvec( |
162 | 3.69k | weight_data: &[u8], |
163 | 3.69k | activations: &[f32], |
164 | 3.69k | in_dim: usize, |
165 | 3.69k | out_dim: usize, |
166 | 3.69k | ) -> Result<Vec<f32>> { |
167 | | // PAR-126: Five-Whys fix - parallel threshold was too high |
168 | | // OLD: PARALLEL_THRESHOLD=4096 meant FFN down (out_dim=1536) used sequential path |
169 | | // PROBLEM: 1.5B model was 11 tok/s instead of 200 tok/s due to single-threaded matmuls |
170 | | // |
171 | | // ANALYSIS (for 32-core system with in_dim=8960): |
172 | | // - Per-row time: 8960/256 superblocks × ~50ns/superblock = ~1.75µs |
173 | | // - Rayon overhead: ~10µs (reduced with work-stealing) |
174 | | // - Break-even: 10µs / (1.75µs/32) = ~183 rows |
175 | | // SOLUTION: Lower threshold to 256 to enable parallelism for all practical matmuls |
176 | | const PARALLEL_THRESHOLD: usize = 256; |
177 | | |
178 | | // Calculate bytes per output row |
179 | 3.69k | let super_blocks_per_row = in_dim.div_ceil(QK_K); |
180 | 3.69k | let bytes_per_row = super_blocks_per_row * 144; // Q4_K: 144 bytes per super-block |
181 | | |
182 | | // Validate dimensions |
183 | 3.69k | let expected_weight_bytes = out_dim * bytes_per_row; |
184 | 3.69k | if weight_data.len() < expected_weight_bytes { |
185 | 3 | return Err(RealizarError::InvalidShape { |
186 | 3 | reason: format!( |
187 | 3 | "Q4_K weight data too small: need {} bytes for {}x{}, have {}", |
188 | 3 | expected_weight_bytes, |
189 | 3 | out_dim, |
190 | 3 | in_dim, |
191 | 3 | weight_data.len() |
192 | 3 | ), |
193 | 3 | }); |
194 | 3.69k | } |
195 | | |
196 | 3.69k | if activations.len() != in_dim { |
197 | 1 | return Err(RealizarError::InvalidShape { |
198 | 1 | reason: format!( |
199 | 1 | "Activation length {} doesn't match in_dim {}", |
200 | 1 | activations.len(), |
201 | 1 | in_dim |
202 | 1 | ), |
203 | 1 | }); |
204 | 3.69k | } |
205 | | |
206 | 3.69k | if out_dim < PARALLEL_THRESHOLD { |
207 | | // Sequential path: avoids rayon overhead for small matrices |
208 | 3.37k | let output: Vec<f32> = (0..out_dim) |
209 | 326k | .map3.37k (|o| { |
210 | 326k | let row_start = o * bytes_per_row; |
211 | 326k | let row_end = row_start + bytes_per_row; |
212 | 326k | let row_data = &weight_data[row_start..row_end]; |
213 | 326k | fused_q4k_dot_simd(row_data, activations).unwrap_or(0.0) |
214 | 326k | }) |
215 | 3.37k | .collect(); |
216 | | |
217 | 3.37k | Ok(output) |
218 | | } else { |
219 | | // Parallel path: better for large matrices |
220 | | use rayon::prelude::*; |
221 | | |
222 | | // Use chunked parallel iteration with optimal chunk size |
223 | | // Chunk size tuned for L2 cache (~256KB): process ~64 rows per chunk |
224 | | const CHUNK_SIZE: usize = 64; |
225 | | |
226 | 316 | let output: Vec<f32> = (0..out_dim) |
227 | 316 | .into_par_iter() |
228 | 316 | .with_min_len(CHUNK_SIZE) |
229 | 134k | .map316 (|o| { |
230 | 134k | let row_start = o * bytes_per_row; |
231 | 134k | let row_end = row_start + bytes_per_row; |
232 | 134k | let row_data = &weight_data[row_start..row_end]; |
233 | 134k | fused_q4k_dot_simd(row_data, activations).unwrap_or(0.0) |
234 | 134k | }) |
235 | 316 | .collect(); |
236 | | |
237 | 316 | Ok(output) |
238 | | } |
239 | 3.69k | } |
240 | | |
241 | | /// Parallel fused Q4_K matrix-vector multiply - writes to pre-allocated buffer |
242 | | /// |
243 | | /// IMP-131: Zero-allocation variant for hot-path inference. |
244 | | /// This avoids Vec allocation overhead that causes 30-40% performance loss. |
245 | | /// |
246 | | /// # Arguments |
247 | | /// * `weight_data` - Raw Q4_K quantized weights [out_dim, in_dim] |
248 | | /// * `activations` - Input activations [in_dim] |
249 | | /// * `in_dim` - Input dimension (must match activations length) |
250 | | /// * `out_dim` - Output dimension (must match output buffer length) |
251 | | /// * `output` - Pre-allocated output buffer [out_dim] |
252 | | /// |
253 | | /// # Errors |
254 | | /// |
255 | | /// Returns error if: |
256 | | /// - Weight data is too small for the given dimensions |
257 | | /// - Activation length doesn't match input dimension |
258 | | /// - Output buffer length doesn't match out_dim |
259 | | #[allow(clippy::similar_names)] |
260 | 2 | pub fn fused_q4k_parallel_matvec_into( |
261 | 2 | weight_data: &[u8], |
262 | 2 | activations: &[f32], |
263 | 2 | in_dim: usize, |
264 | 2 | out_dim: usize, |
265 | 2 | output: &mut [f32], |
266 | 2 | ) -> Result<()> { |
267 | | // PAR-126: Match threshold from allocating version (was 4096, caused 25% perf loss) |
268 | | // Analysis: For 32-core system with in_dim=8960: |
269 | | // - Per-row time: ~1.75µs, Rayon overhead: ~10µs |
270 | | // - Break-even: ~183 rows, so 256 is safe threshold |
271 | | const PARALLEL_THRESHOLD: usize = 256; |
272 | | |
273 | 2 | let super_blocks_per_row = in_dim.div_ceil(QK_K); |
274 | 2 | let bytes_per_row = super_blocks_per_row * 144; |
275 | | |
276 | 2 | let expected_weight_bytes = out_dim * bytes_per_row; |
277 | 2 | if weight_data.len() < expected_weight_bytes { |
278 | 0 | return Err(RealizarError::InvalidShape { |
279 | 0 | reason: format!( |
280 | 0 | "Q4_K weight data too small: need {} bytes for {}x{}, have {}", |
281 | 0 | expected_weight_bytes, |
282 | 0 | out_dim, |
283 | 0 | in_dim, |
284 | 0 | weight_data.len() |
285 | 0 | ), |
286 | 0 | }); |
287 | 2 | } |
288 | | |
289 | 2 | if activations.len() != in_dim { |
290 | 1 | return Err(RealizarError::InvalidShape { |
291 | 1 | reason: format!( |
292 | 1 | "Activation length {} doesn't match in_dim {}", |
293 | 1 | activations.len(), |
294 | 1 | in_dim |
295 | 1 | ), |
296 | 1 | }); |
297 | 1 | } |
298 | | |
299 | 1 | if output.len() < out_dim { |
300 | 0 | return Err(RealizarError::InvalidShape { |
301 | 0 | reason: format!( |
302 | 0 | "Output buffer too small: need {}, have {}", |
303 | 0 | out_dim, |
304 | 0 | output.len() |
305 | 0 | ), |
306 | 0 | }); |
307 | 1 | } |
308 | | |
309 | 1 | if out_dim < PARALLEL_THRESHOLD { |
310 | | // Sequential path |
311 | 32 | for o in 0..out_dim1 { |
312 | 32 | let row_start = o * bytes_per_row; |
313 | 32 | let row_end = row_start + bytes_per_row; |
314 | 32 | let row_data = &weight_data[row_start..row_end]; |
315 | 32 | output[o] = fused_q4k_dot_simd(row_data, activations).unwrap_or(0.0); |
316 | 32 | } |
317 | | } else { |
318 | | // Parallel path with TCB-style midi-tile chunking |
319 | | // Process rows in 64-row chunks to maximize activation cache reuse |
320 | | use rayon::prelude::*; |
321 | | const MIDI_TILE_M: usize = 64; |
322 | | |
323 | 0 | output[..out_dim] |
324 | 0 | .par_chunks_mut(MIDI_TILE_M) |
325 | 0 | .enumerate() |
326 | 0 | .for_each(|(midi_idx, midi_chunk)| { |
327 | 0 | let midi_start = midi_idx * MIDI_TILE_M; |
328 | | |
329 | | // Process each row in this midi-tile |
330 | | // All rows share the same activation vector (kept in L2 cache) |
331 | 0 | for (local_idx, out) in midi_chunk.iter_mut().enumerate() { |
332 | 0 | let row = midi_start + local_idx; |
333 | 0 | let row_start = row * bytes_per_row; |
334 | 0 | let row_end = row_start + bytes_per_row; |
335 | 0 | let row_data = &weight_data[row_start..row_end]; |
336 | 0 | *out = fused_q4k_dot_simd(row_data, activations).unwrap_or(0.0); |
337 | 0 | } |
338 | 0 | }); |
339 | | } |
340 | | |
341 | 1 | Ok(()) |
342 | 2 | } |
343 | | |
344 | | /// Parallel fused Q5_K matrix-vector multiply |
345 | | /// |
346 | | /// # Errors |
347 | | /// |
348 | | /// Returns error if: |
349 | | /// - Weight data is too small for the given dimensions |
350 | | /// - Activation length doesn't match input dimension |
351 | | #[allow(clippy::similar_names)] |
352 | 7 | pub fn fused_q5k_parallel_matvec( |
353 | 7 | weight_data: &[u8], |
354 | 7 | activations: &[f32], |
355 | 7 | in_dim: usize, |
356 | 7 | out_dim: usize, |
357 | 7 | ) -> Result<Vec<f32>> { |
358 | | use rayon::prelude::*; |
359 | | |
360 | 7 | let super_blocks_per_row = in_dim.div_ceil(QK_K); |
361 | 7 | let bytes_per_row = super_blocks_per_row * 176; // Q5_K: 176 bytes per super-block |
362 | | |
363 | 7 | let expected_weight_bytes = out_dim * bytes_per_row; |
364 | 7 | if weight_data.len() < expected_weight_bytes { |
365 | 0 | return Err(RealizarError::InvalidShape { |
366 | 0 | reason: format!( |
367 | 0 | "Q5_K weight data too small: need {} bytes for {}x{}, have {}", |
368 | 0 | expected_weight_bytes, |
369 | 0 | out_dim, |
370 | 0 | in_dim, |
371 | 0 | weight_data.len() |
372 | 0 | ), |
373 | 0 | }); |
374 | 7 | } |
375 | | |
376 | 7 | if activations.len() != in_dim { |
377 | 0 | return Err(RealizarError::InvalidShape { |
378 | 0 | reason: format!( |
379 | 0 | "Activation length {} doesn't match in_dim {}", |
380 | 0 | activations.len(), |
381 | 0 | in_dim |
382 | 0 | ), |
383 | 0 | }); |
384 | 7 | } |
385 | | |
386 | 7 | let output: Vec<f32> = (0..out_dim) |
387 | 7 | .into_par_iter() |
388 | 2.11k | .map7 (|o| { |
389 | 2.11k | let row_start = o * bytes_per_row; |
390 | 2.11k | let row_end = row_start + bytes_per_row; |
391 | 2.11k | let row_data = &weight_data[row_start..row_end]; |
392 | | |
393 | 2.11k | fused_q5k_dot_simd(row_data, activations).unwrap_or(0.0) |
394 | 2.11k | }) |
395 | 7 | .collect(); |
396 | | |
397 | 7 | Ok(output) |
398 | 7 | } |
399 | | |
400 | | /// Parallel fused Q5_K matrix-vector multiply - writes to pre-allocated buffer |
401 | | /// |
402 | | /// IMP-131: Zero-allocation variant for hot-path inference. |
403 | | #[allow(clippy::similar_names)] |
404 | 3 | pub fn fused_q5k_parallel_matvec_into( |
405 | 3 | weight_data: &[u8], |
406 | 3 | activations: &[f32], |
407 | 3 | in_dim: usize, |
408 | 3 | out_dim: usize, |
409 | 3 | output: &mut [f32], |
410 | 3 | ) -> Result<()> { |
411 | | use rayon::prelude::*; |
412 | | |
413 | 3 | let super_blocks_per_row = in_dim.div_ceil(QK_K); |
414 | 3 | let bytes_per_row = super_blocks_per_row * 176; |
415 | | |
416 | 3 | let expected_weight_bytes = out_dim * bytes_per_row; |
417 | 3 | if weight_data.len() < expected_weight_bytes { |
418 | 2 | return Err(RealizarError::InvalidShape { |
419 | 2 | reason: format!( |
420 | 2 | "Q5_K weight data too small: need {} bytes for {}x{}, have {}", |
421 | 2 | expected_weight_bytes, |
422 | 2 | out_dim, |
423 | 2 | in_dim, |
424 | 2 | weight_data.len() |
425 | 2 | ), |
426 | 2 | }); |
427 | 1 | } |
428 | | |
429 | 1 | if activations.len() != in_dim { |
430 | 1 | return Err(RealizarError::InvalidShape { |
431 | 1 | reason: format!( |
432 | 1 | "Activation length {} doesn't match in_dim {}", |
433 | 1 | activations.len(), |
434 | 1 | in_dim |
435 | 1 | ), |
436 | 1 | }); |
437 | 0 | } |
438 | | |
439 | 0 | if output.len() < out_dim { |
440 | 0 | return Err(RealizarError::InvalidShape { |
441 | 0 | reason: format!( |
442 | 0 | "Output buffer too small: need {}, have {}", |
443 | 0 | out_dim, |
444 | 0 | output.len() |
445 | 0 | ), |
446 | 0 | }); |
447 | 0 | } |
448 | | |
449 | 0 | output[..out_dim] |
450 | 0 | .par_iter_mut() |
451 | 0 | .enumerate() |
452 | 0 | .for_each(|(o, out)| { |
453 | 0 | let row_start = o * bytes_per_row; |
454 | 0 | let row_end = row_start + bytes_per_row; |
455 | 0 | let row_data = &weight_data[row_start..row_end]; |
456 | 0 | *out = fused_q5k_dot_simd(row_data, activations).unwrap_or(0.0); |
457 | 0 | }); |
458 | | |
459 | 0 | Ok(()) |
460 | 3 | } |
461 | | |
462 | | /// Parallel fused Q6_K matrix-vector multiply |
463 | | /// |
464 | | /// # Errors |
465 | | /// |
466 | | /// Returns error if: |
467 | | /// - Weight data is too small for the given dimensions |
468 | | /// - Activation length doesn't match input dimension |
469 | | #[allow(clippy::similar_names)] |
470 | 8 | pub fn fused_q6k_parallel_matvec( |
471 | 8 | weight_data: &[u8], |
472 | 8 | activations: &[f32], |
473 | 8 | in_dim: usize, |
474 | 8 | out_dim: usize, |
475 | 8 | ) -> Result<Vec<f32>> { |
476 | | use rayon::prelude::*; |
477 | | |
478 | 8 | let super_blocks_per_row = in_dim.div_ceil(QK_K); |
479 | 8 | let bytes_per_row = super_blocks_per_row * 210; // Q6_K: 210 bytes per super-block |
480 | | |
481 | 8 | let expected_weight_bytes = out_dim * bytes_per_row; |
482 | 8 | if weight_data.len() < expected_weight_bytes { |
483 | 0 | return Err(RealizarError::InvalidShape { |
484 | 0 | reason: format!( |
485 | 0 | "Q6_K weight data too small: need {} bytes for {}x{}, have {}", |
486 | 0 | expected_weight_bytes, |
487 | 0 | out_dim, |
488 | 0 | in_dim, |
489 | 0 | weight_data.len() |
490 | 0 | ), |
491 | 0 | }); |
492 | 8 | } |
493 | | |
494 | 8 | if activations.len() != in_dim { |
495 | 0 | return Err(RealizarError::InvalidShape { |
496 | 0 | reason: format!( |
497 | 0 | "Activation length {} doesn't match in_dim {}", |
498 | 0 | activations.len(), |
499 | 0 | in_dim |
500 | 0 | ), |
501 | 0 | }); |
502 | 8 | } |
503 | | |
504 | 8 | let output: Vec<f32> = (0..out_dim) |
505 | 8 | .into_par_iter() |
506 | 2.14k | .map8 (|o| { |
507 | 2.14k | let row_start = o * bytes_per_row; |
508 | 2.14k | let row_end = row_start + bytes_per_row; |
509 | 2.14k | let row_data = &weight_data[row_start..row_end]; |
510 | | |
511 | 2.14k | fused_q6k_dot_simd(row_data, activations).unwrap_or(0.0) |
512 | 2.14k | }) |
513 | 8 | .collect(); |
514 | | |
515 | 8 | Ok(output) |
516 | 8 | } |
517 | | |
518 | | /// Parallel fused Q6_K matrix-vector multiply - writes to pre-allocated buffer |
519 | | /// |
520 | | /// IMP-131: Zero-allocation variant for hot-path inference. |
521 | | #[allow(clippy::similar_names)] |
522 | 3 | pub fn fused_q6k_parallel_matvec_into( |
523 | 3 | weight_data: &[u8], |
524 | 3 | activations: &[f32], |
525 | 3 | in_dim: usize, |
526 | 3 | out_dim: usize, |
527 | 3 | output: &mut [f32], |
528 | 3 | ) -> Result<()> { |
529 | | use rayon::prelude::*; |
530 | | |
531 | 3 | let super_blocks_per_row = in_dim.div_ceil(QK_K); |
532 | 3 | let bytes_per_row = super_blocks_per_row * 210; |
533 | | |
534 | 3 | let expected_weight_bytes = out_dim * bytes_per_row; |
535 | 3 | if weight_data.len() < expected_weight_bytes { |
536 | 2 | return Err(RealizarError::InvalidShape { |
537 | 2 | reason: format!( |
538 | 2 | "Q6_K weight data too small: need {} bytes for {}x{}, have {}", |
539 | 2 | expected_weight_bytes, |
540 | 2 | out_dim, |
541 | 2 | in_dim, |
542 | 2 | weight_data.len() |
543 | 2 | ), |
544 | 2 | }); |
545 | 1 | } |
546 | | |
547 | 1 | if activations.len() != in_dim { |
548 | 1 | return Err(RealizarError::InvalidShape { |
549 | 1 | reason: format!( |
550 | 1 | "Activation length {} doesn't match in_dim {}", |
551 | 1 | activations.len(), |
552 | 1 | in_dim |
553 | 1 | ), |
554 | 1 | }); |
555 | 0 | } |
556 | | |
557 | 0 | if output.len() < out_dim { |
558 | 0 | return Err(RealizarError::InvalidShape { |
559 | 0 | reason: format!( |
560 | 0 | "Output buffer too small: need {}, have {}", |
561 | 0 | out_dim, |
562 | 0 | output.len() |
563 | 0 | ), |
564 | 0 | }); |
565 | 0 | } |
566 | | |
567 | | // TCB Tiling: Process rows in midi-tiles (64 rows) to maximize activation cache reuse |
568 | | // While Q6K×f32 doesn't have integer Q8K, the f32 activation vector still benefits |
569 | | // from being kept in L2 cache while processing multiple output rows. |
570 | | const MIDI_TILE_M: usize = 64; |
571 | | |
572 | 0 | output[..out_dim] |
573 | 0 | .par_chunks_mut(MIDI_TILE_M) |
574 | 0 | .enumerate() |
575 | 0 | .for_each(|(midi_idx, midi_chunk)| { |
576 | 0 | let midi_start = midi_idx * MIDI_TILE_M; |
577 | | |
578 | | // Process each row in this midi-tile |
579 | | // All rows share the same activation vector (kept in L2 cache) |
580 | 0 | for (local_idx, out) in midi_chunk.iter_mut().enumerate() { |
581 | 0 | let row = midi_start + local_idx; |
582 | 0 | let row_start = row * bytes_per_row; |
583 | 0 | let row_end = row_start + bytes_per_row; |
584 | 0 | let row_data = &weight_data[row_start..row_end]; |
585 | 0 | *out = fused_q6k_dot_simd(row_data, activations).unwrap_or(0.0); |
586 | 0 | } |
587 | 0 | }); |
588 | | |
589 | 0 | Ok(()) |
590 | 3 | } |
591 | | |
592 | | // ============================================================================ |
593 | | |
594 | | /// Backwards-compatible alias for `fused_q6k_parallel_matvec`. |
595 | | /// |
596 | | /// The column-major layout is now the default for the parallel implementation. |
597 | | #[inline] |
598 | 1 | pub fn fused_q6k_colmajor_matvec( |
599 | 1 | weight_data: &[u8], |
600 | 1 | activations: &[f32], |
601 | 1 | in_dim: usize, |
602 | 1 | out_dim: usize, |
603 | 1 | ) -> Result<Vec<f32>> { |
604 | 1 | fused_q6k_parallel_matvec(weight_data, activations, in_dim, out_dim) |
605 | 1 | } |
606 | | |
607 | | /// Backwards-compatible alias for `fused_q4k_parallel_matvec_into`. |
608 | | /// |
609 | | /// The "auto" naming referred to automatic thread dispatch which is now the default. |
610 | | #[inline] |
611 | 1 | pub fn fused_q4k_auto_matvec_into( |
612 | 1 | weight_data: &[u8], |
613 | 1 | activations: &[f32], |
614 | 1 | in_dim: usize, |
615 | 1 | out_dim: usize, |
616 | 1 | output: &mut [f32], |
617 | 1 | ) -> Result<()> { |
618 | 1 | fused_q4k_parallel_matvec_into(weight_data, activations, in_dim, out_dim, output) |
619 | 1 | } |
620 | | |
621 | | /// Parallel Q4_K × Q8_K matrix-vector multiply with TCB tiling |
622 | | /// |
623 | | /// Uses rayon parallel iterators for multi-core acceleration and TCB tiling |
624 | | /// pattern for cache optimization. |
625 | 5 | pub fn fused_q4k_q8k_parallel_matvec_into( |
626 | 5 | weight_data: &[u8], |
627 | 5 | q8k_scales: &[f32], |
628 | 5 | q8k_quants: &[i8], |
629 | 5 | in_dim: usize, |
630 | 5 | out_dim: usize, |
631 | 5 | output: &mut [f32], |
632 | 5 | ) -> Result<()> { |
633 | | use rayon::prelude::*; |
634 | | |
635 | | const SUPER_BLOCK_BYTES: usize = 144; |
636 | | |
637 | 5 | let super_blocks_per_row = in_dim.div_ceil(QK_K); |
638 | 5 | let bytes_per_row = super_blocks_per_row * SUPER_BLOCK_BYTES; |
639 | | |
640 | 5 | let expected_weight_bytes = out_dim * bytes_per_row; |
641 | 5 | if weight_data.len() < expected_weight_bytes { |
642 | 3 | return Err(RealizarError::InvalidShape { |
643 | 3 | reason: format!( |
644 | 3 | "Weight data too small: need {} bytes, have {}", |
645 | 3 | expected_weight_bytes, |
646 | 3 | weight_data.len() |
647 | 3 | ), |
648 | 3 | }); |
649 | 2 | } |
650 | | |
651 | 2 | if output.len() < out_dim { |
652 | 0 | return Err(RealizarError::InvalidShape { |
653 | 0 | reason: format!( |
654 | 0 | "Output buffer too small: need {}, have {}", |
655 | 0 | out_dim, |
656 | 0 | output.len() |
657 | 0 | ), |
658 | 0 | }); |
659 | 2 | } |
660 | | |
661 | | // TCB Tiling Parameters (from trueno::tiling::TilingConfig::cpu_avx512_vnni_q4k_q8k) |
662 | | // - Midi-tile: 64 rows (fits in L2 cache with Q8K input) |
663 | | // - Micro-tile: 4 rows (processed simultaneously sharing Q8K loads) |
664 | | const MIDI_TILE_M: usize = 64; |
665 | | const MICRO_TILE_M: usize = 4; |
666 | | |
667 | | // Check if we can use the optimized 4-row micro-kernel |
668 | | #[cfg(target_arch = "x86_64")] |
669 | 2 | let use_4row_kernel = |
670 | 2 | is_x86_feature_detected!("avx512f") && is_x86_feature_detected!("avx512vnni"); |
671 | | #[cfg(not(target_arch = "x86_64"))] |
672 | | let use_4row_kernel = false; |
673 | | |
674 | 2 | if use_4row_kernel && out_dim >= MICRO_TILE_M { |
675 | | // TCB-style tiled execution with 4-row micro-kernel |
676 | | // Process MIDI_TILE_M rows per parallel chunk to maximize Q8K sharing |
677 | | |
678 | | // Split output into midi-tiles for rayon parallelism |
679 | 0 | output[..out_dim] |
680 | 0 | .par_chunks_mut(MIDI_TILE_M) |
681 | 0 | .enumerate() |
682 | 0 | .for_each(|(midi_idx, midi_chunk)| { |
683 | 0 | let midi_start = midi_idx * MIDI_TILE_M; |
684 | 0 | let midi_rows = midi_chunk.len(); |
685 | | |
686 | | // Process micro-tiles (4 rows at a time) within this midi-tile |
687 | 0 | let full_micro_tiles = midi_rows / MICRO_TILE_M; |
688 | 0 | let remainder = midi_rows % MICRO_TILE_M; |
689 | | |
690 | 0 | for micro_idx in 0..full_micro_tiles { |
691 | 0 | let row_base = midi_start + micro_idx * MICRO_TILE_M; |
692 | 0 |
|
693 | 0 | // Build row pointers for 4-row kernel |
694 | 0 | let row_ptrs: [*const u8; 4] = [ |
695 | 0 | weight_data.as_ptr().wrapping_add(row_base * bytes_per_row), |
696 | 0 | weight_data |
697 | 0 | .as_ptr() |
698 | 0 | .wrapping_add((row_base + 1) * bytes_per_row), |
699 | 0 | weight_data |
700 | 0 | .as_ptr() |
701 | 0 | .wrapping_add((row_base + 2) * bytes_per_row), |
702 | 0 | weight_data |
703 | 0 | .as_ptr() |
704 | 0 | .wrapping_add((row_base + 3) * bytes_per_row), |
705 | 0 | ]; |
706 | 0 |
|
707 | 0 | // SAFETY: AVX-512 VNNI detected, pointers are within weight_data bounds |
708 | 0 | #[cfg(target_arch = "x86_64")] |
709 | 0 | let outputs = unsafe { |
710 | 0 | fused_q4k_q8k_dot_4rows_avx512vnni( |
711 | 0 | row_ptrs, |
712 | 0 | bytes_per_row, |
713 | 0 | q8k_scales, |
714 | 0 | q8k_quants, |
715 | 0 | ) |
716 | 0 | }; |
717 | 0 |
|
718 | 0 | #[cfg(not(target_arch = "x86_64"))] |
719 | 0 | let outputs = [0.0f32; 4]; |
720 | 0 |
|
721 | 0 | let local_base = micro_idx * MICRO_TILE_M; |
722 | 0 | midi_chunk[local_base] = outputs[0]; |
723 | 0 | midi_chunk[local_base + 1] = outputs[1]; |
724 | 0 | midi_chunk[local_base + 2] = outputs[2]; |
725 | 0 | midi_chunk[local_base + 3] = outputs[3]; |
726 | 0 | } |
727 | | |
728 | | // Handle remainder rows (< 4) with single-row kernel |
729 | 0 | for r in 0..remainder { |
730 | 0 | let row = midi_start + full_micro_tiles * MICRO_TILE_M + r; |
731 | 0 | let row_start = row * bytes_per_row; |
732 | 0 | let row_data = &weight_data[row_start..row_start + bytes_per_row]; |
733 | 0 | let local_idx = full_micro_tiles * MICRO_TILE_M + r; |
734 | 0 | midi_chunk[local_idx] = |
735 | 0 | fused_q4k_q8k_dot_simd(row_data, q8k_scales, q8k_quants).unwrap_or(0.0); |
736 | 0 | } |
737 | 0 | }); |
738 | | } else { |
739 | | // Fallback: per-row execution (no TCB optimization) |
740 | 2 | output[..out_dim] |
741 | 2 | .par_iter_mut() |
742 | 2 | .enumerate() |
743 | 4 | .for_each2 (|(o, out)| { |
744 | 4 | let row_start = o * bytes_per_row; |
745 | 4 | let row_data = &weight_data[row_start..row_start + bytes_per_row]; |
746 | 4 | *out = fused_q4k_q8k_dot_simd(row_data, q8k_scales, q8k_quants).unwrap_or(0.0); |
747 | 4 | }); |
748 | | } |
749 | | |
750 | 2 | Ok(()) |
751 | 5 | } |
752 | | |
753 | | /// Fused FFN up+gate projection in single parallel region |
754 | | /// |
755 | | /// Eliminates rayon::join overhead by processing both up and gate weights |
756 | | /// in a single par_chunks_mut call. Both projections share the same Q8K |
757 | | /// quantized input, so we only load it once per midi-tile. |
758 | | /// |
759 | | /// # Performance |
760 | | /// |
761 | | /// Reduces parallel region spawns from 2 to 1 per FFN layer, saving ~10-50µs |
762 | | /// per layer. For 28 layers, this is 280-1400µs per token. |
763 | | /// |
764 | | /// # Arguments |
765 | | /// |
766 | | /// * `up_weight` - Q4K weight data for FFN up projection |
767 | | /// * `gate_weight` - Q4K weight data for FFN gate projection |
768 | | /// * `q8k_scales` - Pre-quantized activation scales |
769 | | /// * `q8k_quants` - Pre-quantized activation values |
770 | | /// * `in_dim` - Input dimension (hidden_dim) |
771 | | /// * `out_dim` - Output dimension (intermediate_dim) |
772 | | /// * `up_output` - Output buffer for up projection |
773 | | /// * `gate_output` - Output buffer for gate projection |
774 | | #[allow(clippy::too_many_arguments)] |
775 | 6 | pub fn fused_q4k_q8k_ffn_up_gate_into( |
776 | 6 | up_weight: &[u8], |
777 | 6 | gate_weight: &[u8], |
778 | 6 | q8k_scales: &[f32], |
779 | 6 | q8k_quants: &[i8], |
780 | 6 | in_dim: usize, |
781 | 6 | out_dim: usize, |
782 | 6 | up_output: &mut [f32], |
783 | 6 | gate_output: &mut [f32], |
784 | 6 | ) -> Result<()> { |
785 | | use rayon::prelude::*; |
786 | | |
787 | | const SUPER_BLOCK_BYTES: usize = 144; |
788 | | const MIDI_TILE_M: usize = 64; |
789 | | |
790 | 6 | let super_blocks_per_row = in_dim.div_ceil(QK_K); |
791 | 6 | let bytes_per_row = super_blocks_per_row * SUPER_BLOCK_BYTES; |
792 | | |
793 | 6 | let expected_weight_bytes = out_dim * bytes_per_row; |
794 | 6 | if up_weight.len() < expected_weight_bytes || gate_weight3 .len() < expected_weight_bytes { |
795 | 4 | return Err(RealizarError::InvalidShape { |
796 | 4 | reason: format!( |
797 | 4 | "Weight data too small: need {} bytes", |
798 | 4 | expected_weight_bytes |
799 | 4 | ), |
800 | 4 | }); |
801 | 2 | } |
802 | | |
803 | 2 | if up_output.len() < out_dim || gate_output.len() < out_dim { |
804 | 0 | return Err(RealizarError::InvalidShape { |
805 | 0 | reason: format!("Output buffers too small: need {}", out_dim), |
806 | 0 | }); |
807 | 2 | } |
808 | | |
809 | | // Process both up and gate in a single parallel region |
810 | | // Each thread handles a midi-tile of rows for BOTH projections |
811 | | // We use zip + par_chunks_mut to ensure thread-safe non-overlapping access |
812 | 2 | up_output[..out_dim] |
813 | 2 | .par_chunks_mut(MIDI_TILE_M) |
814 | 2 | .zip(gate_output[..out_dim].par_chunks_mut(MIDI_TILE_M)) |
815 | 2 | .enumerate() |
816 | 5 | .for_each2 (|(midi_idx, (up_chunk, gate_chunk))| { |
817 | 5 | let midi_start = midi_idx * MIDI_TILE_M; |
818 | | |
819 | 257 | for (local_row, (up_out, gate_out)) in |
820 | 5 | up_chunk.iter_mut().zip(gate_chunk.iter_mut()).enumerate() |
821 | 257 | { |
822 | 257 | let row = midi_start + local_row; |
823 | 257 | let row_start = row * bytes_per_row; |
824 | 257 | |
825 | 257 | // Compute up projection for this row |
826 | 257 | let up_row = &up_weight[row_start..row_start + bytes_per_row]; |
827 | 257 | *up_out = fused_q4k_q8k_dot_simd(up_row, q8k_scales, q8k_quants).unwrap_or(0.0); |
828 | 257 | |
829 | 257 | // Compute gate projection for this row |
830 | 257 | let gate_row = &gate_weight[row_start..row_start + bytes_per_row]; |
831 | 257 | *gate_out = fused_q4k_q8k_dot_simd(gate_row, q8k_scales, q8k_quants).unwrap_or(0.0); |
832 | 257 | } |
833 | 5 | }); |
834 | | |
835 | 2 | Ok(()) |
836 | 6 | } |