Skip to main content

ai_model_vault/
diff.rs

1//! Model diffing — compare two versions of a model.
2//!
3//! Compares tensor metadata (shapes, dtypes, parameter counts) between
4//! two model files or vault versions.  Does NOT load full tensor data
5//! into memory; it parses headers only (SafeTensors, GGUF metadata).
6
7use std::collections::{BTreeMap, HashMap, HashSet};
8use std::fs;
9use std::path::Path;
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::Result;
14
15// ── Diff types ───────────────────────────────────────────────────────────────
16
17/// Summary of differences between two model files.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ModelDiff {
20    /// Left-side label (e.g. "model@v1")
21    pub left: String,
22    /// Right-side label (e.g. "model@v2")
23    pub right: String,
24    /// Size difference in bytes (right − left)
25    pub size_delta: i64,
26    /// Left file size
27    pub left_size: u64,
28    /// Right file size
29    pub right_size: u64,
30    /// Format of left file
31    pub left_format: String,
32    /// Format of right file
33    pub right_format: String,
34    /// Tensors added in right that aren't in left
35    pub added_tensors: Vec<TensorInfo>,
36    /// Tensors removed from left that aren't in right
37    pub removed_tensors: Vec<TensorInfo>,
38    /// Tensors present in both but with different shapes/dtypes
39    pub changed_tensors: Vec<TensorChange>,
40    /// Tensors unchanged between versions
41    pub unchanged_count: usize,
42    /// Total estimated parameter delta
43    pub param_delta: i64,
44    /// Summary statistics
45    pub summary: DiffSummary,
46}
47
48/// Information about a single tensor.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct TensorInfo {
51    pub name: String,
52    pub shape: Vec<usize>,
53    pub dtype: String,
54    pub param_count: u64,
55}
56
57/// A tensor that changed between versions.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct TensorChange {
60    pub name: String,
61    pub left_shape: Vec<usize>,
62    pub right_shape: Vec<usize>,
63    pub left_dtype: String,
64    pub right_dtype: String,
65    pub shape_changed: bool,
66    pub dtype_changed: bool,
67    pub param_delta: i64,
68}
69
70/// Summary statistics for a diff.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct DiffSummary {
73    pub total_left_tensors: usize,
74    pub total_right_tensors: usize,
75    pub added: usize,
76    pub removed: usize,
77    pub changed: usize,
78    pub unchanged: usize,
79    pub size_change_pct: f64,
80}
81
82// ── Parsed tensor map ────────────────────────────────────────────────────────
83
84/// Parsed tensor metadata from a model file header.
85type TensorMap = BTreeMap<String, TensorInfo>;
86
87// ── Diffing engine ───────────────────────────────────────────────────────────
88
89/// Compares model files at the tensor level.
90pub struct ModelDiffer;
91
92impl ModelDiffer {
93    /// Diff two model files on disk.
94    pub fn diff_files(
95        left_path: &Path,
96        right_path: &Path,
97        left_label: &str,
98        right_label: &str,
99    ) -> Result<ModelDiff> {
100        let left_data = fs::read(left_path)?;
101        let right_data = fs::read(right_path)?;
102
103        let left_format = crate::formats::ModelFormat::from_extension(
104            left_path
105                .extension()
106                .and_then(|e| e.to_str())
107                .unwrap_or("bin"),
108        );
109        let right_format = crate::formats::ModelFormat::from_extension(
110            right_path
111                .extension()
112                .and_then(|e| e.to_str())
113                .unwrap_or("bin"),
114        );
115
116        let left_tensors = Self::parse_tensors(&left_data, &left_format);
117        let right_tensors = Self::parse_tensors(&right_data, &right_format);
118
119        Self::compute_diff(
120            &left_tensors,
121            &right_tensors,
122            left_label,
123            right_label,
124            left_data.len() as u64,
125            right_data.len() as u64,
126            &format!("{:?}", left_format),
127            &format!("{:?}", right_format),
128        )
129    }
130
131    /// Diff two raw byte slices (for vault-stored models).
132    pub fn diff_bytes(
133        left: &[u8],
134        right: &[u8],
135        left_label: &str,
136        right_label: &str,
137        left_fmt: &str,
138        right_fmt: &str,
139    ) -> Result<ModelDiff> {
140        // These come from version records, which store `format.name()`.
141        let left_format = crate::formats::ModelFormat::from_stored(left_fmt);
142        let right_format = crate::formats::ModelFormat::from_stored(right_fmt);
143
144        let left_tensors = Self::parse_tensors(left, &left_format);
145        let right_tensors = Self::parse_tensors(right, &right_format);
146
147        Self::compute_diff(
148            &left_tensors,
149            &right_tensors,
150            left_label,
151            right_label,
152            left.len() as u64,
153            right.len() as u64,
154            left_fmt,
155            right_fmt,
156        )
157    }
158
159    #[allow(clippy::too_many_arguments)]
160    fn compute_diff(
161        left_tensors: &TensorMap,
162        right_tensors: &TensorMap,
163        left_label: &str,
164        right_label: &str,
165        left_size: u64,
166        right_size: u64,
167        left_format: &str,
168        right_format: &str,
169    ) -> Result<ModelDiff> {
170        let left_names: HashSet<&str> = left_tensors.keys().map(|s| s.as_str()).collect();
171        let right_names: HashSet<&str> = right_tensors.keys().map(|s| s.as_str()).collect();
172
173        let mut added_tensors = Vec::new();
174        let mut removed_tensors = Vec::new();
175        let mut changed_tensors = Vec::new();
176        let mut unchanged_count = 0;
177        let mut param_delta: i64 = 0;
178
179        // Added (in right but not left)
180        for name in &right_names {
181            if !left_names.contains(name) {
182                if let Some(t) = right_tensors.get(*name) {
183                    param_delta += t.param_count as i64;
184                    added_tensors.push(t.clone());
185                }
186            }
187        }
188
189        // Removed (in left but not right)
190        for name in &left_names {
191            if !right_names.contains(name) {
192                if let Some(t) = left_tensors.get(*name) {
193                    param_delta -= t.param_count as i64;
194                    removed_tensors.push(t.clone());
195                }
196            }
197        }
198
199        // Changed or unchanged (in both)
200        for name in left_names.intersection(&right_names) {
201            let lt = &left_tensors[*name];
202            let rt = &right_tensors[*name];
203
204            let shape_changed = lt.shape != rt.shape;
205            let dtype_changed = lt.dtype != rt.dtype;
206
207            if shape_changed || dtype_changed {
208                let delta = rt.param_count as i64 - lt.param_count as i64;
209                param_delta += delta;
210                changed_tensors.push(TensorChange {
211                    name: (*name).to_string(),
212                    left_shape: lt.shape.clone(),
213                    right_shape: rt.shape.clone(),
214                    left_dtype: lt.dtype.clone(),
215                    right_dtype: rt.dtype.clone(),
216                    shape_changed,
217                    dtype_changed,
218                    param_delta: delta,
219                });
220            } else {
221                unchanged_count += 1;
222            }
223        }
224
225        let size_change_pct = if left_size > 0 {
226            (right_size as f64 - left_size as f64) / left_size as f64 * 100.0
227        } else {
228            0.0
229        };
230
231        let summary = DiffSummary {
232            total_left_tensors: left_tensors.len(),
233            total_right_tensors: right_tensors.len(),
234            added: added_tensors.len(),
235            removed: removed_tensors.len(),
236            changed: changed_tensors.len(),
237            unchanged: unchanged_count,
238            size_change_pct,
239        };
240
241        Ok(ModelDiff {
242            left: left_label.to_string(),
243            right: right_label.to_string(),
244            size_delta: right_size as i64 - left_size as i64,
245            left_size,
246            right_size,
247            left_format: left_format.to_string(),
248            right_format: right_format.to_string(),
249            added_tensors,
250            removed_tensors,
251            changed_tensors,
252            unchanged_count,
253            param_delta,
254            summary,
255        })
256    }
257
258    // ── Tensor parsing (header-only) ─────────────────────────────────────
259
260    fn parse_tensors(data: &[u8], format: &crate::formats::ModelFormat) -> TensorMap {
261        match format {
262            crate::formats::ModelFormat::Safetensors => Self::parse_safetensors_header(data),
263            crate::formats::ModelFormat::GGUF => Self::parse_gguf_header(data),
264            _ => Self::parse_generic(data),
265        }
266    }
267
268    /// Parse SafeTensors header to extract tensor metadata.
269    ///
270    /// SafeTensors file layout: [8-byte LE header_size][JSON header][tensor data]
271    fn parse_safetensors_header(data: &[u8]) -> TensorMap {
272        let mut map = BTreeMap::new();
273
274        if data.len() < 8 {
275            return map;
276        }
277
278        let header_size = u64::from_le_bytes(data[0..8].try_into().unwrap_or_default()) as usize;
279
280        // Order matters. This was `data.len() < 8 + header_size || header_size
281        // > MAX`, and `||` evaluates left to right — so a file declaring a
282        // header near `usize::MAX` overflowed the addition and panicked
283        // before the cap could reject it. Found by fuzzing `diff_engine`.
284        //
285        // `data.len() >= 8` is guaranteed above, so the subtraction is safe.
286        const MAX_HEADER_BYTES: usize = 100_000_000;
287        if header_size > MAX_HEADER_BYTES || data.len() - 8 < header_size {
288            return map;
289        }
290
291        let header_json = &data[8..8 + header_size];
292        if let Ok(header) =
293            serde_json::from_slice::<HashMap<String, serde_json::Value>>(header_json)
294        {
295            for (name, info) in &header {
296                if name == "__metadata__" {
297                    continue;
298                }
299                if let Some(obj) = info.as_object() {
300                    let dtype = obj
301                        .get("dtype")
302                        .and_then(|v| v.as_str())
303                        .unwrap_or("F32")
304                        .to_string();
305                    let shape: Vec<usize> = obj
306                        .get("shape")
307                        .and_then(|v| v.as_array())
308                        .map(|arr| {
309                            arr.iter()
310                                .filter_map(|v| v.as_u64().map(|n| n as usize))
311                                .collect()
312                        })
313                        .unwrap_or_default();
314
315                    let param_count: u64 = if shape.is_empty() {
316                        1
317                    } else {
318                        shape.iter().product::<usize>() as u64
319                    };
320
321                    map.insert(
322                        name.clone(),
323                        TensorInfo {
324                            name: name.clone(),
325                            shape,
326                            dtype,
327                            param_count,
328                        },
329                    );
330                }
331            }
332        }
333
334        map
335    }
336
337    /// Parse GGUF header for tensor info.
338    ///
339    /// Delegates to [`crate::gguf`], which walks the variable-length metadata
340    /// block to reach the tensor descriptors. A truncated or malformed header
341    /// yields whatever was read before the break rather than an error — the
342    /// caller only ever uses this for comparison.
343    fn parse_gguf_header(data: &[u8]) -> TensorMap {
344        crate::gguf::tensors(data)
345            .into_iter()
346            .map(|t| {
347                (
348                    t.name.clone(),
349                    TensorInfo {
350                        name: t.name,
351                        shape: t.shape,
352                        dtype: t.dtype.to_string(),
353                        param_count: t.param_count,
354                    },
355                )
356            })
357            .collect()
358    }
359
360    /// Generic fallback: no tensor-level info available, just file-level.
361    fn parse_generic(_data: &[u8]) -> TensorMap {
362        BTreeMap::new()
363    }
364}
365
366// ── Display helpers ──────────────────────────────────────────────────────────
367
368impl ModelDiff {
369    /// Format the diff as a human-readable string.
370    pub fn display(&self) -> String {
371        let mut out = String::new();
372
373        out.push_str(&format!("Model Diff: {} ↔ {}\n", self.left, self.right));
374        out.push_str("──────────────────────────────────\n");
375        out.push_str(&format!(
376            "Size: {} → {} ({:+} bytes, {:+.1}%)\n",
377            format_size(self.left_size),
378            format_size(self.right_size),
379            self.size_delta,
380            self.summary.size_change_pct
381        ));
382        out.push_str(&format!(
383            "Format: {} → {}\n",
384            self.left_format, self.right_format
385        ));
386        out.push_str(&format!(
387            "Tensors: {} → {} (added: {}, removed: {}, changed: {}, unchanged: {})\n",
388            self.summary.total_left_tensors,
389            self.summary.total_right_tensors,
390            self.summary.added,
391            self.summary.removed,
392            self.summary.changed,
393            self.summary.unchanged,
394        ));
395
396        if self.param_delta != 0 {
397            out.push_str(&format!("Parameter delta: {:+}\n", self.param_delta));
398        }
399
400        if !self.added_tensors.is_empty() {
401            out.push_str("\n+ Added tensors:\n");
402            for t in &self.added_tensors {
403                out.push_str(&format!(
404                    "  + {} {:?} {} ({} params)\n",
405                    t.name, t.shape, t.dtype, t.param_count
406                ));
407            }
408        }
409
410        if !self.removed_tensors.is_empty() {
411            out.push_str("\n- Removed tensors:\n");
412            for t in &self.removed_tensors {
413                out.push_str(&format!(
414                    "  - {} {:?} {} ({} params)\n",
415                    t.name, t.shape, t.dtype, t.param_count
416                ));
417            }
418        }
419
420        if !self.changed_tensors.is_empty() {
421            out.push_str("\n~ Changed tensors:\n");
422            for t in &self.changed_tensors {
423                if t.shape_changed {
424                    out.push_str(&format!(
425                        "  ~ {} shape: {:?} → {:?}\n",
426                        t.name, t.left_shape, t.right_shape
427                    ));
428                }
429                if t.dtype_changed {
430                    out.push_str(&format!(
431                        "  ~ {} dtype: {} → {}\n",
432                        t.name, t.left_dtype, t.right_dtype
433                    ));
434                }
435            }
436        }
437
438        out
439    }
440}
441
442fn format_size(bytes: u64) -> String {
443    if bytes >= 1_073_741_824 {
444        format!("{:.2} GiB", bytes as f64 / 1_073_741_824.0)
445    } else if bytes >= 1_048_576 {
446        format!("{:.2} MiB", bytes as f64 / 1_048_576.0)
447    } else if bytes >= 1024 {
448        format!("{:.2} KiB", bytes as f64 / 1024.0)
449    } else {
450        format!("{} B", bytes)
451    }
452}
453
454// ── Tests ────────────────────────────────────────────────────────────────────
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    fn make_safetensors_header(tensors: &[(&str, &str, &[usize])]) -> Vec<u8> {
461        let mut header = serde_json::Map::new();
462        for (name, dtype, shape) in tensors {
463            let mut info = serde_json::Map::new();
464            info.insert("dtype".to_string(), serde_json::json!(dtype));
465            info.insert("shape".to_string(), serde_json::json!(shape));
466            info.insert("data_offsets".to_string(), serde_json::json!([0, 0]));
467            header.insert(name.to_string(), serde_json::Value::Object(info));
468        }
469        let json = serde_json::to_vec(&header).unwrap();
470        let mut data = Vec::new();
471        data.extend_from_slice(&(json.len() as u64).to_le_bytes());
472        data.extend_from_slice(&json);
473        data
474    }
475
476    /// A GGUF v3 header carrying one string and one array metadata pair, so the
477    /// walker has to step over both before reaching the tensor infos.
478    fn make_gguf_header(tensors: &[(&str, &[u64], u32)]) -> Vec<u8> {
479        use crate::gguf::test_support::{build, Meta};
480
481        build(
482            &[
483                ("general.architecture", Meta::Str("llama")),
484                ("tokenizer.ggml.tokens", Meta::StrArray(&["a", "bb"])),
485            ],
486            tensors,
487        )
488    }
489
490    #[test]
491    fn test_gguf_header_yields_real_tensor_metadata() {
492        let data = make_gguf_header(&[
493            ("blk.0.attn_q.weight", &[4096, 4096], 12), // Q4_K
494            ("output_norm.weight", &[4096], 0),         // F32
495        ]);
496
497        let map = ModelDiffer::parse_tensors(&data, &crate::formats::ModelFormat::GGUF);
498
499        assert_eq!(map.len(), 2);
500        let q = &map["blk.0.attn_q.weight"];
501        assert_eq!(q.shape, vec![4096, 4096]);
502        assert_eq!(q.dtype, "Q4_K");
503        assert_eq!(q.param_count, 4096 * 4096);
504        let norm = &map["output_norm.weight"];
505        assert_eq!(norm.shape, vec![4096]);
506        assert_eq!(norm.dtype, "F32");
507    }
508
509    /// The previous implementation invented `tensor_0..N` names with empty
510    /// shapes, so any two GGUF files with the same tensor count diffed as
511    /// identical no matter how different they actually were.
512    #[test]
513    fn test_gguf_diff_detects_changes_at_equal_tensor_count() {
514        let left = make_gguf_header(&[("blk.0.attn_q.weight", &[4096, 4096], 0)]);
515        let right = make_gguf_header(&[("blk.0.attn_q.weight", &[4096, 4096], 12)]);
516
517        let diff = ModelDiffer::diff_bytes(&left, &right, "f32", "q4k", "gguf", "gguf").unwrap();
518
519        assert_eq!(diff.summary.changed, 1);
520        assert_eq!(diff.changed_tensors[0].left_dtype, "F32");
521        assert_eq!(diff.changed_tensors[0].right_dtype, "Q4_K");
522        assert!(diff.changed_tensors[0].dtype_changed);
523        assert!(!diff.changed_tensors[0].shape_changed);
524    }
525
526    #[test]
527    fn test_gguf_renamed_tensor_is_add_plus_remove() {
528        let left = make_gguf_header(&[("old.name", &[8], 0)]);
529        let right = make_gguf_header(&[("new.name", &[8], 0)]);
530
531        let diff = ModelDiffer::diff_bytes(&left, &right, "a", "b", "gguf", "gguf").unwrap();
532
533        assert_eq!(diff.summary.added, 1);
534        assert_eq!(diff.summary.removed, 1);
535        assert_eq!(diff.summary.unchanged, 0);
536    }
537
538    #[test]
539    fn test_gguf_truncated_header_is_not_fatal() {
540        let full = make_gguf_header(&[("a", &[2], 0), ("b", &[2], 0)]);
541        for cut in [4, 12, 24, 40, full.len() - 4] {
542            let map = ModelDiffer::parse_tensors(&full[..cut], &crate::formats::ModelFormat::GGUF);
543            assert!(
544                map.len() < 2,
545                "truncation at {cut} should not yield a full map"
546            );
547        }
548    }
549
550    #[test]
551    fn test_gguf_rejects_bad_magic_and_version() {
552        let mut bad_magic = make_gguf_header(&[("a", &[2], 0)]);
553        bad_magic[0] = b'X';
554        assert!(
555            ModelDiffer::parse_tensors(&bad_magic, &crate::formats::ModelFormat::GGUF).is_empty()
556        );
557
558        let mut bad_version = make_gguf_header(&[("a", &[2], 0)]);
559        bad_version[4..8].copy_from_slice(&99u32.to_le_bytes());
560        assert!(
561            ModelDiffer::parse_tensors(&bad_version, &crate::formats::ModelFormat::GGUF).is_empty()
562        );
563    }
564
565    #[test]
566    fn test_diff_identical() {
567        let data = make_safetensors_header(&[
568            ("layer.weight", "F32", &[768, 768]),
569            ("layer.bias", "F32", &[768]),
570        ]);
571
572        let diff = ModelDiffer::diff_bytes(&data, &data, "v1", "v2", "safetensors", "safetensors")
573            .unwrap();
574
575        assert_eq!(diff.summary.added, 0);
576        assert_eq!(diff.summary.removed, 0);
577        assert_eq!(diff.summary.changed, 0);
578        assert_eq!(diff.summary.unchanged, 2);
579        assert_eq!(diff.size_delta, 0);
580    }
581
582    #[test]
583    fn test_diff_added_tensor() {
584        let left = make_safetensors_header(&[("weight", "F32", &[768, 768])]);
585        let right =
586            make_safetensors_header(&[("weight", "F32", &[768, 768]), ("bias", "F32", &[768])]);
587
588        let diff = ModelDiffer::diff_bytes(&left, &right, "v1", "v2", "safetensors", "safetensors")
589            .unwrap();
590
591        assert_eq!(diff.summary.added, 1);
592        assert_eq!(diff.summary.removed, 0);
593        assert_eq!(diff.added_tensors[0].name, "bias");
594    }
595
596    #[test]
597    fn test_diff_removed_tensor() {
598        let left =
599            make_safetensors_header(&[("weight", "F32", &[768, 768]), ("bias", "F32", &[768])]);
600        let right = make_safetensors_header(&[("weight", "F32", &[768, 768])]);
601
602        let diff = ModelDiffer::diff_bytes(&left, &right, "v1", "v2", "safetensors", "safetensors")
603            .unwrap();
604
605        assert_eq!(diff.summary.removed, 1);
606        assert_eq!(diff.removed_tensors[0].name, "bias");
607    }
608
609    #[test]
610    fn test_diff_changed_shape() {
611        let left = make_safetensors_header(&[("weight", "F32", &[768, 768])]);
612        let right = make_safetensors_header(&[("weight", "F32", &[1024, 768])]);
613
614        let diff = ModelDiffer::diff_bytes(&left, &right, "v1", "v2", "safetensors", "safetensors")
615            .unwrap();
616
617        assert_eq!(diff.summary.changed, 1);
618        assert!(diff.changed_tensors[0].shape_changed);
619    }
620
621    #[test]
622    fn test_diff_display() {
623        let left = make_safetensors_header(&[("weight", "F32", &[768, 768])]);
624        let right = make_safetensors_header(&[
625            ("weight", "F16", &[1024, 768]),
626            ("new_layer", "F32", &[256]),
627        ]);
628
629        let diff = ModelDiffer::diff_bytes(
630            &left,
631            &right,
632            "model@v1",
633            "model@v2",
634            "safetensors",
635            "safetensors",
636        )
637        .unwrap();
638
639        let display = diff.display();
640        assert!(display.contains("model@v1"));
641        assert!(display.contains("model@v2"));
642    }
643    // ── SafeTensors header bounds ───────────────────────────────────────────
644
645    /// A declared header size near `usize::MAX` must not overflow the bounds
646    /// check. The guard used to read `data.len() < 8 + header_size ||
647    /// header_size > MAX`; `||` evaluates left to right, so the addition
648    /// panicked with "attempt to add with overflow" before the cap applied.
649    /// Found by the `diff_engine` fuzz target.
650    #[test]
651    fn test_safetensors_header_size_near_usize_max_does_not_overflow() {
652        for declared in [u64::MAX, u64::MAX - 7, u64::MAX - 8, i64::MAX as u64] {
653            let mut data = declared.to_le_bytes().to_vec();
654            data.extend_from_slice(br#"{"a":{"dtype":"F32","shape":[1]}}"#);
655
656            // Must return an empty map rather than panicking.
657            let map = ModelDiffer::parse_safetensors_header(&data);
658            assert!(
659                map.is_empty(),
660                "a header of {declared} bytes cannot be present in {} bytes of input",
661                data.len()
662            );
663        }
664    }
665
666    /// The cap still rejects an oversized-but-non-overflowing header, and a
667    /// header longer than the data is still refused.
668    #[test]
669    fn test_safetensors_header_size_bounds_are_enforced() {
670        // Over the 100 MB cap.
671        let mut over_cap = 200_000_000u64.to_le_bytes().to_vec();
672        over_cap.extend_from_slice(b"{}");
673        assert!(ModelDiffer::parse_safetensors_header(&over_cap).is_empty());
674
675        // Within the cap but longer than the file.
676        let mut truncated = 4096u64.to_le_bytes().to_vec();
677        truncated.extend_from_slice(b"{}");
678        assert!(ModelDiffer::parse_safetensors_header(&truncated).is_empty());
679    }
680
681    /// ...and a well-formed header still parses, so the guard is not simply
682    /// rejecting everything.
683    #[test]
684    fn test_safetensors_well_formed_header_still_parses() {
685        let json = br#"{"blk.0.weight":{"dtype":"F32","shape":[2,3]}}"#;
686        let mut data = (json.len() as u64).to_le_bytes().to_vec();
687        data.extend_from_slice(json);
688        data.extend_from_slice(&[0u8; 24]);
689
690        let map = ModelDiffer::parse_safetensors_header(&data);
691        assert_eq!(map.len(), 1, "expected one tensor, got {map:?}");
692        let t = map.get("blk.0.weight").expect("tensor missing");
693        assert_eq!(t.shape, vec![2, 3]);
694        assert_eq!(t.dtype, "F32");
695    }
696}