/home/noah/src/realizar/src/apr/mod.rs
Line | Count | Source |
1 | | //! Aprender .apr format support for realizar (APR v2 only) |
2 | | //! |
3 | | //! This module provides loading and inference for models in Aprender's native |
4 | | //! .apr v2 format (Magic: `APR\0` = 0x41505232). |
5 | | //! |
6 | | //! ## Format Structure (APR v2, 64-byte header) |
7 | | //! |
8 | | //! ```text |
9 | | //! ┌─────────────────────────────────────────────────────────────┐ |
10 | | //! │ Header (64 bytes) │ |
11 | | //! │ - Magic: "APR\0" (4 bytes) │ |
12 | | //! │ - Version: major.minor (2 bytes) │ |
13 | | //! │ - Flags (2 bytes) │ |
14 | | //! │ - Tensor count (4 bytes) │ |
15 | | //! │ - Metadata offset (8 bytes) │ |
16 | | //! │ - Metadata size (4 bytes) │ |
17 | | //! │ - Tensor index offset (8 bytes) │ |
18 | | //! │ - Data offset (8 bytes) │ |
19 | | //! │ - Checksum (4 bytes) │ |
20 | | //! │ - Reserved (20 bytes) │ |
21 | | //! ├─────────────────────────────────────────────────────────────┤ |
22 | | //! │ JSON Metadata (padded to 64-byte boundary) │ |
23 | | //! ├─────────────────────────────────────────────────────────────┤ |
24 | | //! │ Tensor Index (sorted by name) │ |
25 | | //! ├─────────────────────────────────────────────────────────────┤ |
26 | | //! │ Tensor Data (each tensor 64-byte aligned) │ |
27 | | //! └─────────────────────────────────────────────────────────────┘ |
28 | | //! ``` |
29 | | //! |
30 | | //! ## Example |
31 | | //! |
32 | | //! ```rust,ignore |
33 | | //! use realizar::apr::AprV2Model; |
34 | | //! |
35 | | //! let model = AprV2Model::load("model.apr")?; |
36 | | //! println!("Tensors: {}", model.tensor_count()); |
37 | | //! ``` |
38 | | |
39 | | use std::collections::HashMap; |
40 | | use std::fs::{self, File}; |
41 | | use std::path::{Path, PathBuf}; |
42 | | |
43 | | use serde::{Deserialize, Serialize}; |
44 | | |
45 | | use crate::error::{RealizarError, Result}; |
46 | | |
47 | | // PMAT-802: Extracted modules |
48 | | #[cfg(feature = "cuda")] |
49 | | mod cuda; |
50 | | mod helpers; |
51 | | mod tokenizer; |
52 | | |
53 | | #[cfg(feature = "cuda")] |
54 | | pub use cuda::AprV2ModelCuda; |
55 | | pub use helpers::{is_apr_file, detect_format, simd_dot}; |
56 | | use helpers::{rms_norm, matmul, simple_attention}; |
57 | | #[cfg(feature = "cuda")] |
58 | | use helpers::transpose_matrix; |
59 | | pub use tokenizer::{BpeTokenizer, SimpleTokenizer, byte_to_bpe_char}; |
60 | | use tokenizer::bpe_encode; |
61 | | |
62 | | // ============================================================================ |
63 | | // Memory-mapped model data (Heijunka - Level Loading) |
64 | | // ============================================================================ |
65 | | // |
66 | | // References: |
67 | | // - Didona et al. (2022): mmap vs read() achieves 2.3x throughput for sequential access |
68 | | // - Chu (2011): LMDB design - let kernel manage pages, don't fight the VM subsystem |
69 | | // - Vahalia (1996): SIGBUS behavior on truncated mmap |
70 | | // |
71 | | // This abstraction allows models to be loaded via: |
72 | | // 1. Memory mapping (mmap) - zero-copy, kernel manages pages, no zram pressure |
73 | | // 2. Heap allocation (Vec<u8>) - required for compressed files after decompression |
74 | | |
75 | | /// Model data storage abstraction for zero-copy access. |
76 | | /// |
77 | | /// # Memory Management |
78 | | /// |
79 | | /// When using `Mmap` variant: |
80 | | /// - Data is not copied into userspace heap |
81 | | /// - Kernel demand-pages from disk on access |
82 | | /// - After GPU transfer, call `release_cpu_pages()` to advise kernel |
83 | | /// - Pages backed by file (not zram) when evicted |
84 | | /// |
85 | | /// When using `Heap` variant: |
86 | | /// - Used for compressed files (must decompress to Vec<u8>) |
87 | | /// - Standard heap allocation behavior |
88 | | /// - May be compressed to zram when idle |
89 | | #[derive(Debug)] |
90 | | pub enum ModelData { |
91 | | /// Memory-mapped file (zero-copy, kernel-managed paging) |
92 | | #[cfg(not(target_arch = "wasm32"))] |
93 | | Mmap { |
94 | | /// Memory-mapped region |
95 | | mmap: memmap2::Mmap, |
96 | | /// Original file path (for diagnostics) |
97 | | path: PathBuf, |
98 | | }, |
99 | | /// Heap-allocated data (for compressed files or WASM) |
100 | | Heap(Vec<u8>), |
101 | | } |
102 | | |
103 | | impl ModelData { |
104 | | /// Open a file with memory mapping. |
105 | | /// |
106 | | /// # Safety |
107 | | /// |
108 | | /// Uses `memmap2::Mmap` which requires: |
109 | | /// - File must not be truncated while mapped (SIGBUS on Unix) |
110 | | /// - File must not be modified while mapped (undefined behavior) |
111 | | /// |
112 | | /// # References |
113 | | /// |
114 | | /// - Vahalia (1996): SIGBUS from truncated mmap |
115 | | /// - memmap2 crate safety documentation |
116 | | #[cfg(not(target_arch = "wasm32"))] |
117 | | #[allow(unsafe_code)] |
118 | 8 | pub fn open_mmap(path: impl AsRef<Path>) -> Result<Self> { |
119 | 8 | let path_ref = path.as_ref(); |
120 | 8 | let file7 = File::open(path_ref).map_err(|e| RealizarError::IoError { |
121 | 1 | message: format!("Failed to open file '{}': {e}", path_ref.display()), |
122 | 1 | })?; |
123 | | |
124 | | // SAFETY: File is opened read-only. We document the single-writer |
125 | | // assumption. Callers should validate checksums before trusting data. |
126 | | // SIGBUS can occur if file is truncated externally - this is documented. |
127 | 7 | let mmap = unsafe { |
128 | 7 | memmap2::MmapOptions::new() |
129 | 7 | .map(&file) |
130 | 7 | .map_err(|e| RealizarError::IoError { |
131 | 0 | message: format!("Failed to mmap file '{}': {e}", path_ref.display()), |
132 | 0 | })? |
133 | | }; |
134 | | |
135 | 7 | Ok(Self::Mmap { |
136 | 7 | mmap, |
137 | 7 | path: path_ref.to_path_buf(), |
138 | 7 | }) |
139 | 8 | } |
140 | | |
141 | | /// Create from heap-allocated data (for compressed files). |
142 | | #[must_use] |
143 | 162 | pub fn from_vec(data: Vec<u8>) -> Self { |
144 | 162 | Self::Heap(data) |
145 | 162 | } |
146 | | |
147 | | /// Get the data as a byte slice. |
148 | | #[must_use] |
149 | 183 | pub fn as_slice(&self) -> &[u8] { |
150 | 183 | match self { |
151 | | #[cfg(not(target_arch = "wasm32"))] |
152 | 7 | Self::Mmap { mmap, .. } => mmap, |
153 | 176 | Self::Heap(data) => data, |
154 | | } |
155 | 183 | } |
156 | | |
157 | | /// Get data length. |
158 | | #[must_use] |
159 | 8 | pub fn len(&self) -> usize { |
160 | 8 | self.as_slice().len() |
161 | 8 | } |
162 | | |
163 | | /// Check if data is empty. |
164 | | #[must_use] |
165 | 8 | pub fn is_empty(&self) -> bool { |
166 | 8 | self.as_slice().is_empty() |
167 | 8 | } |
168 | | |
169 | | /// Release CPU pages after GPU transfer (Unix only). |
170 | | /// |
171 | | /// Calls `madvise(MADV_DONTNEED)` to tell the kernel these pages |
172 | | /// are no longer needed. The kernel will: |
173 | | /// - Drop pages immediately (not compress to zram) |
174 | | /// - Re-fault from disk if accessed again |
175 | | /// |
176 | | /// # When to Call |
177 | | /// |
178 | | /// After `cuMemcpy()` completes for all tensors. |
179 | | /// |
180 | | /// # Safety |
181 | | /// |
182 | | /// Uses `unchecked_advise` because `MADV_DONTNEED` is in the |
183 | | /// `UncheckedAdvice` enum. This is safe for read-only mmaps where |
184 | | /// data can be re-faulted from the backing file. |
185 | | /// |
186 | | /// # References |
187 | | /// |
188 | | /// - Didona et al. (2022): madvise for memory management |
189 | | #[cfg(all(unix, not(target_arch = "wasm32")))] |
190 | | #[allow(unsafe_code)] |
191 | 3 | pub fn release_cpu_pages(&self) -> Result<()> { |
192 | 3 | match self { |
193 | 2 | Self::Mmap { mmap, path } => { |
194 | | // SAFETY: We opened the file read-only, so MADV_DONTNEED is safe - |
195 | | // the kernel will re-fault pages from the backing file if accessed. |
196 | | unsafe { |
197 | 2 | mmap.unchecked_advise(memmap2::UncheckedAdvice::DontNeed) |
198 | 2 | .map_err(|e| RealizarError::IoError { |
199 | 0 | message: format!( |
200 | 0 | "madvise(MADV_DONTNEED) failed for '{}': {e}", |
201 | 0 | path.display() |
202 | | ), |
203 | 0 | }) |
204 | | } |
205 | | }, |
206 | | Self::Heap(_) => { |
207 | | // No-op for heap data - kernel manages via normal VM pressure |
208 | 1 | Ok(()) |
209 | | }, |
210 | | } |
211 | 3 | } |
212 | | |
213 | | /// Advise sequential access pattern (Unix only). |
214 | | /// |
215 | | /// Call before linear scan through model data. |
216 | | #[cfg(all(unix, not(target_arch = "wasm32")))] |
217 | 4 | pub fn advise_sequential(&self) -> Result<()> { |
218 | 4 | match self { |
219 | 3 | Self::Mmap { mmap, path } => { |
220 | 3 | mmap.advise(memmap2::Advice::Sequential) |
221 | 3 | .map_err(|e| RealizarError::IoError { |
222 | 0 | message: format!( |
223 | 0 | "madvise(MADV_SEQUENTIAL) failed for '{}': {e}", |
224 | 0 | path.display() |
225 | | ), |
226 | 0 | }) |
227 | | }, |
228 | 1 | Self::Heap(_) => Ok(()), |
229 | | } |
230 | 4 | } |
231 | | |
232 | | /// Check if this is memory-mapped data. |
233 | | #[must_use] |
234 | 7 | pub fn is_mmap(&self) -> bool { |
235 | 7 | match self { |
236 | | #[cfg(not(target_arch = "wasm32"))] |
237 | 3 | Self::Mmap { .. } => true, |
238 | 4 | Self::Heap(_) => false, |
239 | | } |
240 | 7 | } |
241 | | } |
242 | | |
243 | | /// Magic number: "APR" followed by version byte |
244 | | /// - Legacy: APR\0 (0x41, 0x50, 0x52, 0x00) |
245 | | /// - v1: APR1 (0x41, 0x50, 0x52, 0x31) |
246 | | /// - v2: APR2 (0x41, 0x50, 0x52, 0x32) |
247 | | pub const MAGIC_PREFIX: [u8; 3] = [0x41, 0x50, 0x52]; // "APR" |
248 | | |
249 | | /// Legacy magic for compatibility |
250 | | pub const MAGIC: [u8; 4] = [0x41, 0x50, 0x52, 0x00]; |
251 | | |
252 | | /// Header size in bytes (64-byte aligned) |
253 | | pub const HEADER_SIZE: usize = 64; |
254 | | |
255 | | /// Tensor alignment in bytes |
256 | | pub const ALIGNMENT: usize = 64; |
257 | | |
258 | | // ============================================================================ |
259 | | // Dequantization helpers for quantized tensor formats |
260 | | // ============================================================================ |
261 | | |
262 | | /// Convert F16 (IEEE 754 half-precision) to F32 |
263 | | #[inline] |
264 | 67 | pub(crate) fn f16_to_f32(bits: u16) -> f32 { |
265 | 67 | let sign = u32::from((bits >> 15) & 1); |
266 | 67 | let exp = u32::from((bits >> 10) & 0x1F); |
267 | 67 | let mant = u32::from(bits & 0x3FF); |
268 | | |
269 | 67 | if exp == 0 { |
270 | 14 | if mant == 0 { |
271 | | // Zero |
272 | 8 | f32::from_bits(sign << 31) |
273 | | } else { |
274 | | // Subnormal - convert to normalized f32 |
275 | 6 | let mut m = mant; |
276 | 6 | let mut e = 0i32; |
277 | 48 | while (m & 0x400) == 0 { |
278 | 42 | m <<= 1; |
279 | 42 | e -= 1; |
280 | 42 | } |
281 | 6 | m &= 0x3FF; |
282 | 6 | let f32_exp = (127 - 15 + 1 + e) as u32; |
283 | 6 | f32::from_bits((sign << 31) | (f32_exp << 23) | (m << 13)) |
284 | | } |
285 | 53 | } else if exp == 31 { |
286 | | // Inf or NaN |
287 | 7 | if mant == 0 { |
288 | 4 | f32::from_bits((sign << 31) | (0xFF << 23)) |
289 | | } else { |
290 | 3 | f32::from_bits((sign << 31) | (0xFF << 23) | (mant << 13)) |
291 | | } |
292 | | } else { |
293 | | // Normal number |
294 | 46 | let f32_exp = (exp as i32 - 15 + 127) as u32; |
295 | 46 | f32::from_bits((sign << 31) | (f32_exp << 23) | (mant << 13)) |
296 | | } |
297 | 67 | } |
298 | | |
299 | | /// Dequantize F16 data to F32 |
300 | 9 | pub(crate) fn dequantize_f16(bytes: &[u8], num_elements: usize) -> Vec<f32> { |
301 | 9 | let mut result = Vec::with_capacity(num_elements); |
302 | 15 | for chunk in bytes9 .chunks_exact9 (2) { |
303 | 15 | let bits = u16::from_le_bytes([chunk[0], chunk[1]]); |
304 | 15 | result.push(f16_to_f32(bits)); |
305 | 15 | } |
306 | 9 | result.truncate(num_elements); |
307 | 9 | result |
308 | 9 | } |
309 | | |
310 | | /// Dequantize Q8_0 format (GGUF compatible) |
311 | | /// Q8_0: blocks of 32 elements, each block has 2-byte f16 scale + 32 bytes of int8 quants |
312 | 9 | pub(crate) fn dequantize_q8_0(bytes: &[u8], num_elements: usize) -> Vec<f32> { |
313 | | const BLOCK_SIZE: usize = 32; |
314 | | const BLOCK_BYTES: usize = 2 + 32; // f16 scale + 32 int8 values |
315 | | |
316 | 9 | let mut result = Vec::with_capacity(num_elements); |
317 | 9 | let mut offset = 0; |
318 | | |
319 | 21 | while result.len() < num_elements && offset + BLOCK_BYTES13 <= bytes.len() { |
320 | | // Read scale (f16) |
321 | 12 | let scale_bits = u16::from_le_bytes([bytes[offset], bytes[offset + 1]]); |
322 | 12 | let scale = f16_to_f32(scale_bits); |
323 | 12 | offset += 2; |
324 | | |
325 | | // Read 32 int8 values |
326 | 374 | for i363 in 0..32 { |
327 | 363 | if result.len() >= num_elements { |
328 | 1 | break; |
329 | 362 | } |
330 | 362 | let v = f32::from(bytes[offset + i] as i8); |
331 | 362 | result.push(v * scale); |
332 | | } |
333 | 12 | offset += 32; |
334 | | } |
335 | | |
336 | 9 | result.truncate(num_elements); |
337 | 9 | result |
338 | 9 | } |
339 | | |
340 | | /// Dequantize Q4_K format (GGUF K-quants) |
341 | | /// Q4_K: super blocks of 256 elements |
342 | | /// Each super block: d (f16) + dmin (f16) + scales (12 bytes) + qs (128 bytes) = 144 bytes |
343 | 7 | pub(crate) fn dequantize_q4_k(bytes: &[u8], num_elements: usize) -> Vec<f32> { |
344 | | const SUPER_BLOCK_SIZE: usize = 256; |
345 | | const SUPER_BLOCK_BYTES: usize = 2 + 2 + 12 + 128; // 144 bytes |
346 | | |
347 | 7 | let mut result = Vec::with_capacity(num_elements); |
348 | 7 | let mut offset = 0; |
349 | | |
350 | 13 | while result.len() < num_elements && offset + SUPER_BLOCK_BYTES7 <= bytes.len() { |
351 | | // Read d (f16 scale) and dmin (f16 min) |
352 | 6 | let d = f16_to_f32(u16::from_le_bytes([bytes[offset], bytes[offset + 1]])); |
353 | 6 | let dmin = f16_to_f32(u16::from_le_bytes([bytes[offset + 2], bytes[offset + 3]])); |
354 | 6 | offset += 4; |
355 | | |
356 | | // Read scales (12 bytes = 8 6-bit scale values packed) |
357 | 6 | let scales_bytes = &bytes[offset..offset + 12]; |
358 | 6 | let mut scales = [0u8; 8]; |
359 | 6 | let mut mins = [0u8; 8]; |
360 | | |
361 | | // Unpack 6-bit scales and mins from 12 bytes |
362 | 30 | for i24 in 0..4 { |
363 | 24 | scales[i] = scales_bytes[i] & 0x3F; |
364 | 24 | scales[i + 4] = scales_bytes[i + 4] & 0x3F; |
365 | 24 | mins[i] = (scales_bytes[i] >> 6) | ((scales_bytes[i + 8] & 0x0F) << 2); |
366 | 24 | mins[i + 4] = (scales_bytes[i + 4] >> 6) | ((scales_bytes[i + 8] >> 4) << 2); |
367 | 24 | } |
368 | 6 | offset += 12; |
369 | | |
370 | | // Read 128 bytes = 256 4-bit quantized values |
371 | 6 | let qs = &bytes[offset..offset + 128]; |
372 | 6 | offset += 128; |
373 | | |
374 | | // Dequantize: each sub-block has 32 elements (8 sub-blocks total) |
375 | 54 | for j48 in 0..8 { |
376 | 48 | let scale = d * f32::from(scales[j]); |
377 | 48 | let min_val = dmin * f32::from(mins[j]); |
378 | | |
379 | 619 | for l584 in 0..16 { |
380 | 584 | if result.len() >= num_elements { |
381 | 13 | break; |
382 | 571 | } |
383 | 571 | let q_byte = qs[j * 16 + l]; |
384 | 571 | let q0 = (q_byte & 0x0F) as f32; |
385 | 571 | let q1 = (q_byte >> 4) as f32; |
386 | 571 | result.push(q0 * scale - min_val); |
387 | 571 | if result.len() < num_elements { |
388 | 570 | result.push(q1 * scale - min_val); |
389 | 570 | }1 |
390 | | } |
391 | | } |
392 | | } |
393 | | |
394 | 7 | result.truncate(num_elements); |
395 | 7 | result |
396 | 7 | } |
397 | | |
398 | | /// Dequantize Q6_K format (GGUF K-quants) |
399 | | /// Q6_K: super blocks of 256 elements |
400 | | /// Each super block: ql (128 bytes) + qh (64 bytes) + scales (16 bytes) + d (f16) = 210 bytes |
401 | 7 | pub(crate) fn dequantize_q6_k(bytes: &[u8], num_elements: usize) -> Vec<f32> { |
402 | | const SUPER_BLOCK_SIZE: usize = 256; |
403 | | const SUPER_BLOCK_BYTES: usize = 128 + 64 + 16 + 2; // 210 bytes |
404 | | |
405 | 7 | let mut result = Vec::with_capacity(num_elements); |
406 | 7 | let mut offset = 0; |
407 | | |
408 | 13 | while result.len() < num_elements && offset + SUPER_BLOCK_BYTES7 <= bytes.len() { |
409 | | // Read ql (128 bytes = low 4 bits of 256 6-bit values) |
410 | 6 | let ql = &bytes[offset..offset + 128]; |
411 | 6 | offset += 128; |
412 | | |
413 | | // Read qh (64 bytes = high 2 bits of 256 6-bit values) |
414 | 6 | let qh = &bytes[offset..offset + 64]; |
415 | 6 | offset += 64; |
416 | | |
417 | | // Read scales (16 bytes = 16 int8 scales) |
418 | 6 | let scales = &bytes[offset..offset + 16]; |
419 | 6 | offset += 16; |
420 | | |
421 | | // Read d (f16) |
422 | 6 | let d = f16_to_f32(u16::from_le_bytes([bytes[offset], bytes[offset + 1]])); |
423 | 6 | offset += 2; |
424 | | |
425 | | // Dequantize 16 sub-blocks of 16 elements each |
426 | 102 | for j96 in 0..16 { |
427 | 96 | let scale = d * f32::from(scales[j] as i8); |
428 | | |
429 | 650 | for l581 in 0..8 { |
430 | 581 | if result.len() >= num_elements { |
431 | 27 | break; |
432 | 554 | } |
433 | 554 | let idx = j * 8 + l; |
434 | 554 | let ql_byte = ql[idx]; |
435 | 554 | let qh_byte = qh[idx / 2]; |
436 | | |
437 | | // Extract two 6-bit values |
438 | 554 | let qh_shift = (l % 2) * 4; |
439 | 554 | let q0 = ((ql_byte & 0x0F) | ((qh_byte >> qh_shift) & 0x03) << 4) as i8 - 32; |
440 | 554 | let q1 = ((ql_byte >> 4) | (((qh_byte >> qh_shift) >> 2) & 0x03) << 4) as i8 - 32; |
441 | | |
442 | 554 | result.push(f32::from(q0) * scale); |
443 | 554 | if result.len() < num_elements { |
444 | 553 | result.push(f32::from(q1) * scale); |
445 | 553 | }1 |
446 | | } |
447 | | } |
448 | | } |
449 | | |
450 | 7 | result.truncate(num_elements); |
451 | 7 | result |
452 | 7 | } |
453 | | |
454 | | // ============================================================================ |
455 | | // Quantization type mapping for GPU kernels |
456 | | // ============================================================================ |
457 | | |
458 | | /// Map APR dtype string to GGML quantization type ID. |
459 | | /// |
460 | | /// These IDs are used by `load_quantized_weights_with_type()` to select |
461 | | /// the correct GPU dequantization kernel (Q4K GEMV, Q6K GEMV, etc.). |
462 | | #[inline] |
463 | 43 | pub(crate) fn dtype_to_ggml_qtype(dtype: &str) -> Option<u32> { |
464 | 43 | match dtype { |
465 | 43 | "Q4_K" | "q4_k"40 => Some(12)5 , // GGML_TYPE_Q4_K |
466 | 38 | "Q5_K" | "q5_k"35 => Some(13)4 , // GGML_TYPE_Q5_K |
467 | 34 | "Q6_K" | "q6_k"31 => Some(14)4 , // GGML_TYPE_Q6_K |
468 | 30 | "Q8_0" | "q8_0"26 => Some(8)5 , // GGML_TYPE_Q8_0 |
469 | 25 | "Q4_0" | "q4_0"21 => Some(2)5 , // GGML_TYPE_Q4_0 |
470 | 20 | "Q4_1" | "q4_1"17 => Some(3)4 , // GGML_TYPE_Q4_1 |
471 | 16 | "Q5_0" | "q5_0"13 => Some(6)4 , // GGML_TYPE_Q5_0 |
472 | 12 | _ => None, // F32/F16 are not quantized |
473 | | } |
474 | 43 | } |
475 | | |
476 | | /// Check if dtype is a quantized format that can use GPU dequant kernels. |
477 | | #[inline] |
478 | 18 | pub(crate) fn is_quantized_dtype(dtype: &str) -> bool { |
479 | 18 | dtype_to_ggml_qtype(dtype).is_some() |
480 | 18 | } |
481 | | |
482 | | /// APR v2 feature flags |
483 | | #[derive(Debug, Clone, Copy, Default)] |
484 | | pub struct AprFlags(u16); |
485 | | |
486 | | impl AprFlags { |
487 | | /// LZ4 compression enabled |
488 | | pub const LZ4_COMPRESSED: u16 = 0x0001; |
489 | | /// Zstandard compression enabled |
490 | | pub const ZSTD_COMPRESSED: u16 = 0x0002; |
491 | | /// Model is encrypted |
492 | | pub const ENCRYPTED: u16 = 0x0004; |
493 | | /// Model has cryptographic signature |
494 | | pub const SIGNED: u16 = 0x0008; |
495 | | /// Model is sharded across multiple files |
496 | | pub const SHARDED: u16 = 0x0010; |
497 | | /// Weights are quantized (int8/int4) |
498 | | pub const QUANTIZED: u16 = 0x0020; |
499 | | /// Model includes embedded vocabulary |
500 | | pub const HAS_VOCAB: u16 = 0x0200; |
501 | | |
502 | | /// Create flags from raw bits |
503 | | #[must_use] |
504 | 194 | pub const fn new(bits: u16) -> Self { |
505 | 194 | Self(bits) |
506 | 194 | } |
507 | | |
508 | | /// Check if model uses compression (LZ4 or Zstd) |
509 | | #[must_use] |
510 | 166 | pub const fn is_compressed(&self) -> bool { |
511 | 166 | self.0 & (Self::LZ4_COMPRESSED | Self::ZSTD_COMPRESSED) != 0 |
512 | 166 | } |
513 | | |
514 | | /// Check if model uses LZ4 compression |
515 | | #[must_use] |
516 | 10 | pub const fn is_lz4(&self) -> bool { |
517 | 10 | self.0 & Self::LZ4_COMPRESSED != 0 |
518 | 10 | } |
519 | | |
520 | | /// Check if model uses ZSTD compression |
521 | | #[must_use] |
522 | 8 | pub const fn is_zstd(&self) -> bool { |
523 | 8 | self.0 & Self::ZSTD_COMPRESSED != 0 |
524 | 8 | } |
525 | | |
526 | | /// Check if model is encrypted |
527 | | #[must_use] |
528 | 161 | pub const fn is_encrypted(&self) -> bool { |
529 | 161 | self.0 & Self::ENCRYPTED != 0 |
530 | 161 | } |
531 | | |
532 | | /// Check if weights are quantized |
533 | | #[must_use] |
534 | 10 | pub const fn is_quantized(&self) -> bool { |
535 | 10 | self.0 & Self::QUANTIZED != 0 |
536 | 10 | } |
537 | | |
538 | | /// Check if model includes embedded vocabulary |
539 | | #[must_use] |
540 | 4 | pub const fn has_vocab(&self) -> bool { |
541 | 4 | self.0 & Self::HAS_VOCAB != 0 |
542 | 4 | } |
543 | | } |
544 | | |
545 | | /// APR v2 file header (64 bytes) |
546 | | #[derive(Debug, Clone)] |
547 | | pub struct AprHeader { |
548 | | /// Magic number ("APR\0") |
549 | | pub magic: [u8; 4], |
550 | | /// Format version (major, minor) |
551 | | pub version: (u8, u8), |
552 | | /// Feature flags |
553 | | pub flags: AprFlags, |
554 | | /// Number of tensors |
555 | | pub tensor_count: u32, |
556 | | /// Offset to metadata section |
557 | | pub metadata_offset: u64, |
558 | | /// Size of metadata section |
559 | | pub metadata_size: u32, |
560 | | /// Offset to tensor index |
561 | | pub tensor_index_offset: u64, |
562 | | /// Offset to tensor data |
563 | | pub data_offset: u64, |
564 | | /// Header checksum (CRC32) |
565 | | pub checksum: u32, |
566 | | } |
567 | | |
568 | | impl AprHeader { |
569 | | /// Parse header from bytes |
570 | 187 | pub fn from_bytes(data: &[u8]) -> Result<Self> { |
571 | 187 | if data.len() < HEADER_SIZE { |
572 | 5 | return Err(RealizarError::FormatError { |
573 | 5 | reason: format!( |
574 | 5 | ".apr header too small: {} bytes (need {})", |
575 | 5 | data.len(), |
576 | 5 | HEADER_SIZE |
577 | 5 | ), |
578 | 5 | }); |
579 | 182 | } |
580 | | |
581 | | // Check magic - first 3 bytes must be "APR", 4th byte is version |
582 | 182 | let magic: [u8; 4] = data[0..4] |
583 | 182 | .try_into() |
584 | 182 | .map_err(|_| RealizarError::FormatError { |
585 | 0 | reason: "Failed to read magic bytes".to_string(), |
586 | 0 | })?; |
587 | | |
588 | | // Validate magic prefix (APR) |
589 | 182 | if magic[0..3] != MAGIC_PREFIX { |
590 | 7 | return Err(RealizarError::FormatError { |
591 | 7 | reason: format!( |
592 | 7 | "Invalid .apr magic: expected APR {:?}, got {:?}", |
593 | 7 | MAGIC_PREFIX, &magic[0..3] |
594 | 7 | ), |
595 | 7 | }); |
596 | 175 | } |
597 | | |
598 | | // Validate version byte (0, '1', or '2') |
599 | 175 | let version_byte = magic[3]; |
600 | 175 | if version_byte != 0 && version_byte != b'1'0 && version_byte != b'2'0 { |
601 | 0 | return Err(RealizarError::FormatError { |
602 | 0 | reason: format!( |
603 | 0 | "Invalid .apr version byte: expected 0, '1', or '2', got {}", |
604 | 0 | version_byte |
605 | 0 | ), |
606 | 0 | }); |
607 | 175 | } |
608 | | |
609 | | // APR v1 (magic "APR1") has different header layout - not supported for inference |
610 | | // APR v1 is used by Whisper models but has inline tensor index format |
611 | 175 | if version_byte == b'1' { |
612 | 0 | return Err(RealizarError::UnsupportedOperation { |
613 | 0 | operation: "load_apr_v1".to_string(), |
614 | 0 | reason: "APR v1 format not supported for inference. \ |
615 | 0 | Use 'apr convert model.apr -o model_v2.apr --format apr2' \ |
616 | 0 | to convert to APR v2 format, or use the GGUF version.".to_string(), |
617 | 0 | }); |
618 | 175 | } |
619 | | |
620 | 175 | let version = (data[4], data[5]); |
621 | 175 | let flags = AprFlags::new(u16::from_le_bytes([data[6], data[7]])); |
622 | 175 | let tensor_count = u32::from_le_bytes([data[8], data[9], data[10], data[11]]); |
623 | 175 | let metadata_offset = u64::from_le_bytes([ |
624 | 175 | data[12], data[13], data[14], data[15], data[16], data[17], data[18], data[19], |
625 | 175 | ]); |
626 | 175 | let metadata_size = u32::from_le_bytes([data[20], data[21], data[22], data[23]]); |
627 | 175 | let tensor_index_offset = u64::from_le_bytes([ |
628 | 175 | data[24], data[25], data[26], data[27], data[28], data[29], data[30], data[31], |
629 | 175 | ]); |
630 | 175 | let data_offset = u64::from_le_bytes([ |
631 | 175 | data[32], data[33], data[34], data[35], data[36], data[37], data[38], data[39], |
632 | 175 | ]); |
633 | 175 | let checksum = u32::from_le_bytes([data[40], data[41], data[42], data[43]]); |
634 | | |
635 | 175 | Ok(Self { |
636 | 175 | magic, |
637 | 175 | version, |
638 | 175 | flags, |
639 | 175 | tensor_count, |
640 | 175 | metadata_offset, |
641 | 175 | metadata_size, |
642 | 175 | tensor_index_offset, |
643 | 175 | data_offset, |
644 | 175 | checksum, |
645 | 175 | }) |
646 | 187 | } |
647 | | } |
648 | | |
649 | | /// Tensor entry in the index |
650 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
651 | | pub struct TensorEntry { |
652 | | /// Tensor name (e.g., "model.layers.0.attention.wq") |
653 | | pub name: String, |
654 | | /// Data type (e.g., "F32", "F16", "BF16", "I8") |
655 | | pub dtype: String, |
656 | | /// Tensor dimensions |
657 | | pub shape: Vec<usize>, |
658 | | /// Byte offset from data section start |
659 | | pub offset: u64, |
660 | | /// Size in bytes |
661 | | pub size: u64, |
662 | | } |
663 | | |
664 | | impl TensorEntry { |
665 | | /// Parse tensor entry from binary format (aprender v2 format) |
666 | | /// |
667 | | /// Binary format: |
668 | | /// - name_len (2 bytes LE) + name bytes |
669 | | /// - dtype (1 byte) |
670 | | /// - ndim (1 byte) + dims (8 bytes LE each, up to 8) |
671 | | /// - offset (8 bytes LE) |
672 | | /// - size (8 bytes LE) |
673 | 202 | pub fn from_binary(data: &[u8]) -> Result<(Self, usize)> { |
674 | 202 | if data.len() < 4 { |
675 | 1 | return Err(RealizarError::FormatError { |
676 | 1 | reason: "Tensor entry too short".to_string(), |
677 | 1 | }); |
678 | 201 | } |
679 | | |
680 | 201 | let mut pos = 0; |
681 | | |
682 | | // Name |
683 | 201 | let name_len = u16::from_le_bytes([data[pos], data[pos + 1]]) as usize; |
684 | 201 | pos += 2; |
685 | | |
686 | 201 | if data.len() < pos + name_len + 2 { |
687 | 104 | return Err(RealizarError::FormatError { |
688 | 104 | reason: "Tensor entry truncated at name".to_string(), |
689 | 104 | }); |
690 | 97 | } |
691 | | |
692 | 97 | let name = String::from_utf8_lossy(&data[pos..pos + name_len]).to_string(); |
693 | 97 | pos += name_len; |
694 | | |
695 | | // Dtype (1 byte) |
696 | 97 | let dtype_byte = data[pos]; |
697 | 97 | pos += 1; |
698 | 97 | let dtype = match dtype_byte { |
699 | 80 | 0 => "F32", |
700 | 3 | 1 => "F16", |
701 | 3 | 2 => "BF16", |
702 | 2 | 3 => "I8", |
703 | 1 | 4 => "I16", |
704 | 1 | 5 => "I32", |
705 | 1 | 6 => "I64", |
706 | 1 | 7 => "U8", |
707 | 1 | 8 => "Q4_K", // GGUF Q4_K_M quantization (4.5 bits/element) |
708 | 1 | 9 => "Q6_K", // GGUF Q6_K quantization (6.5 bits/element) |
709 | 2 | 10 => "Q8_0", // GGUF Q8_0 quantization (8 bits/element) |
710 | 1 | _ => "F32", |
711 | | } |
712 | 97 | .to_string(); |
713 | | |
714 | | // Shape: ndim (1 byte) + dims |
715 | 97 | let ndim = data[pos] as usize; |
716 | 97 | pos += 1; |
717 | | |
718 | 97 | if data.len() < pos + ndim * 8 + 16 { |
719 | 2 | return Err(RealizarError::FormatError { |
720 | 2 | reason: "Tensor entry truncated at shape".to_string(), |
721 | 2 | }); |
722 | 95 | } |
723 | | |
724 | 95 | let mut shape = Vec::with_capacity(ndim); |
725 | 166 | for _ in 0..ndim95 { |
726 | 166 | let dim = u64::from_le_bytes([ |
727 | 166 | data[pos], |
728 | 166 | data[pos + 1], |
729 | 166 | data[pos + 2], |
730 | 166 | data[pos + 3], |
731 | 166 | data[pos + 4], |
732 | 166 | data[pos + 5], |
733 | 166 | data[pos + 6], |
734 | 166 | data[pos + 7], |
735 | 166 | ]) as usize; |
736 | 166 | pos += 8; |
737 | 166 | shape.push(dim); |
738 | 166 | } |
739 | | |
740 | | // Offset and size |
741 | 95 | let offset = u64::from_le_bytes([ |
742 | 95 | data[pos], |
743 | 95 | data[pos + 1], |
744 | 95 | data[pos + 2], |
745 | 95 | data[pos + 3], |
746 | 95 | data[pos + 4], |
747 | 95 | data[pos + 5], |
748 | 95 | data[pos + 6], |
749 | 95 | data[pos + 7], |
750 | 95 | ]); |
751 | 95 | pos += 8; |
752 | | |
753 | 95 | let size = u64::from_le_bytes([ |
754 | 95 | data[pos], |
755 | 95 | data[pos + 1], |
756 | 95 | data[pos + 2], |
757 | 95 | data[pos + 3], |
758 | 95 | data[pos + 4], |
759 | 95 | data[pos + 5], |
760 | 95 | data[pos + 6], |
761 | 95 | data[pos + 7], |
762 | 95 | ]); |
763 | 95 | pos += 8; |
764 | | |
765 | 95 | Ok(( |
766 | 95 | Self { |
767 | 95 | name, |
768 | 95 | dtype, |
769 | 95 | shape, |
770 | 95 | offset, |
771 | 95 | size, |
772 | 95 | }, |
773 | 95 | pos, |
774 | 95 | )) |
775 | 202 | } |
776 | | |
777 | | /// Calculate element count from shape |
778 | 16 | pub fn element_count(&self) -> usize { |
779 | 16 | self.shape.iter().product() |
780 | 16 | } |
781 | | } |
782 | | |
783 | | /// Model metadata from .apr file |
784 | | #[derive(Debug, Clone, Default, Serialize, Deserialize)] |
785 | | pub struct AprMetadata { |
786 | | /// Model type (e.g., "transformer_lm", "whisper", "llama") |
787 | | #[serde(default)] |
788 | | pub model_type: Option<String>, |
789 | | /// Human-readable model name |
790 | | #[serde(default)] |
791 | | pub name: Option<String>, |
792 | | /// Model architecture family |
793 | | #[serde(default)] |
794 | | pub architecture: Option<String>, |
795 | | /// Hidden dimension size |
796 | | #[serde(default)] |
797 | | pub hidden_size: Option<usize>, |
798 | | /// Number of transformer layers |
799 | | #[serde(default)] |
800 | | pub num_layers: Option<usize>, |
801 | | /// Number of attention heads |
802 | | #[serde(default)] |
803 | | pub num_heads: Option<usize>, |
804 | | /// Number of key-value heads (for GQA, defaults to num_heads) |
805 | | #[serde(default)] |
806 | | pub num_kv_heads: Option<usize>, |
807 | | /// Vocabulary size |
808 | | #[serde(default)] |
809 | | pub vocab_size: Option<usize>, |
810 | | /// FFN intermediate dimension |
811 | | #[serde(default)] |
812 | | pub intermediate_size: Option<usize>, |
813 | | /// Maximum context/sequence length |
814 | | #[serde(default)] |
815 | | pub max_position_embeddings: Option<usize>, |
816 | | /// RoPE theta for position encoding |
817 | | #[serde(default)] |
818 | | pub rope_theta: Option<f32>, |
819 | | /// RoPE type: 0=NORM (adjacent pairs), 2=NEOX (split halves) |
820 | | /// CORRECTNESS-011: Qwen2.5 models require rope_type=2 (NEOX style) |
821 | | #[serde(default)] |
822 | | pub rope_type: Option<u32>, |
823 | | /// Layer norm epsilon |
824 | | #[serde(default)] |
825 | | pub rms_norm_eps: Option<f32>, |
826 | | /// Additional metadata fields |
827 | | #[serde(flatten)] |
828 | | pub extra: HashMap<String, serde_json::Value>, |
829 | | } |
830 | | |
831 | | impl AprMetadata { |
832 | | /// Check if this model has transformer configuration |
833 | | #[must_use] |
834 | 18 | pub fn is_transformer(&self) -> bool { |
835 | 18 | self.hidden_size.is_some() |
836 | 14 | && self.num_layers.is_some() |
837 | 9 | && self.num_heads.is_some() |
838 | 8 | && self.vocab_size.is_some() |
839 | 18 | } |
840 | | |
841 | | /// Extract embedded tokenizer vocabulary from APR metadata (GH-156) |
842 | | /// |
843 | | /// APR files can contain embedded tokenizer data in the metadata section. |
844 | | /// This is the preferred way to decode tokens - no sibling files needed. |
845 | | /// |
846 | | /// # Returns |
847 | | /// - `Some(vocab)` if tokenizer.vocabulary is present in metadata |
848 | | /// - `None` if no embedded tokenizer |
849 | | #[must_use] |
850 | 0 | pub fn get_embedded_vocabulary(&self) -> Option<Vec<String>> { |
851 | 0 | let vocab_value = self.extra.get("tokenizer.vocabulary")?; |
852 | 0 | let vocab_array = vocab_value.as_array()?; |
853 | | |
854 | 0 | let vocab: Vec<String> = vocab_array |
855 | 0 | .iter() |
856 | 0 | .filter_map(|v| v.as_str().map(String::from)) |
857 | 0 | .collect(); |
858 | | |
859 | 0 | if vocab.is_empty() { |
860 | 0 | None |
861 | | } else { |
862 | 0 | Some(vocab) |
863 | | } |
864 | 0 | } |
865 | | |
866 | | /// Get embedded BOS token ID from APR metadata |
867 | | #[must_use] |
868 | 0 | pub fn get_embedded_bos_token_id(&self) -> Option<u32> { |
869 | 0 | self.extra |
870 | 0 | .get("tokenizer.bos_token_id") |
871 | 0 | .and_then(|v| v.as_u64()) |
872 | 0 | .map(|v| v as u32) |
873 | 0 | } |
874 | | |
875 | | /// Get embedded EOS token ID from APR metadata |
876 | | #[must_use] |
877 | 0 | pub fn get_embedded_eos_token_id(&self) -> Option<u32> { |
878 | 0 | self.extra |
879 | 0 | .get("tokenizer.eos_token_id") |
880 | 0 | .and_then(|v| v.as_u64()) |
881 | 0 | .map(|v| v as u32) |
882 | 0 | } |
883 | | } |
884 | | |
885 | | /// APR v2 model for realizar inference |
886 | | /// |
887 | | /// # Memory Management |
888 | | /// |
889 | | /// Uses memory-mapped I/O for uncompressed files to avoid zram pressure. |
890 | | /// After loading tensors to GPU, call `release_cpu_pages()` to advise |
891 | | /// the kernel that pages can be dropped (re-faulted from disk if needed). |
892 | | /// |
893 | | /// # References |
894 | | /// |
895 | | /// - Didona et al. (2022): mmap vs read() performance |
896 | | /// - See docs/model-loading.md for full design rationale |
897 | | #[derive(Debug)] |
898 | | pub struct AprV2Model { |
899 | | /// Header information |
900 | | header: AprHeader, |
901 | | /// Model metadata |
902 | | metadata: AprMetadata, |
903 | | /// Tensor index |
904 | | tensors: Vec<TensorEntry>, |
905 | | /// Raw file data (mmap for uncompressed, heap for compressed) |
906 | | data: ModelData, |
907 | | } |
908 | | |
909 | | impl AprV2Model { |
910 | | /// Load a model from a .apr file using memory mapping. |
911 | | /// |
912 | | /// # Memory Efficiency |
913 | | /// |
914 | | /// For uncompressed files, uses `mmap()` for zero-copy access. |
915 | | /// The kernel manages pages via demand paging - only accessed |
916 | | /// pages are loaded into RAM. After GPU transfer, call |
917 | | /// `release_cpu_pages()` to advise the kernel to drop pages. |
918 | | /// |
919 | | /// For compressed files, falls back to heap allocation after |
920 | | /// decompression (mmap not possible for decompressed data). |
921 | | /// |
922 | | /// # References |
923 | | /// |
924 | | /// - Didona et al. (2022): mmap achieves 2.3x throughput vs read() |
925 | | /// - See docs/model-loading.md for design rationale |
926 | | #[cfg(not(target_arch = "wasm32"))] |
927 | 5 | pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> { |
928 | | use std::io::Read; |
929 | | |
930 | 5 | let path_ref = path.as_ref(); |
931 | | |
932 | | // Read just the header first to check for compression |
933 | 5 | let mut file4 = File::open(path_ref).map_err(|e| RealizarError::IoError { |
934 | 1 | message: format!("Failed to open .apr file: {e}"), |
935 | 1 | })?; |
936 | | |
937 | 4 | let mut header_buf = [0u8; HEADER_SIZE]; |
938 | 4 | file.read_exact(&mut header_buf) |
939 | 4 | .map_err(|e| RealizarError::IoError { |
940 | 1 | message: format!("Failed to read .apr header: {e}"), |
941 | 1 | })?; |
942 | | |
943 | 3 | let header2 = AprHeader::from_bytes(&header_buf)?1 ; |
944 | | |
945 | | // Check for unsupported features |
946 | 2 | if header.flags.is_encrypted() { |
947 | 0 | return Err(RealizarError::FormatError { |
948 | 0 | reason: "Encrypted .apr files not yet supported".to_string(), |
949 | 0 | }); |
950 | 2 | } |
951 | | |
952 | | // Choose loading strategy based on compression |
953 | 2 | let data = if header.flags.is_compressed() { |
954 | | // Compressed: must read entire file into heap, then decompress |
955 | 0 | drop(file); // Close file handle |
956 | 0 | let raw_data = std::fs::read(path_ref).map_err(|e| RealizarError::IoError { |
957 | 0 | message: format!("Failed to read compressed .apr file: {e}"), |
958 | 0 | })?; |
959 | 0 | let decompressed = Self::decompress_apr_data(&header, raw_data)?; |
960 | 0 | ModelData::from_vec(decompressed) |
961 | | } else { |
962 | | // Uncompressed: use mmap for zero-copy access |
963 | 2 | drop(file); // Close file handle before mmap |
964 | 2 | ModelData::open_mmap(path_ref)?0 |
965 | | }; |
966 | | |
967 | | // Advise sequential access pattern for parsing |
968 | | #[cfg(unix)] |
969 | 2 | let _ = data.advise_sequential(); |
970 | | |
971 | 2 | Self::from_model_data(header, data) |
972 | 5 | } |
973 | | |
974 | | /// Load a model from a .apr file (WASM fallback). |
975 | | #[cfg(target_arch = "wasm32")] |
976 | | pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> { |
977 | | let raw_data = std::fs::read(path.as_ref()).map_err(|e| RealizarError::IoError { |
978 | | message: format!("Failed to read .apr file: {e}"), |
979 | | })?; |
980 | | Self::from_bytes(raw_data) |
981 | | } |
982 | | |
983 | | /// Load a model from bytes (heap-allocated). |
984 | | /// |
985 | | /// Use this for: |
986 | | /// - Compressed files after decompression |
987 | | /// - Data received over network |
988 | | /// - WASM environments (no mmap support) |
989 | | /// |
990 | | /// For file-based loading with mmap support, use `load()` instead. |
991 | 153 | pub fn from_bytes(data: Vec<u8>) -> Result<Self> { |
992 | | // Parse header |
993 | 153 | let header151 = AprHeader::from_bytes(&data)?2 ; |
994 | | |
995 | | // Check for unsupported features |
996 | 151 | if header.flags.is_encrypted() { |
997 | 2 | return Err(RealizarError::FormatError { |
998 | 2 | reason: "Encrypted .apr files not yet supported".to_string(), |
999 | 2 | }); |
1000 | 149 | } |
1001 | | |
1002 | | // Decompress data if needed (GH-35) |
1003 | 149 | let data148 = if header.flags.is_compressed() { |
1004 | 1 | Self::decompress_apr_data(&header, data)? |
1005 | | } else { |
1006 | 148 | data |
1007 | | }; |
1008 | | |
1009 | 148 | Self::from_model_data(header, ModelData::from_vec(data)) |
1010 | 153 | } |
1011 | | |
1012 | | /// Internal: construct model from header and ModelData. |
1013 | 150 | fn from_model_data(header: AprHeader, data: ModelData) -> Result<Self> { |
1014 | 150 | let data_slice = data.as_slice(); |
1015 | | |
1016 | | // Parse metadata |
1017 | 150 | let metadata_start = header.metadata_offset as usize; |
1018 | 150 | let metadata_end = metadata_start + header.metadata_size as usize; |
1019 | | |
1020 | 150 | if data_slice.len() < metadata_end { |
1021 | 1 | return Err(RealizarError::FormatError { |
1022 | 1 | reason: format!( |
1023 | 1 | ".apr file truncated: metadata extends to {} but file is {} bytes", |
1024 | 1 | metadata_end, |
1025 | 1 | data_slice.len() |
1026 | 1 | ), |
1027 | 1 | }); |
1028 | 149 | } |
1029 | | |
1030 | 149 | let metadata: AprMetadata = if header.metadata_size > 0 { |
1031 | 145 | serde_json::from_slice(&data_slice[metadata_start..metadata_end]).unwrap_or_default() |
1032 | | } else { |
1033 | 4 | AprMetadata::default() |
1034 | | }; |
1035 | | |
1036 | | // Parse tensor index (binary format from aprender v2) |
1037 | 149 | let index_start = header.tensor_index_offset as usize; |
1038 | 149 | let index_end = header.data_offset as usize; |
1039 | | |
1040 | 149 | let mut tensors = Vec::with_capacity(header.tensor_count as usize); |
1041 | 149 | if index_start < index_end && index_end145 <= data_slice.len() { |
1042 | 145 | let index_data = &data_slice[index_start..index_end]; |
1043 | 145 | let mut pos = 0; |
1044 | | |
1045 | 220 | while pos < index_data.len() && tensors179 .len() < header.tensor_count as usize { |
1046 | 179 | match TensorEntry::from_binary(&index_data[pos..]) { |
1047 | 75 | Ok((entry, consumed)) => { |
1048 | 75 | tensors.push(entry); |
1049 | 75 | pos += consumed; |
1050 | 75 | }, |
1051 | 104 | Err(_) => break, // Stop on parse error |
1052 | | } |
1053 | | } |
1054 | 4 | } |
1055 | | |
1056 | 149 | Ok(Self { |
1057 | 149 | header, |
1058 | 149 | metadata, |
1059 | 149 | tensors, |
1060 | 149 | data, |
1061 | 149 | }) |
1062 | 150 | } |
1063 | | |
1064 | | /// Decompress APR data based on compression flags (GH-35) |
1065 | | /// |
1066 | | /// The compressed format stores: header (64 bytes, uncompressed) + compressed payload. |
1067 | | /// We decompress the payload and reconstruct the full data vector. |
1068 | | #[allow(unreachable_patterns)] // Pattern varies based on apr-compression feature |
1069 | 1 | fn decompress_apr_data(header: &AprHeader, data: Vec<u8>) -> Result<Vec<u8>> { |
1070 | | #[cfg(feature = "apr-compression")] |
1071 | | let compressed_payload = &data[HEADER_SIZE..]; |
1072 | | |
1073 | | #[cfg(feature = "apr-compression")] |
1074 | | { |
1075 | | let decompressed = if header.flags.is_lz4() { |
1076 | | lz4_flex::decompress_size_prepended(compressed_payload).map_err(|e| { |
1077 | | RealizarError::FormatError { |
1078 | | reason: format!("LZ4 decompression failed: {e}"), |
1079 | | } |
1080 | | })? |
1081 | | } else if header.flags.is_zstd() { |
1082 | | zstd::decode_all(compressed_payload).map_err(|e| RealizarError::FormatError { |
1083 | | reason: format!("ZSTD decompression failed: {e}"), |
1084 | | })? |
1085 | | } else { |
1086 | | // Unknown compression - should not happen |
1087 | | return Err(RealizarError::FormatError { |
1088 | | reason: "Unknown compression algorithm in APR flags".to_string(), |
1089 | | }); |
1090 | | }; |
1091 | | |
1092 | | // Reconstruct full data: header + decompressed payload |
1093 | | let mut result = Vec::with_capacity(HEADER_SIZE + decompressed.len()); |
1094 | | result.extend_from_slice(&data[..HEADER_SIZE]); |
1095 | | result.extend_from_slice(&decompressed); |
1096 | | Ok(result) |
1097 | | } |
1098 | | |
1099 | | #[cfg(not(feature = "apr-compression"))] |
1100 | | { |
1101 | 1 | let _ = (header, &data); // Suppress unused warnings |
1102 | 1 | Err(RealizarError::FormatError { |
1103 | 1 | reason: "Compressed .apr files require 'apr-compression' feature".to_string(), |
1104 | 1 | }) |
1105 | | } |
1106 | 1 | } |
1107 | | |
1108 | | /// Get number of tensors |
1109 | | #[must_use] |
1110 | 6 | pub fn tensor_count(&self) -> u32 { |
1111 | 6 | self.header.tensor_count |
1112 | 6 | } |
1113 | | |
1114 | | /// Get tensor names |
1115 | | #[must_use] |
1116 | 3 | pub fn tensor_names(&self) -> Vec<&str> { |
1117 | 3 | self.tensors.iter().map(|t| t.name.as_str()).collect() |
1118 | 3 | } |
1119 | | |
1120 | | /// Get metadata |
1121 | | #[must_use] |
1122 | 7 | pub fn metadata(&self) -> &AprMetadata { |
1123 | 7 | &self.metadata |
1124 | 7 | } |
1125 | | |
1126 | | /// Get tensor by name |
1127 | | #[must_use] |
1128 | 33 | pub fn get_tensor(&self, name: &str) -> Option<&TensorEntry> { |
1129 | 51 | self.tensors.iter()33 .find33 (|t| t.name == name) |
1130 | 33 | } |
1131 | | |
1132 | | /// Get tensor data as f32 slice |
1133 | 10 | pub fn get_tensor_f32(&self, name: &str) -> Result<Vec<f32>> { |
1134 | 10 | let entry7 = self |
1135 | 10 | .get_tensor(name) |
1136 | 10 | .ok_or_else(|| RealizarError::FormatError { |
1137 | 3 | reason: format!("Tensor not found: {name}"), |
1138 | 3 | })?; |
1139 | | |
1140 | 7 | let start = (self.header.data_offset + entry.offset) as usize; |
1141 | 7 | let end = start + entry.size as usize; |
1142 | 7 | let data_slice = self.data.as_slice(); |
1143 | | |
1144 | 7 | if end > data_slice.len() { |
1145 | 0 | return Err(RealizarError::FormatError { |
1146 | 0 | reason: format!("Tensor data out of bounds: {name}"), |
1147 | 0 | }); |
1148 | 7 | } |
1149 | | |
1150 | 7 | let bytes = &data_slice[start..end]; |
1151 | | |
1152 | | // Calculate total number of elements from shape |
1153 | 7 | let num_elements: usize = entry.shape.iter().product(); |
1154 | | |
1155 | | // Parse based on dtype |
1156 | 7 | match entry.dtype.as_str() { |
1157 | 7 | "F32" | "f32"3 => { |
1158 | 4 | let floats: Vec<f32> = bytes |
1159 | 4 | .chunks_exact(4) |
1160 | 40 | .map4 (|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])) |
1161 | 4 | .collect(); |
1162 | 4 | Ok(floats) |
1163 | | }, |
1164 | 3 | "F16" | "f16"2 => Ok(1 dequantize_f161 (bytes, num_elements)), |
1165 | 2 | "Q8_0" | "q8_0"1 => Ok(1 dequantize_q8_01 (bytes, num_elements)), |
1166 | 1 | "Q4_K" | "q4_k" => Ok(0 dequantize_q4_k0 (bytes, num_elements)), |
1167 | 1 | "Q6_K" | "q6_k" => Ok(0 dequantize_q6_k0 (bytes, num_elements)), |
1168 | 1 | dtype => Err(RealizarError::FormatError { |
1169 | 1 | reason: format!("Unsupported tensor dtype: {dtype}"), |
1170 | 1 | }), |
1171 | | } |
1172 | 10 | } |
1173 | | |
1174 | | /// Get raw tensor bytes |
1175 | 4 | pub fn get_tensor_bytes(&self, name: &str) -> Result<&[u8]> { |
1176 | 4 | let entry2 = self |
1177 | 4 | .get_tensor(name) |
1178 | 4 | .ok_or_else(|| RealizarError::FormatError { |
1179 | 2 | reason: format!("Tensor not found: {name}"), |
1180 | 2 | })?; |
1181 | | |
1182 | 2 | let start = (self.header.data_offset + entry.offset) as usize; |
1183 | 2 | let end = start + entry.size as usize; |
1184 | 2 | let data_slice = self.data.as_slice(); |
1185 | | |
1186 | 2 | if end > data_slice.len() { |
1187 | 0 | return Err(RealizarError::FormatError { |
1188 | 0 | reason: format!("Tensor data out of bounds: {name}"), |
1189 | 0 | }); |
1190 | 2 | } |
1191 | | |
1192 | 2 | Ok(&data_slice[start..end]) |
1193 | 4 | } |
1194 | | |
1195 | | /// Release CPU pages after GPU transfer (Unix only). |
1196 | | /// |
1197 | | /// Advises the kernel that the mapped pages are no longer needed. |
1198 | | /// The kernel will drop pages immediately (not compress to zram) |
1199 | | /// and re-fault from disk if accessed again. |
1200 | | /// |
1201 | | /// # When to Call |
1202 | | /// |
1203 | | /// After all tensor data has been copied to GPU via `cuMemcpy()`. |
1204 | | /// This is the key method for reducing zram pressure. |
1205 | | /// |
1206 | | /// # Example |
1207 | | /// |
1208 | | /// ```rust,ignore |
1209 | | /// let model = AprV2Model::load("model.apr")?; |
1210 | | /// for name in model.tensor_names() { |
1211 | | /// let bytes = model.get_tensor_bytes(&name)?; |
1212 | | /// cuda::memcpy_htod(gpu_ptr, bytes); |
1213 | | /// } |
1214 | | /// // Free CPU pages now that data is on GPU |
1215 | | /// model.release_cpu_pages()?; |
1216 | | /// ``` |
1217 | | #[cfg(all(unix, not(target_arch = "wasm32")))] |
1218 | 1 | pub fn release_cpu_pages(&self) -> Result<()> { |
1219 | 1 | self.data.release_cpu_pages() |
1220 | 1 | } |
1221 | | |
1222 | | /// Check if model is using memory-mapped I/O. |
1223 | | /// |
1224 | | /// Returns `true` if the model was loaded via mmap (uncompressed file). |
1225 | | /// Returns `false` if the model is heap-allocated (compressed file or WASM). |
1226 | | #[must_use] |
1227 | 3 | pub fn is_mmap(&self) -> bool { |
1228 | 3 | self.data.is_mmap() |
1229 | 3 | } |
1230 | | |
1231 | | /// Estimate total parameters |
1232 | | #[must_use] |
1233 | 3 | pub fn estimated_parameters(&self) -> usize { |
1234 | 3 | self.tensors |
1235 | 3 | .iter() |
1236 | 3 | .map(|t| t.shape.iter().product::<usize>()) |
1237 | 3 | .sum() |
1238 | 3 | } |
1239 | | |
1240 | | /// Run inference on input features (for simple models) |
1241 | | /// |
1242 | | /// For transformer models, use `forward()` instead. |
1243 | | /// |
1244 | | /// # Arguments |
1245 | | /// |
1246 | | /// * `features` - Input feature vector |
1247 | | /// |
1248 | | /// # Returns |
1249 | | /// |
1250 | | /// Output vector |
1251 | | /// |
1252 | | /// # Errors |
1253 | | /// |
1254 | | /// Returns error if model has no tensors |
1255 | 5 | pub fn predict(&self, features: &[f32]) -> Result<Vec<f32>> { |
1256 | 5 | if self.tensors.is_empty() && self.header.tensor_count == 00 { |
1257 | 0 | let sum: f32 = features.iter().sum(); |
1258 | 0 | return Ok(vec![sum]); |
1259 | 5 | } |
1260 | | |
1261 | | // Linear model: y = Wx + b (if we have weights) |
1262 | 5 | if let Some(weight1 ) = self.get_tensor("weight") { |
1263 | 1 | let weights = self.get_tensor_f32("weight")?0 ; |
1264 | 1 | let bias = self.get_tensor_f32("bias").unwrap_or_default(); |
1265 | | |
1266 | 1 | let output_dim = weight.shape.first().copied().unwrap_or(1); |
1267 | 1 | let input_dim = weight.shape.get(1).copied().unwrap_or(features.len()); |
1268 | | |
1269 | 1 | let mut output = vec![0.0; output_dim]; |
1270 | 2 | for (i, out) in output.iter_mut()1 .enumerate1 () { |
1271 | 6 | for (j, &feat) in features2 .iter2 ().take2 (input_dim2 ).enumerate2 () { |
1272 | 6 | *out += weights.get(i * input_dim + j).copied().unwrap_or(0.0) * feat; |
1273 | 6 | } |
1274 | 2 | *out += bias.get(i).copied().unwrap_or(0.0); |
1275 | | } |
1276 | 1 | return Ok(output); |
1277 | 4 | } |
1278 | | |
1279 | 4 | let sum: f32 = features.iter().sum(); |
1280 | 4 | Ok(vec![sum]) |
1281 | 5 | } |
1282 | | |
1283 | | /// Run transformer forward pass on token IDs |
1284 | | /// |
1285 | | /// Returns logits for the next token prediction. |
1286 | | /// |
1287 | | /// # Arguments |
1288 | | /// |
1289 | | /// * `token_ids` - Input token IDs |
1290 | | /// |
1291 | | /// # Returns |
1292 | | /// |
1293 | | /// Logits vector of size `vocab_size` |
1294 | | /// |
1295 | | /// # Errors |
1296 | | /// |
1297 | | /// Returns error if model is not a transformer or tensors are missing |
1298 | 6 | pub fn forward(&self, token_ids: &[u32]) -> Result<Vec<f32>> { |
1299 | 6 | if token_ids.is_empty() { |
1300 | 2 | return Err(RealizarError::InvalidShape { |
1301 | 2 | reason: "Token sequence cannot be empty".to_string(), |
1302 | 2 | }); |
1303 | 4 | } |
1304 | | |
1305 | 4 | if !self.metadata.is_transformer() { |
1306 | 3 | return Err(RealizarError::FormatError { |
1307 | 3 | reason: "Model is not a transformer (missing config)".to_string(), |
1308 | 3 | }); |
1309 | 1 | } |
1310 | | |
1311 | 1 | let hidden_dim = self.metadata.hidden_size.unwrap_or(0); |
1312 | 1 | let num_layers = self.metadata.num_layers.unwrap_or(0); |
1313 | 1 | let num_heads = self.metadata.num_heads.unwrap_or(1); |
1314 | 1 | let num_kv_heads = self.metadata.num_kv_heads.unwrap_or(num_heads); |
1315 | 1 | let vocab_size = self.metadata.vocab_size.unwrap_or(0); |
1316 | 1 | let intermediate_dim = self.metadata.intermediate_size.unwrap_or(hidden_dim * 4); |
1317 | 1 | let eps = self.metadata.rms_norm_eps.unwrap_or(1e-6); |
1318 | | |
1319 | | // 1. Token embedding lookup |
1320 | 1 | let embed_name0 = self.find_tensor_name(&[ |
1321 | 1 | "model.embed_tokens.weight", |
1322 | 1 | "embed_tokens.weight", // SafeTensors (no model. prefix) |
1323 | 1 | "transformer.wte.weight", |
1324 | 1 | "embeddings.word_embeddings.weight", |
1325 | 1 | "tok_embeddings.weight", |
1326 | 1 | "token_embd.weight", // GGUF naming convention |
1327 | 1 | ])?; |
1328 | | |
1329 | 0 | let embeddings = self.get_tensor_f32(&embed_name)?; |
1330 | 0 | let mut hidden = Vec::with_capacity(token_ids.len() * hidden_dim); |
1331 | | |
1332 | 0 | for &token_id in token_ids { |
1333 | 0 | let offset = (token_id as usize) * hidden_dim; |
1334 | 0 | if offset + hidden_dim <= embeddings.len() { |
1335 | 0 | hidden.extend_from_slice(&embeddings[offset..offset + hidden_dim]); |
1336 | 0 | } else { |
1337 | 0 | hidden.extend(std::iter::repeat_n(0.0, hidden_dim)); |
1338 | 0 | } |
1339 | | } |
1340 | | |
1341 | | // 2. Process through transformer layers |
1342 | 0 | for layer_idx in 0..num_layers { |
1343 | | // Try common naming patterns (HuggingFace, SafeTensors, GPT-2, LLaMA, GGUF) |
1344 | 0 | let attn_norm_name = self.find_tensor_name(&[ |
1345 | 0 | &format!("model.layers.{layer_idx}.input_layernorm.weight"), |
1346 | 0 | &format!("layers.{layer_idx}.input_layernorm.weight"), // SafeTensors |
1347 | 0 | &format!("transformer.h.{layer_idx}.ln_1.weight"), |
1348 | 0 | &format!("layers.{layer_idx}.attention_norm.weight"), |
1349 | 0 | &format!("blk.{layer_idx}.attn_norm.weight"), // GGUF naming |
1350 | 0 | ])?; |
1351 | | |
1352 | 0 | let q_name = self.find_tensor_name(&[ |
1353 | 0 | &format!("model.layers.{layer_idx}.self_attn.q_proj.weight"), |
1354 | 0 | &format!("layers.{layer_idx}.self_attn.q_proj.weight"), // SafeTensors |
1355 | 0 | &format!("transformer.h.{layer_idx}.attn.q_proj.weight"), |
1356 | 0 | &format!("layers.{layer_idx}.attention.wq.weight"), |
1357 | 0 | &format!("blk.{layer_idx}.attn_q.weight"), // GGUF naming |
1358 | 0 | ])?; |
1359 | | |
1360 | 0 | let k_name = self.find_tensor_name(&[ |
1361 | 0 | &format!("model.layers.{layer_idx}.self_attn.k_proj.weight"), |
1362 | 0 | &format!("layers.{layer_idx}.self_attn.k_proj.weight"), // SafeTensors |
1363 | 0 | &format!("transformer.h.{layer_idx}.attn.k_proj.weight"), |
1364 | 0 | &format!("layers.{layer_idx}.attention.wk.weight"), |
1365 | 0 | &format!("blk.{layer_idx}.attn_k.weight"), // GGUF naming |
1366 | 0 | ])?; |
1367 | | |
1368 | 0 | let v_name = self.find_tensor_name(&[ |
1369 | 0 | &format!("model.layers.{layer_idx}.self_attn.v_proj.weight"), |
1370 | 0 | &format!("layers.{layer_idx}.self_attn.v_proj.weight"), // SafeTensors |
1371 | 0 | &format!("transformer.h.{layer_idx}.attn.v_proj.weight"), |
1372 | 0 | &format!("layers.{layer_idx}.attention.wv.weight"), |
1373 | 0 | &format!("blk.{layer_idx}.attn_v.weight"), // GGUF naming |
1374 | 0 | ])?; |
1375 | | |
1376 | 0 | let o_name = self.find_tensor_name(&[ |
1377 | 0 | &format!("model.layers.{layer_idx}.self_attn.o_proj.weight"), |
1378 | 0 | &format!("layers.{layer_idx}.self_attn.o_proj.weight"), // SafeTensors |
1379 | 0 | &format!("transformer.h.{layer_idx}.attn.out_proj.weight"), |
1380 | 0 | &format!("layers.{layer_idx}.attention.wo.weight"), |
1381 | 0 | &format!("blk.{layer_idx}.attn_output.weight"), // GGUF naming |
1382 | 0 | ])?; |
1383 | | |
1384 | | // Load tensors |
1385 | 0 | let norm_weight = self.get_tensor_f32(&attn_norm_name)?; |
1386 | 0 | let q_weight = self.get_tensor_f32(&q_name)?; |
1387 | 0 | let k_weight = self.get_tensor_f32(&k_name)?; |
1388 | 0 | let v_weight = self.get_tensor_f32(&v_name)?; |
1389 | 0 | let o_weight = self.get_tensor_f32(&o_name)?; |
1390 | | |
1391 | | // RMSNorm |
1392 | 0 | let normed = rms_norm(&hidden, &norm_weight, eps); |
1393 | | |
1394 | | // Attention: Q, K, V projections |
1395 | 0 | let seq_len = token_ids.len(); |
1396 | 0 | let head_dim = hidden_dim / num_heads; |
1397 | | |
1398 | 0 | let q = matmul(&normed, &q_weight, seq_len, hidden_dim, hidden_dim); |
1399 | 0 | let k = matmul( |
1400 | 0 | &normed, |
1401 | 0 | &k_weight, |
1402 | 0 | seq_len, |
1403 | 0 | hidden_dim, |
1404 | 0 | num_kv_heads * head_dim, |
1405 | | ); |
1406 | 0 | let v = matmul( |
1407 | 0 | &normed, |
1408 | 0 | &v_weight, |
1409 | 0 | seq_len, |
1410 | 0 | hidden_dim, |
1411 | 0 | num_kv_heads * head_dim, |
1412 | | ); |
1413 | | |
1414 | | // Simplified attention (no RoPE for now, full attention) |
1415 | 0 | let attn_out = simple_attention(&q, &k, &v, seq_len, num_heads, num_kv_heads, head_dim); |
1416 | | |
1417 | | // Output projection |
1418 | 0 | let attn_proj = matmul(&attn_out, &o_weight, seq_len, hidden_dim, hidden_dim); |
1419 | | |
1420 | | // Residual connection |
1421 | 0 | for (h, &a) in hidden.iter_mut().zip(attn_proj.iter()) { |
1422 | 0 | *h += a; |
1423 | 0 | } |
1424 | | |
1425 | | // FFN |
1426 | 0 | let ffn_norm_name = self.find_tensor_name(&[ |
1427 | 0 | &format!("model.layers.{layer_idx}.post_attention_layernorm.weight"), |
1428 | 0 | &format!("layers.{layer_idx}.post_attention_layernorm.weight"), // SafeTensors |
1429 | 0 | &format!("transformer.h.{layer_idx}.ln_2.weight"), |
1430 | 0 | &format!("layers.{layer_idx}.ffn_norm.weight"), |
1431 | 0 | &format!("blk.{layer_idx}.ffn_norm.weight"), // GGUF naming |
1432 | 0 | ])?; |
1433 | | |
1434 | 0 | let gate_name = self.find_tensor_name(&[ |
1435 | 0 | &format!("model.layers.{layer_idx}.mlp.gate_proj.weight"), |
1436 | 0 | &format!("layers.{layer_idx}.mlp.gate_proj.weight"), // SafeTensors |
1437 | 0 | &format!("transformer.h.{layer_idx}.mlp.gate_proj.weight"), |
1438 | 0 | &format!("layers.{layer_idx}.feed_forward.w1.weight"), |
1439 | 0 | &format!("blk.{layer_idx}.ffn_gate.weight"), // GGUF naming |
1440 | 0 | ])?; |
1441 | | |
1442 | 0 | let up_name = self.find_tensor_name(&[ |
1443 | 0 | &format!("model.layers.{layer_idx}.mlp.up_proj.weight"), |
1444 | 0 | &format!("layers.{layer_idx}.mlp.up_proj.weight"), // SafeTensors |
1445 | 0 | &format!("transformer.h.{layer_idx}.mlp.up_proj.weight"), |
1446 | 0 | &format!("layers.{layer_idx}.feed_forward.w3.weight"), |
1447 | 0 | &format!("blk.{layer_idx}.ffn_up.weight"), // GGUF naming |
1448 | 0 | ])?; |
1449 | | |
1450 | 0 | let down_name = self.find_tensor_name(&[ |
1451 | 0 | &format!("model.layers.{layer_idx}.mlp.down_proj.weight"), |
1452 | 0 | &format!("layers.{layer_idx}.mlp.down_proj.weight"), // SafeTensors |
1453 | 0 | &format!("transformer.h.{layer_idx}.mlp.down_proj.weight"), |
1454 | 0 | &format!("layers.{layer_idx}.feed_forward.w2.weight"), |
1455 | 0 | &format!("blk.{layer_idx}.ffn_down.weight"), // GGUF naming |
1456 | 0 | ])?; |
1457 | | |
1458 | 0 | let ffn_norm = self.get_tensor_f32(&ffn_norm_name)?; |
1459 | 0 | let gate = self.get_tensor_f32(&gate_name)?; |
1460 | 0 | let up = self.get_tensor_f32(&up_name)?; |
1461 | 0 | let down = self.get_tensor_f32(&down_name)?; |
1462 | | |
1463 | 0 | let normed = rms_norm(&hidden, &ffn_norm, eps); |
1464 | 0 | let gate_out = matmul(&normed, &gate, seq_len, hidden_dim, intermediate_dim); |
1465 | 0 | let up_out = matmul(&normed, &up, seq_len, hidden_dim, intermediate_dim); |
1466 | | |
1467 | | // SiLU activation and element-wise multiply |
1468 | 0 | let mut ffn_hidden = Vec::with_capacity(seq_len * intermediate_dim); |
1469 | 0 | for (g, u) in gate_out.iter().zip(up_out.iter()) { |
1470 | 0 | let silu = g * (1.0 / (1.0 + (-g).exp())); |
1471 | 0 | ffn_hidden.push(silu * u); |
1472 | 0 | } |
1473 | | |
1474 | 0 | let ffn_out = matmul(&ffn_hidden, &down, seq_len, intermediate_dim, hidden_dim); |
1475 | | |
1476 | | // Residual |
1477 | 0 | for (h, &f) in hidden.iter_mut().zip(ffn_out.iter()) { |
1478 | 0 | *h += f; |
1479 | 0 | } |
1480 | | } |
1481 | | |
1482 | | // 3. Final layer norm |
1483 | 0 | let final_norm_name = self.find_tensor_name(&[ |
1484 | 0 | "model.norm.weight", |
1485 | 0 | "norm.weight", // SafeTensors |
1486 | 0 | "transformer.ln_f.weight", |
1487 | 0 | "output_norm.weight", // GGUF naming |
1488 | 0 | ])?; |
1489 | 0 | let final_norm = self.get_tensor_f32(&final_norm_name)?; |
1490 | 0 | let hidden = rms_norm(&hidden, &final_norm, eps); |
1491 | | |
1492 | | // 4. LM head (last token only for generation) |
1493 | 0 | let lm_head_name = self.find_tensor_name(&[ |
1494 | 0 | "lm_head.weight", |
1495 | 0 | "output.weight", |
1496 | 0 | "model.embed_tokens.weight", // Tied embeddings |
1497 | 0 | "embed_tokens.weight", // SafeTensors tied embeddings |
1498 | 0 | ])?; |
1499 | 0 | let lm_head = self.get_tensor_f32(&lm_head_name)?; |
1500 | | |
1501 | | // Get hidden state for last token |
1502 | 0 | let last_hidden = &hidden[hidden.len() - hidden_dim..]; |
1503 | | |
1504 | | // Project to vocab |
1505 | 0 | let mut logits = vec![0.0; vocab_size]; |
1506 | 0 | for (i, logit) in logits.iter_mut().enumerate() { |
1507 | 0 | for (j, &h) in last_hidden.iter().enumerate() { |
1508 | 0 | *logit += h * lm_head.get(i * hidden_dim + j).copied().unwrap_or(0.0); |
1509 | 0 | } |
1510 | | } |
1511 | | |
1512 | 0 | Ok(logits) |
1513 | 6 | } |
1514 | | |
1515 | | /// Autoregressive text generation. |
1516 | | /// |
1517 | | /// Generates tokens one at a time using greedy decoding (argmax sampling). |
1518 | | /// |
1519 | | /// # Arguments |
1520 | | /// |
1521 | | /// * `input_tokens` - Initial token sequence (prompt) |
1522 | | /// * `max_new_tokens` - Maximum number of new tokens to generate |
1523 | | /// * `eos_token_id` - End-of-sequence token ID (stops generation early) |
1524 | | /// |
1525 | | /// # Returns |
1526 | | /// |
1527 | | /// Complete token sequence including input and generated tokens |
1528 | | /// |
1529 | | /// # Errors |
1530 | | /// |
1531 | | /// Returns error if model is not a transformer or forward pass fails |
1532 | 3 | pub fn generate( |
1533 | 3 | &self, |
1534 | 3 | input_tokens: &[u32], |
1535 | 3 | max_new_tokens: usize, |
1536 | 3 | eos_token_id: Option<u32>, |
1537 | 3 | ) -> Result<Vec<u32>> { |
1538 | 3 | if input_tokens.is_empty() { |
1539 | 1 | return Err(RealizarError::InvalidShape { |
1540 | 1 | reason: "Input tokens cannot be empty".to_string(), |
1541 | 1 | }); |
1542 | 2 | } |
1543 | | |
1544 | 2 | let mut tokens = input_tokens.to_vec(); |
1545 | 2 | let vocab_size = self.metadata.vocab_size.unwrap_or(0); |
1546 | | |
1547 | 2 | for _ in 0..max_new_tokens { |
1548 | | // Forward pass to get logits for next token |
1549 | 1 | let logits0 = self.forward(&tokens)?; |
1550 | | |
1551 | | // Greedy sampling: pick token with highest logit |
1552 | 0 | let next_token = logits |
1553 | 0 | .iter() |
1554 | 0 | .enumerate() |
1555 | 0 | .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) |
1556 | 0 | .map_or(0, |(idx, _)| idx as u32); |
1557 | | |
1558 | | // Check for EOS |
1559 | 0 | if let Some(eos) = eos_token_id { |
1560 | 0 | if next_token == eos { |
1561 | 0 | break; |
1562 | 0 | } |
1563 | 0 | } |
1564 | | |
1565 | | // Sanity check: don't append invalid tokens |
1566 | 0 | if (next_token as usize) >= vocab_size && vocab_size > 0 { |
1567 | 0 | break; |
1568 | 0 | } |
1569 | | |
1570 | 0 | tokens.push(next_token); |
1571 | | } |
1572 | | |
1573 | 1 | Ok(tokens) |
1574 | 3 | } |
1575 | | |
1576 | | /// Find first matching tensor name from candidates |
1577 | 1 | fn find_tensor_name(&self, candidates: &[&str]) -> Result<String> { |
1578 | 7 | for &name6 in candidates { |
1579 | 6 | if self.get_tensor(name).is_some() { |
1580 | 0 | return Ok(name.to_string()); |
1581 | 6 | } |
1582 | | } |
1583 | 1 | Err(RealizarError::FormatError { |
1584 | 1 | reason: format!("No matching tensor found. Tried: {:?}", candidates), |
1585 | 1 | }) |
1586 | 1 | } |
1587 | | |
1588 | | /// Load tokenizer from sibling tokenizer.json file |
1589 | | /// |
1590 | | /// Looks for tokenizer.json in the same directory as the model file. |
1591 | | /// Returns (vocab, bos_token_id, eos_token_id) if found. |
1592 | 5 | pub fn load_tokenizer_from_sibling( |
1593 | 5 | model_path: &Path, |
1594 | 5 | ) -> Option<(Vec<String>, Option<u32>, Option<u32>)> { |
1595 | 5 | let tokenizer_path = model_path.with_file_name("tokenizer.json"); |
1596 | 5 | if !tokenizer_path.exists() { |
1597 | 1 | return None; |
1598 | 4 | } |
1599 | | |
1600 | 4 | let content = fs::read_to_string(&tokenizer_path).ok()?0 ; |
1601 | 4 | let json3 : serde_json::Value3 = serde_json::from_str(&content).ok()?1 ; |
1602 | | |
1603 | | // Extract vocabulary from model.vocab |
1604 | 3 | let vocab_obj1 = json.get("model")?1 .get2 ("vocab")?1 ; |
1605 | 1 | let vocab_map = vocab_obj.as_object()?0 ; |
1606 | | |
1607 | | // Build vocab vector (sorted by ID) |
1608 | 1 | let mut vocab_vec: Vec<(String, u32)> = vocab_map |
1609 | 1 | .iter() |
1610 | 4 | .filter_map1 (|(token, id)| Some((token.clone(), id.as_u64()?0 as u32))) |
1611 | 1 | .collect(); |
1612 | 1 | vocab_vec.sort_by_key(|(_, id)| *id); |
1613 | | |
1614 | 1 | let vocab: Vec<String> = vocab_vec.into_iter().map(|(token, _)| token).collect(); |
1615 | | |
1616 | | // Extract special tokens |
1617 | 1 | let mut bos_id = None; |
1618 | 1 | let mut eos_id = None; |
1619 | | |
1620 | 1 | if let Some(added_tokens) = json.get("added_tokens").and_then(|v| v.as_array()) { |
1621 | 3 | for token2 in added_tokens { |
1622 | 2 | let content = token.get("content").and_then(|v| v.as_str()); |
1623 | 2 | let id = token |
1624 | 2 | .get("id") |
1625 | 2 | .and_then(serde_json::Value::as_u64) |
1626 | 2 | .map(|v| v as u32); |
1627 | | |
1628 | 2 | if let (Some(content), Some(id)) = (content, id) { |
1629 | 2 | if content == "<|endoftext|>" || content == "</s>" || content == "<eos>"1 { |
1630 | 1 | eos_id = Some(id); |
1631 | 1 | } |
1632 | 2 | if content == "<s>" || content == "<bos>"1 { |
1633 | 1 | bos_id = Some(id); |
1634 | 1 | } |
1635 | 0 | } |
1636 | | } |
1637 | 0 | } |
1638 | | |
1639 | 1 | Some((vocab, bos_id, eos_id)) |
1640 | 5 | } |
1641 | | |
1642 | | /// Decode token IDs to text using vocabulary |
1643 | | /// |
1644 | | /// If vocab is not available, returns formatted token IDs. |
1645 | 22 | pub fn decode_tokens(vocab: &[String], token_ids: &[u32]) -> String { |
1646 | 22 | let mut result = String::new(); |
1647 | 70 | for &id48 in token_ids { |
1648 | 48 | if let Some(token39 ) = vocab.get(id as usize) { |
1649 | 39 | // Handle byte-level BPE encoding (Ġ = space prefix) |
1650 | 39 | let decoded = token |
1651 | 39 | .replace("Ġ", " ") |
1652 | 39 | .replace("Ċ", "\n") |
1653 | 39 | .replace("ĉ", "\t"); |
1654 | 39 | result.push_str(&decoded); |
1655 | 39 | } else { |
1656 | 9 | result.push_str(&format!("[{}]", id)); |
1657 | 9 | } |
1658 | | } |
1659 | 22 | result |
1660 | 22 | } |
1661 | | |
1662 | | /// Encode text to token IDs using BPE tokenization |
1663 | | /// |
1664 | | /// Loads vocab and merges from tokenizer.json, then performs BPE encoding. |
1665 | | /// Returns None if tokenizer not found or encoding fails. |
1666 | 3 | pub fn encode_text(model_path: &Path, text: &str) -> Option<Vec<u32>> { |
1667 | 3 | let tokenizer_path = model_path.with_file_name("tokenizer.json"); |
1668 | 3 | if !tokenizer_path.exists() { |
1669 | 1 | return None; |
1670 | 2 | } |
1671 | | |
1672 | 2 | let content = fs::read_to_string(&tokenizer_path).ok()?0 ; |
1673 | 2 | let json1 : serde_json::Value1 = serde_json::from_str(&content).ok()?1 ; |
1674 | | |
1675 | | // Extract vocabulary (token -> id) |
1676 | 1 | let vocab_obj = json.get("model")?0 .get("vocab")?0 ; |
1677 | 1 | let vocab_map = vocab_obj.as_object()?0 ; |
1678 | 1 | let token_to_id: HashMap<String, u32> = vocab_map |
1679 | 1 | .iter() |
1680 | 4 | .filter_map1 (|(token, id)| Some((token.clone(), id.as_u64()?0 as u32))) |
1681 | 1 | .collect(); |
1682 | | |
1683 | | // Extract merges (pair rules for BPE) |
1684 | 1 | let merges = json.get("model")?0 .get("merges")?0 .as_array()?0 ; |
1685 | | |
1686 | 1 | let merge_rules: Vec<(String, String)> = merges |
1687 | 1 | .iter() |
1688 | 1 | .filter_map(|m| {0 |
1689 | 0 | let s = m.as_str()?; |
1690 | 0 | let parts: Vec<&str> = s.splitn(2, ' ').collect(); |
1691 | 0 | if parts.len() == 2 { |
1692 | 0 | Some((parts[0].to_string(), parts[1].to_string())) |
1693 | | } else { |
1694 | 0 | None |
1695 | | } |
1696 | 0 | }) |
1697 | 1 | .collect(); |
1698 | | |
1699 | | // BPE encoding: convert text to byte-level tokens, then apply merges |
1700 | 1 | let tokens = bpe_encode(text, &token_to_id, &merge_rules); |
1701 | 1 | Some(tokens) |
1702 | 3 | } |
1703 | | |
1704 | | /// Load tokenizer from embedded APR metadata (GH-156) |
1705 | | /// |
1706 | | /// APR files can contain embedded tokenizer data - this is the preferred |
1707 | | /// way to decode tokens since it doesn't require sibling files. |
1708 | | /// |
1709 | | /// Returns a simple decode-only tokenizer (no BPE encoding support). |
1710 | 0 | pub fn load_embedded_tokenizer(&self) -> Option<SimpleTokenizer> { |
1711 | 0 | let vocab = self.metadata.get_embedded_vocabulary()?; |
1712 | 0 | let bos_id = self.metadata.get_embedded_bos_token_id(); |
1713 | 0 | let eos_id = self.metadata.get_embedded_eos_token_id(); |
1714 | | |
1715 | 0 | Some(SimpleTokenizer { |
1716 | 0 | id_to_token: vocab, |
1717 | 0 | bos_token_id: bos_id, |
1718 | 0 | eos_token_id: eos_id, |
1719 | 0 | }) |
1720 | 0 | } |
1721 | | |
1722 | | /// Load a full tokenizer struct from sibling tokenizer.json |
1723 | | /// |
1724 | | /// Returns a BpeTokenizer that can be reused for multiple encode/decode calls. |
1725 | | /// For decode-only operations, prefer `load_embedded_tokenizer()` first. |
1726 | 3 | pub fn load_tokenizer(model_path: &Path) -> Option<BpeTokenizer> { |
1727 | 3 | let tokenizer_path = model_path.with_file_name("tokenizer.json"); |
1728 | 3 | if !tokenizer_path.exists() { |
1729 | 1 | return None; |
1730 | 2 | } |
1731 | | |
1732 | 2 | let content = fs::read_to_string(&tokenizer_path).ok()?0 ; |
1733 | 2 | let json1 : serde_json::Value1 = serde_json::from_str(&content).ok()?1 ; |
1734 | | |
1735 | | // Extract vocabulary |
1736 | 1 | let vocab_obj = json.get("model")?0 .get("vocab")?0 ; |
1737 | 1 | let vocab_map = vocab_obj.as_object()?0 ; |
1738 | | |
1739 | 1 | let mut token_to_id: HashMap<String, u32> = HashMap::new(); |
1740 | 1 | let mut id_to_token: Vec<String> = Vec::new(); |
1741 | | |
1742 | 1 | let mut vocab_vec: Vec<(String, u32)> = vocab_map |
1743 | 1 | .iter() |
1744 | 5 | .filter_map1 (|(token, id)| Some((token.clone(), id.as_u64()?0 as u32))) |
1745 | 1 | .collect(); |
1746 | 1 | vocab_vec.sort_by_key(|(_, id)| *id); |
1747 | | |
1748 | 6 | for (token5 , id5 ) in vocab_vec { |
1749 | 5 | token_to_id.insert(token.clone(), id); |
1750 | | // Pad id_to_token if needed |
1751 | 10 | while id_to_token.len() <= id as usize { |
1752 | 5 | id_to_token.push(String::new()); |
1753 | 5 | } |
1754 | 5 | id_to_token[id as usize] = token; |
1755 | | } |
1756 | | |
1757 | | // Extract merges |
1758 | 1 | let merges = json.get("model")?0 .get("merges")?0 .as_array()?0 ; |
1759 | 1 | let merge_rules: Vec<(String, String)> = merges |
1760 | 1 | .iter() |
1761 | 1 | .filter_map(|m| { |
1762 | 1 | let s = m.as_str()?0 ; |
1763 | 1 | let parts: Vec<&str> = s.splitn(2, ' ').collect(); |
1764 | 1 | if parts.len() == 2 { |
1765 | 1 | Some((parts[0].to_string(), parts[1].to_string())) |
1766 | | } else { |
1767 | 0 | None |
1768 | | } |
1769 | 1 | }) |
1770 | 1 | .collect(); |
1771 | | |
1772 | | // Extract special tokens |
1773 | 1 | let mut bos_id = None; |
1774 | 1 | let mut eos_id = None; |
1775 | | |
1776 | 1 | if let Some(added_tokens) = json.get("added_tokens").and_then(|v| v.as_array()) { |
1777 | 3 | for token2 in added_tokens { |
1778 | 2 | let content = token.get("content").and_then(|v| v.as_str()); |
1779 | 2 | let id = token |
1780 | 2 | .get("id") |
1781 | 2 | .and_then(serde_json::Value::as_u64) |
1782 | 2 | .map(|v| v as u32); |
1783 | | |
1784 | 2 | if let (Some(content), Some(id)) = (content, id) { |
1785 | 2 | if content == "<|endoftext|>" || content == "</s>"1 || content == "<eos>"1 { |
1786 | 1 | eos_id = Some(id); |
1787 | 1 | } |
1788 | 2 | if content == "<s>" || content == "<bos>" { |
1789 | 1 | bos_id = Some(id); |
1790 | 1 | } |
1791 | 0 | } |
1792 | | } |
1793 | 0 | } |
1794 | | |
1795 | 1 | Some(BpeTokenizer { |
1796 | 1 | token_to_id, |
1797 | 1 | id_to_token, |
1798 | 1 | merge_rules, |
1799 | 1 | bos_id, |
1800 | 1 | eos_id, |
1801 | 1 | }) |
1802 | 3 | } |
1803 | | } |
1804 | | |
1805 | | /// Legacy type alias for APR v2 model |
1806 | | pub type AprModel = AprV2Model; |
1807 | | /// Legacy type alias (model types are now in metadata) |
1808 | | pub type AprModelType = (); |
1809 | | |
1810 | | // ============================================================================= |
1811 | | |
1812 | | use memmap2::Mmap; |
1813 | | |
1814 | | /// Memory-mapped APR model for fast loading and GPU inference |
1815 | | /// |
1816 | | /// Similar to MappedGGUFModel, this provides zero-copy access to APR tensor data. |
1817 | | /// The file is memory-mapped for fast startup (~36x faster than full file read). |
1818 | | #[derive(Debug)] |
1819 | | pub struct MappedAprModel { |
1820 | | /// APR header |
1821 | | pub header: AprHeader, |
1822 | | /// Model metadata |
1823 | | pub metadata: AprMetadata, |
1824 | | /// Tensor index |
1825 | | pub tensors: Vec<TensorEntry>, |
1826 | | /// Memory-mapped file data |
1827 | | mmap: Mmap, |
1828 | | } |
1829 | | |
1830 | | impl MappedAprModel { |
1831 | | /// Load an APR model with memory mapping for fast startup |
1832 | | /// |
1833 | | /// # Arguments |
1834 | | /// * `path` - Path to the .apr file |
1835 | | /// |
1836 | | /// # Errors |
1837 | | /// Returns error if file cannot be opened or has invalid format. |
1838 | 0 | pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self> { |
1839 | 0 | let file = File::open(path.as_ref()).map_err(|e| RealizarError::IoError { |
1840 | 0 | message: format!("Failed to open .apr file: {e}"), |
1841 | 0 | })?; |
1842 | | |
1843 | | // SAFETY: File is opened read-only, callers validate format before trusting data |
1844 | 0 | let mmap = unsafe { |
1845 | 0 | Mmap::map(&file).map_err(|e| RealizarError::IoError { |
1846 | 0 | message: format!("Failed to mmap .apr file: {e}"), |
1847 | 0 | })? |
1848 | | }; |
1849 | | |
1850 | 0 | Self::from_mmap(mmap) |
1851 | 0 | } |
1852 | | |
1853 | | /// Create from existing memory map |
1854 | 0 | fn from_mmap(mmap: Mmap) -> Result<Self> { |
1855 | 0 | let data = &mmap[..]; |
1856 | | |
1857 | | // Parse header |
1858 | 0 | let header = AprHeader::from_bytes(data)?; |
1859 | | |
1860 | | // Validate magic |
1861 | 0 | if header.magic != MAGIC { |
1862 | 0 | return Err(RealizarError::FormatError { |
1863 | 0 | reason: "Invalid APR magic bytes".to_string(), |
1864 | 0 | }); |
1865 | 0 | } |
1866 | | |
1867 | | // Parse metadata |
1868 | 0 | let metadata_start = header.metadata_offset as usize; |
1869 | 0 | let metadata_end = metadata_start + header.metadata_size as usize; |
1870 | | |
1871 | 0 | if data.len() < metadata_end { |
1872 | 0 | return Err(RealizarError::FormatError { |
1873 | 0 | reason: "APR file truncated: metadata extends past EOF".to_string(), |
1874 | 0 | }); |
1875 | 0 | } |
1876 | | |
1877 | 0 | let metadata: AprMetadata = if header.metadata_size > 0 { |
1878 | 0 | serde_json::from_slice(&data[metadata_start..metadata_end]).unwrap_or_default() |
1879 | | } else { |
1880 | 0 | AprMetadata::default() |
1881 | | }; |
1882 | | |
1883 | | // Parse tensor index |
1884 | 0 | let index_start = header.tensor_index_offset as usize; |
1885 | 0 | let index_end = header.data_offset as usize; |
1886 | | |
1887 | 0 | let mut tensors = Vec::with_capacity(header.tensor_count as usize); |
1888 | 0 | if index_start < index_end && index_end <= data.len() { |
1889 | 0 | let index_data = &data[index_start..index_end]; |
1890 | 0 | let mut pos = 0; |
1891 | | |
1892 | 0 | while pos < index_data.len() && tensors.len() < header.tensor_count as usize { |
1893 | 0 | match TensorEntry::from_binary(&index_data[pos..]) { |
1894 | 0 | Ok((entry, consumed)) => { |
1895 | 0 | tensors.push(entry); |
1896 | 0 | pos += consumed; |
1897 | 0 | }, |
1898 | 0 | Err(_) => break, |
1899 | | } |
1900 | | } |
1901 | 0 | } |
1902 | | |
1903 | 0 | Ok(Self { |
1904 | 0 | header, |
1905 | 0 | metadata, |
1906 | 0 | tensors, |
1907 | 0 | mmap, |
1908 | 0 | }) |
1909 | 0 | } |
1910 | | |
1911 | | /// Get raw file data (for tensor access) |
1912 | | #[must_use] |
1913 | 0 | pub fn data(&self) -> &[u8] { |
1914 | 0 | &self.mmap[..] |
1915 | 0 | } |
1916 | | |
1917 | | /// Get file size in bytes |
1918 | | #[must_use] |
1919 | 0 | pub fn file_size(&self) -> usize { |
1920 | 0 | self.mmap.len() |
1921 | 0 | } |
1922 | | |
1923 | | /// Get tensor count |
1924 | | #[must_use] |
1925 | 0 | pub fn tensor_count(&self) -> usize { |
1926 | 0 | self.tensors.len() |
1927 | 0 | } |
1928 | | |
1929 | | /// Get data offset (start of tensor data section) |
1930 | | #[must_use] |
1931 | 0 | pub fn data_offset(&self) -> u64 { |
1932 | 0 | self.header.data_offset |
1933 | 0 | } |
1934 | | |
1935 | | /// Find tensor by name |
1936 | | #[must_use] |
1937 | 0 | pub fn find_tensor(&self, name: &str) -> Option<&TensorEntry> { |
1938 | 0 | self.tensors.iter().find(|t| t.name == name) |
1939 | 0 | } |
1940 | | |
1941 | | /// Get raw tensor data by name |
1942 | 0 | pub fn get_tensor_data(&self, name: &str) -> Result<&[u8]> { |
1943 | 0 | let tensor = self |
1944 | 0 | .find_tensor(name) |
1945 | 0 | .ok_or_else(|| RealizarError::FormatError { |
1946 | 0 | reason: format!("Tensor not found: {name}"), |
1947 | 0 | })?; |
1948 | | |
1949 | 0 | let start = self.header.data_offset as usize + tensor.offset as usize; |
1950 | 0 | let end = start + tensor.size as usize; |
1951 | | |
1952 | 0 | if end > self.mmap.len() { |
1953 | 0 | return Err(RealizarError::FormatError { |
1954 | 0 | reason: format!("Tensor {name} extends past EOF"), |
1955 | 0 | }); |
1956 | 0 | } |
1957 | | |
1958 | 0 | Ok(&self.mmap[start..end]) |
1959 | 0 | } |
1960 | | |
1961 | | /// Convert APR dtype string to GGML qtype |
1962 | | #[must_use] |
1963 | 0 | pub fn dtype_to_qtype(dtype: &str) -> u32 { |
1964 | 0 | match dtype { |
1965 | 0 | "F32" => 0, |
1966 | 0 | "F16" => 1, |
1967 | 0 | "Q4_0" => 2, |
1968 | 0 | "Q4_1" => 3, |
1969 | 0 | "Q5_0" => 6, |
1970 | 0 | "Q5_1" => 7, |
1971 | 0 | "Q8_0" => 8, |
1972 | 0 | "Q8_1" => 9, |
1973 | 0 | "Q2_K" => 10, |
1974 | 0 | "Q3_K" => 11, |
1975 | 0 | "Q4_K" => 12, |
1976 | 0 | "Q5_K" => 13, |
1977 | 0 | "Q6_K" => 14, |
1978 | 0 | "IQ2_XXS" => 16, |
1979 | 0 | "IQ2_XS" => 17, |
1980 | 0 | "BF16" => 30, |
1981 | 0 | _ => 0, // Default to F32 |
1982 | | } |
1983 | 0 | } |
1984 | | } |
1985 | | |
1986 | | // Tests extracted to tests.rs (PMAT-802) |
1987 | | #[cfg(test)] |
1988 | | #[path = "tests.rs"] |
1989 | | mod apr_tests; |