Skip to main content

apr_format/v2/
streaming_writer.rs

1//! Streaming v2 writer — constant-memory import for large models (issue #2231).
2//!
3//! Formerly `include!`d into `v2/mod.rs`; now a real module. The
4//! `AprV2StreamingWriter` struct lives here next to its `impl` (private fields).
5//! f16 conversion routes through [`crate::f16`] (IEEE round-to-nearest-even,
6//! `half` crate), NOT `trueno::f32_to_f16`.
7
8use super::{
9    align_64, AprV2Flags, AprV2Header, AprV2Metadata, TensorDType, TensorIndexEntry, V2FormatError,
10    HEADER_SIZE_V2,
11};
12use crate::f16::f32_to_f16;
13use std::io::{Read, Write};
14
15/// Streaming APR v2 writer — writes tensors to disk incrementally (realizar#136).
16///
17/// Unlike `AprV2Writer` which accumulates all tensor data in RAM,
18/// this writer streams tensor data to a temp file, keeping only the
19/// index entries (~KB) in memory. Peak RAM = largest single tensor.
20///
21/// # Architecture
22///
23/// 1. Tensor data written to temp file in insertion order, 64B aligned
24/// 2. Index entries (name, dtype, shape, offset, size) accumulated in Vec (~KB)
25/// 3. `finalize()` writes: header + metadata + index, then copies data from temp file
26///
27/// Index entries are sorted by name during `finalize()` (APR v2 contract).
28/// Data in the temp file stays in insertion order; index offsets point correctly.
29#[allow(missing_debug_implementations)]
30pub struct AprV2StreamingWriter {
31    header: AprV2Header,
32    metadata: AprV2Metadata,
33    /// Index entries only — tensor data is on disk
34    index_entries: Vec<TensorIndexEntry>,
35    /// Temp file for tensor data
36    data_writer: std::io::BufWriter<std::fs::File>,
37    /// Current offset in the data section
38    data_offset: u64,
39}
40
41impl AprV2StreamingWriter {
42    /// Create a new streaming writer.
43    ///
44    /// # Errors
45    ///
46    /// Returns error if the temp file cannot be created.
47    pub fn new(metadata: AprV2Metadata) -> Result<Self, V2FormatError> {
48        let mut header = AprV2Header::new();
49        header.flags = header.flags.with(AprV2Flags::LAYOUT_ROW_MAJOR);
50
51        let data_file = tempfile::tempfile()
52            .map_err(|e| V2FormatError::IoError(format!("Failed to create temp file: {e}")))?;
53
54        Ok(Self {
55            header,
56            metadata,
57            index_entries: Vec::new(),
58            data_writer: std::io::BufWriter::new(data_file),
59            data_offset: 0,
60        })
61    }
62
63    /// Add a tensor, writing its data to the temp file immediately.
64    ///
65    /// Only the index entry (~100 bytes) is kept in memory.
66    /// The `data` slice can be dropped after this call returns.
67    ///
68    /// # Errors
69    ///
70    /// Returns error if writing to the temp file fails.
71    pub fn add_tensor(
72        &mut self,
73        name: impl Into<String>,
74        dtype: TensorDType,
75        shape: Vec<usize>,
76        data: &[u8],
77    ) -> Result<(), V2FormatError> {
78        let entry = TensorIndexEntry::new(name, dtype, shape, self.data_offset, data.len() as u64);
79        self.index_entries.push(entry);
80
81        // Write data + 64-byte alignment padding
82        self.data_writer
83            .write_all(data)
84            .map_err(|e| V2FormatError::IoError(e.to_string()))?;
85
86        let padded_size = align_64(data.len());
87        let padding = padded_size - data.len();
88        if padding > 0 {
89            self.data_writer
90                .write_all(&vec![0u8; padding])
91                .map_err(|e| V2FormatError::IoError(e.to_string()))?;
92        }
93
94        self.data_offset += padded_size as u64;
95        Ok(())
96    }
97
98    /// Add f32 tensor (streaming).
99    ///
100    /// # Errors
101    ///
102    /// Returns error if writing fails.
103    pub fn add_f32_tensor(
104        &mut self,
105        name: impl Into<String>,
106        shape: Vec<usize>,
107        data: &[f32],
108    ) -> Result<(), V2FormatError> {
109        let bytes: Vec<u8> = data.iter().flat_map(|f| f.to_le_bytes()).collect();
110        self.add_tensor(name, TensorDType::F32, shape, &bytes)
111    }
112
113    /// Add raw BF16/F16 bytes directly (zero conversion, streaming).
114    ///
115    /// # Errors
116    ///
117    /// Returns error if writing fails.
118    pub fn add_raw_f16_tensor(
119        &mut self,
120        name: impl Into<String>,
121        shape: Vec<usize>,
122        data: &[u8],
123        is_bf16: bool,
124    ) -> Result<(), V2FormatError> {
125        let dtype = if is_bf16 {
126            TensorDType::BF16
127        } else {
128            TensorDType::F16
129        };
130        self.add_tensor(name, dtype, shape, data)
131    }
132
133    /// Add f16 tensor (converts f32 → f16, streaming).
134    ///
135    /// GH-478: Enables streaming quantization for sharded imports.
136    ///
137    /// # Errors
138    ///
139    /// Returns error if writing fails.
140    pub fn add_f16_tensor(
141        &mut self,
142        name: impl Into<String>,
143        shape: Vec<usize>,
144        data: &[f32],
145    ) -> Result<(), V2FormatError> {
146        let bytes: Vec<u8> = data
147            .iter()
148            .flat_map(|&f| f32_to_f16(f).to_le_bytes())
149            .collect();
150        self.add_tensor(name, TensorDType::F16, shape, &bytes)
151    }
152
153    /// Add Q8 tensor (8-bit symmetric quantization, streaming).
154    ///
155    /// GH-478: Enables streaming quantization for sharded imports.
156    /// Format: [scale: f32 (4 bytes)] + [quantized: i8 × n]
157    ///
158    /// # Errors
159    ///
160    /// Returns error if writing fails.
161    pub fn add_q8_tensor(
162        &mut self,
163        name: impl Into<String>,
164        shape: Vec<usize>,
165        data: &[f32],
166    ) -> Result<(), V2FormatError> {
167        if data.is_empty() {
168            return self.add_tensor(name, TensorDType::AprQ8, shape, &[]);
169        }
170        let max_abs = data.iter().map(|v| v.abs()).fold(0.0f32, f32::max);
171        let scale = if max_abs == 0.0 { 1.0 } else { max_abs / 127.0 };
172        let mut bytes = Vec::with_capacity(4 + data.len());
173        bytes.extend_from_slice(&scale.to_le_bytes());
174        for &v in data {
175            let q = (v / scale).round().clamp(-127.0, 127.0) as i8;
176            bytes.push(q as u8);
177        }
178        self.add_tensor(name, TensorDType::AprQ8, shape, &bytes)
179    }
180
181    /// Add Q4 tensor (4-bit symmetric quantization, block-wise, streaming).
182    ///
183    /// GH-478: Enables streaming quantization for sharded imports.
184    /// Format: For each block of 32 values:
185    ///   [block_scale: f16 (2 bytes)] + [packed nibbles: 16 bytes]
186    ///
187    /// # Errors
188    ///
189    /// Returns error if writing fails.
190    pub fn add_q4_tensor(
191        &mut self,
192        name: impl Into<String>,
193        shape: Vec<usize>,
194        data: &[f32],
195    ) -> Result<(), V2FormatError> {
196        const BLOCK_SIZE: usize = 32;
197        if data.is_empty() {
198            return self.add_tensor(name, TensorDType::AprQ4, shape, &[]);
199        }
200        let num_blocks = data.len().div_ceil(BLOCK_SIZE);
201        let mut bytes = Vec::with_capacity(num_blocks * 18);
202        for block_start in (0..data.len()).step_by(BLOCK_SIZE) {
203            let block_end = (block_start + BLOCK_SIZE).min(data.len());
204            let block = &data[block_start..block_end];
205            let max_abs = block.iter().map(|v| v.abs()).fold(0.0f32, f32::max);
206            let scale = if max_abs == 0.0 { 1.0 } else { max_abs / 7.0 };
207            bytes.extend_from_slice(&f32_to_f16(scale).to_le_bytes());
208            let mut packed_buf = [0u8; 16];
209            let mut packed_idx = 0;
210            for (i, &v) in block.iter().enumerate() {
211                let q = (v / scale).round().clamp(-8.0, 7.0) as i8;
212                let nibble = ((q + 8) as u8) & 0x0F;
213                if i % 2 == 0 {
214                    packed_buf[packed_idx] = nibble;
215                } else {
216                    packed_buf[packed_idx] |= nibble << 4;
217                    packed_idx += 1;
218                }
219            }
220            bytes.extend_from_slice(&packed_buf);
221        }
222        self.add_tensor(name, TensorDType::AprQ4, shape, &bytes)
223    }
224
225    /// Add raw Q4_K tensor (GGUF-compatible super-block format, streaming).
226    ///
227    /// GH-478: Enables streaming quantization for sharded imports.
228    ///
229    /// # Errors
230    ///
231    /// Returns error if writing fails.
232    pub fn add_q4k_raw_tensor(
233        &mut self,
234        name: impl Into<String>,
235        shape: Vec<usize>,
236        raw_data: &[u8],
237    ) -> Result<(), V2FormatError> {
238        self.add_tensor(name, TensorDType::Q4K, shape, raw_data)
239    }
240
241    /// Number of tensors added so far.
242    #[must_use]
243    pub fn tensor_count(&self) -> usize {
244        self.index_entries.len()
245    }
246
247    /// Total bytes of tensor data written to temp file.
248    #[must_use]
249    pub fn data_bytes_written(&self) -> u64 {
250        self.data_offset
251    }
252
253    /// Finalize and write the complete APR v2 file.
254    ///
255    /// Writes header + metadata + tensor index + tensor data (streamed from temp file).
256    /// The temp file is consumed and deleted automatically.
257    ///
258    /// # Errors
259    ///
260    /// Returns error if assembly or writing fails.
261    pub fn finalize(mut self, output_path: &std::path::Path) -> Result<(), V2FormatError> {
262        use std::io::{BufWriter, Seek, SeekFrom};
263
264        // Serialize metadata
265        let metadata_bytes = self.metadata.to_json()?;
266        let metadata_padded_size = align_64(metadata_bytes.len());
267
268        // Sort index entries by name (APR v2 contract — readers enforce sorted order).
269        // Offsets are preserved from insertion time — they point to correct data positions
270        // in the temp file regardless of index order.
271        self.index_entries.sort_by(|a, b| a.name.cmp(&b.name));
272
273        // Build tensor index bytes
274        let mut tensor_index_bytes = Vec::new();
275        for entry in &self.index_entries {
276            tensor_index_bytes.extend_from_slice(&entry.to_bytes());
277        }
278        let tensor_index_padded_size = align_64(tensor_index_bytes.len());
279
280        // Calculate section offsets
281        let metadata_offset = HEADER_SIZE_V2;
282        let tensor_index_offset = metadata_offset + metadata_padded_size;
283        let data_section_offset = tensor_index_offset + tensor_index_padded_size;
284
285        // Update header
286        self.header.tensor_count = self.index_entries.len() as u32;
287        self.header.metadata_offset = metadata_offset as u64;
288        self.header.metadata_size = metadata_bytes.len() as u32;
289        self.header.tensor_index_offset = tensor_index_offset as u64;
290        self.header.data_offset = data_section_offset as u64;
291        self.header.update_checksum();
292
293        // Flush and rewind temp data file
294        self.data_writer
295            .flush()
296            .map_err(|e| V2FormatError::IoError(e.to_string()))?;
297        let mut data_file = self
298            .data_writer
299            .into_inner()
300            .map_err(|e| V2FormatError::IoError(e.to_string()))?;
301        data_file
302            .seek(SeekFrom::Start(0))
303            .map_err(|e| V2FormatError::IoError(e.to_string()))?;
304
305        // Write output file
306        let out_file = std::fs::File::create(output_path).map_err(|e| {
307            V2FormatError::IoError(format!("Failed to create {}: {e}", output_path.display()))
308        })?;
309        let mut out = BufWriter::new(out_file);
310
311        // Header
312        out.write_all(&self.header.to_bytes())
313            .map_err(|e| V2FormatError::IoError(e.to_string()))?;
314
315        // Metadata (padded)
316        out.write_all(&metadata_bytes)
317            .map_err(|e| V2FormatError::IoError(e.to_string()))?;
318        let metadata_pad = metadata_padded_size - metadata_bytes.len();
319        if metadata_pad > 0 {
320            out.write_all(&vec![0u8; metadata_pad])
321                .map_err(|e| V2FormatError::IoError(e.to_string()))?;
322        }
323
324        // Tensor index (padded)
325        out.write_all(&tensor_index_bytes)
326            .map_err(|e| V2FormatError::IoError(e.to_string()))?;
327        let index_pad = tensor_index_padded_size - tensor_index_bytes.len();
328        if index_pad > 0 {
329            out.write_all(&vec![0u8; index_pad])
330                .map_err(|e| V2FormatError::IoError(e.to_string()))?;
331        }
332
333        // Tensor data — stream from temp file in 256KB chunks
334        let mut buf = vec![0u8; 256 * 1024];
335        loop {
336            let n = data_file
337                .read(&mut buf)
338                .map_err(|e| V2FormatError::IoError(e.to_string()))?;
339            if n == 0 {
340                break;
341            }
342            out.write_all(&buf[..n])
343                .map_err(|e| V2FormatError::IoError(e.to_string()))?;
344        }
345
346        // Footer checksum — flush, re-read, append CRC32
347        out.flush()
348            .map_err(|e| V2FormatError::IoError(e.to_string()))?;
349        drop(out);
350
351        let footer_crc = streaming_crc32_file(output_path)?;
352
353        let mut file = std::fs::OpenOptions::new()
354            .append(true)
355            .open(output_path)
356            .map_err(|e| V2FormatError::IoError(e.to_string()))?;
357        file.write_all(&footer_crc.to_le_bytes())
358            .map_err(|e| V2FormatError::IoError(e.to_string()))?;
359
360        Ok(())
361    }
362}
363
364/// CRC32 over a file, read in 256KB chunks. Same polynomial as `crc32()`.
365fn streaming_crc32_file(path: &std::path::Path) -> Result<u32, V2FormatError> {
366    const TABLE: [u32; 256] = {
367        let mut table = [0u32; 256];
368        let mut i = 0;
369        while i < 256 {
370            let mut c = i as u32;
371            let mut j = 0;
372            while j < 8 {
373                if c & 1 != 0 {
374                    c = (c >> 1) ^ 0xEDB8_8320;
375                } else {
376                    c >>= 1;
377                }
378                j += 1;
379            }
380            table[i] = c;
381            i += 1;
382        }
383        table
384    };
385
386    let mut file = std::fs::File::open(path).map_err(|e| V2FormatError::IoError(e.to_string()))?;
387    let mut crc = 0xFFFF_FFFF_u32;
388    let mut buf = vec![0u8; 256 * 1024];
389    loop {
390        let n = file
391            .read(&mut buf)
392            .map_err(|e| V2FormatError::IoError(e.to_string()))?;
393        if n == 0 {
394            break;
395        }
396        for &byte in &buf[..n] {
397            let idx = ((crc ^ u32::from(byte)) & 0xFF) as usize;
398            crc = (crc >> 8) ^ TABLE[idx];
399        }
400    }
401    Ok(!crc)
402}