Skip to main content

cortiq_engine/
dsv41_vision.rs

1//! DeepSeek-V4.1 vision tower, image preprocessing, and image-span layout.
2//!
3//! This is a direct CPU port of the pinned model's `inference/vision.py` and
4//! `inference/image_processor.py`.  The model's vision weights stay in the
5//! CMF mapping through [`QTensor`]; only the activations are materialised.
6//! The public preparation types are deliberately independent of the text
7//! pipeline so an OpenAI server, CLI, or another caller can build the same
8//! image spans before handing them to the runtime owner.
9
10use crate::pool::Pool;
11use crate::qtensor::QTensor;
12use crate::tokenizer::Tokenizer;
13use cortiq_core::CmfModel;
14use image::imageops::{self, FilterType};
15use image::{Rgb, RgbImage};
16use serde_json::Value;
17use std::fmt;
18use std::sync::Arc;
19use std::sync::atomic::{AtomicUsize, Ordering};
20
21/// Number of vision attention calls that completed through the selected GPU
22/// backend in this process.  The component probe reports this so a Vulkan
23/// timing cannot be mistaken for a CPU fallback.
24static GPU_ATTENTION_DISPATCHES: AtomicUsize = AtomicUsize::new(0);
25static GPU_DENSE_GEMM_DISPATCHES: AtomicUsize = AtomicUsize::new(0);
26
27pub fn gpu_attention_dispatches() -> usize {
28    GPU_ATTENTION_DISPATCHES.load(Ordering::Relaxed)
29}
30
31pub fn gpu_dense_gemm_dispatches() -> usize {
32    GPU_DENSE_GEMM_DISPATCHES.load(Ordering::Relaxed)
33}
34
35/// Text positions carry this type; non-negative values identify image-span
36/// positions.  The values match the reference processor exactly.
37pub const TEXT: i8 = -1;
38pub const IMAGE_START: i8 = 0;
39pub const IMAGE: i8 = 1;
40pub const IMAGE_NEW_LINE: i8 = 2;
41pub const IMAGE_END: i8 = 3;
42
43/// Minimal source configuration needed by the V4.1 vision tower and image
44/// processor.  `from_source` accepts the complete checkpoint config and
45/// reads its nested `vision_config`/`text_config` objects.
46#[derive(Clone, Debug, PartialEq)]
47pub struct VisionConfig {
48    pub vision_n_layers: usize,
49    pub vision_dim: usize,
50    pub vision_n_heads: usize,
51    pub vision_inter_dim: usize,
52    pub vision_patch_size: usize,
53    pub vision_rope_theta: f32,
54    pub vision_downsample_ratio: usize,
55    pub vision_max_n_token: usize,
56    pub vision_min_pixels: usize,
57    pub vision_max_wh_ratio: Option<f64>,
58    pub text_dim: usize,
59    pub image_token_id: u32,
60}
61
62impl Default for VisionConfig {
63    fn default() -> Self {
64        Self {
65            vision_n_layers: 0,
66            vision_dim: 1024,
67            vision_n_heads: 16,
68            vision_inter_dim: 2816,
69            vision_patch_size: 14,
70            vision_rope_theta: 10_000.0,
71            vision_downsample_ratio: 3,
72            vision_max_n_token: 1024,
73            vision_min_pixels: 544 * 544,
74            vision_max_wh_ratio: None,
75            text_dim: 5120,
76            image_token_id: 129_264,
77        }
78    }
79}
80
81impl VisionConfig {
82    /// Read a complete DeepSeek-V4.1 config, or a vision-only object.
83    pub fn from_source(source: &Value) -> Result<Self, String> {
84        let mut out = Self::default();
85        let vision = source.get("vision_config").unwrap_or(source);
86        let empty = Value::Null;
87        let text = source.get("text_config").unwrap_or(&empty);
88        let usize_field = |object: &Value, key: &str, old: usize| {
89            object
90                .get(key)
91                .and_then(Value::as_u64)
92                .map(|v| v as usize)
93                .unwrap_or(old)
94        };
95        let f32_field = |object: &Value, key: &str, old: f32| {
96            object
97                .get(key)
98                .and_then(Value::as_f64)
99                .map(|v| v as f32)
100                .unwrap_or(old)
101        };
102        out.vision_n_layers = usize_field(vision, "num_hidden_layers", out.vision_n_layers);
103        out.vision_dim = usize_field(vision, "hidden_size", out.vision_dim);
104        out.vision_n_heads = usize_field(vision, "num_attention_heads", out.vision_n_heads);
105        out.vision_inter_dim = usize_field(vision, "intermediate_size", out.vision_inter_dim);
106        out.vision_patch_size = usize_field(vision, "patch_size", out.vision_patch_size);
107        out.vision_rope_theta = f32_field(vision, "rope_theta", out.vision_rope_theta);
108        out.vision_downsample_ratio =
109            usize_field(vision, "downsample_ratio", out.vision_downsample_ratio);
110        out.vision_max_n_token = usize_field(vision, "max_image_tokens", out.vision_max_n_token);
111        out.vision_min_pixels = usize_field(vision, "min_pixels", out.vision_min_pixels);
112        out.vision_max_wh_ratio = vision.get("max_wh_ratio").and_then(Value::as_f64);
113        out.text_dim = usize_field(text, "hidden_size", out.text_dim);
114        out.image_token_id = source
115            .get("image_token_id")
116            .and_then(Value::as_u64)
117            .or_else(|| vision.get("image_token_id").and_then(Value::as_u64))
118            .map(|v| v as u32)
119            .unwrap_or(out.image_token_id);
120        out.validate()?;
121        Ok(out)
122    }
123
124    pub fn validate(&self) -> Result<(), String> {
125        if self.vision_n_layers == 0 {
126            return Ok(());
127        }
128        if self.vision_dim == 0
129            || self.vision_n_heads == 0
130            || self.vision_dim % self.vision_n_heads != 0
131            || self.vision_dim / self.vision_n_heads % 2 != 0
132        {
133            return Err(format!(
134                "invalid vision attention geometry dim={} heads={}",
135                self.vision_dim, self.vision_n_heads
136            ));
137        }
138        if self.vision_patch_size == 0 || self.vision_downsample_ratio == 0 {
139            return Err("vision patch_size and downsample_ratio must be non-zero".to_string());
140        }
141        if self.vision_max_n_token < 4 {
142            return Err("vision max_image_tokens must be at least 4".to_string());
143        }
144        if self.text_dim == 0 {
145            return Err("text hidden_size must be non-zero for the aligner".to_string());
146        }
147        Ok(())
148    }
149
150    pub fn vision_enabled(&self) -> bool {
151        self.vision_n_layers > 0
152    }
153
154    pub fn head_dim(&self) -> usize {
155        self.vision_dim / self.vision_n_heads
156    }
157
158    pub fn rope_dim(&self) -> usize {
159        self.head_dim() / 2
160    }
161}
162
163/// An image record after prompt encoding.  Patches use the same contiguous
164/// `[n_vit_h*n_vit_w, 3, patch, patch]` order as PyTorch's reference.
165#[derive(Clone, Debug)]
166pub struct ImageInput {
167    pub start: usize,
168    pub patches: Vec<f32>,
169    pub n_vit_h: usize,
170    pub n_vit_w: usize,
171    pub n_llm_h: usize,
172    pub n_llm_w: usize,
173    pub types: Vec<i8>,
174}
175
176impl ImageInput {
177    pub fn image_positions(&self) -> usize {
178        self.types.iter().filter(|&&kind| kind == IMAGE).count()
179    }
180
181    pub fn span_len(&self) -> usize {
182        self.types.len()
183    }
184}
185
186/// Token IDs plus per-position image type metadata consumed by the text
187/// runtime.  `images` is empty for text-only prompts.
188#[derive(Clone, Debug)]
189pub struct PreparedVlInputs {
190    pub token_ids: Vec<u32>,
191    pub token_types: Vec<i8>,
192    pub images: Vec<ImageInput>,
193}
194
195/// `num_image_tokens` from the pinned processor.
196pub fn num_image_tokens(n_llm_h: usize, n_llm_w: usize) -> usize {
197    n_llm_h.saturating_mul(n_llm_w + 1).saturating_add(2)
198}
199
200/// Number of LLM rows/columns after the aligner's `r×r` downsample.
201pub fn llm_grid(
202    best_height: usize,
203    best_width: usize,
204    patch_size: usize,
205    downsample_ratio: usize,
206) -> (usize, usize) {
207    (
208        (best_height / patch_size).div_ceil(downsample_ratio),
209        (best_width / patch_size).div_ceil(downsample_ratio),
210    )
211}
212
213/// Largest aspect-preserving pixel size whose token grid fits the cap.
214pub fn solve_resize_ratio(
215    height: usize,
216    width: usize,
217    patch_size: usize,
218    downsample_ratio: usize,
219    max_n_token: usize,
220) -> (usize, usize) {
221    solve_resize_ratio_f64(
222        height.max(1) as f64,
223        width.max(1) as f64,
224        patch_size,
225        downsample_ratio,
226        max_n_token,
227    )
228}
229
230fn solve_resize_ratio_f64(
231    height_f: f64,
232    width_f: f64,
233    patch_size: usize,
234    downsample_ratio: usize,
235    max_n_token: usize,
236) -> (usize, usize) {
237    let aspect = height_f / width_f;
238    let max_w_float = (((max_n_token.saturating_sub(2)) as f64 / aspect) + 0.25).sqrt() - 0.5;
239    let max_h_float = max_w_float * aspect;
240    let cell = patch_size.saturating_mul(downsample_ratio).max(1);
241    if max_w_float < 1.0 {
242        return (max_n_token.saturating_sub(2) / 2 * cell, cell);
243    }
244    if max_h_float < 1.0 {
245        return (cell, max_n_token.saturating_sub(3) * cell);
246    }
247    let beta = (max_w_float.floor() * cell as f64 / width_f)
248        .min(max_h_float.floor() * cell as f64 / height_f);
249    let best_h =
250        ((height_f * beta / patch_size.max(1) as f64).floor() as usize).saturating_mul(patch_size);
251    let best_w =
252        ((width_f * beta / patch_size.max(1) as f64).floor() as usize).saturating_mul(patch_size);
253    (best_h.max(patch_size), best_w.max(patch_size))
254}
255
256/// Apply the official token-cap correction to a planned pixel grid.
257pub fn safe_resize(
258    height: usize,
259    width: usize,
260    mut best_height: usize,
261    mut best_width: usize,
262    patch_size: usize,
263    downsample_ratio: usize,
264    max_n_token: usize,
265) -> Result<(usize, usize, usize, usize), String> {
266    let (mut n_llm_h, mut n_llm_w) =
267        llm_grid(best_height, best_width, patch_size, downsample_ratio);
268    if num_image_tokens(n_llm_h, n_llm_w) > max_n_token {
269        let (h, w) = solve_resize_ratio(height, width, patch_size, downsample_ratio, max_n_token);
270        best_height = h;
271        best_width = w;
272        (n_llm_h, n_llm_w) = llm_grid(best_height, best_width, patch_size, downsample_ratio);
273        if num_image_tokens(n_llm_h, n_llm_w) > max_n_token {
274            return Err(format!(
275                "image grid {}x{} costs {} tokens, cap {}",
276                n_llm_h,
277                n_llm_w,
278                num_image_tokens(n_llm_h, n_llm_w),
279                max_n_token
280            ));
281        }
282    }
283    Ok((n_llm_h, n_llm_w, best_height, best_width))
284}
285
286/// Compute the pixel/token plan before decoding the image payload.
287pub fn plan_image_grid(
288    width: usize,
289    height: usize,
290    config: &VisionConfig,
291) -> Result<(usize, usize, usize, usize), String> {
292    config.validate()?;
293    if width == 0 || height == 0 {
294        return Err("image dimensions must be non-zero".to_string());
295    }
296    let mut width_f = width as f64;
297    let mut height_f = height as f64;
298    if let Some(max_ratio) = config.vision_max_wh_ratio.filter(|v| *v > 0.0) {
299        if width_f > height_f * max_ratio {
300            width_f = height_f * max_ratio;
301        }
302    }
303    if width_f * height_f < config.vision_min_pixels as f64 {
304        let scale = (config.vision_min_pixels as f64 / (width_f * height_f)).sqrt();
305        width_f = (width_f * scale) as usize as f64;
306        height_f = (height_f * scale) as usize as f64;
307    }
308    let p = config.vision_patch_size;
309    let mut best_width = (width_f.ceil() as usize)
310        .max(1)
311        .div_ceil(p)
312        .saturating_mul(p);
313    let mut best_height = (height_f.ceil() as usize)
314        .max(1)
315        .div_ceil(p)
316        .saturating_mul(p);
317    let (mut n_h, mut n_w) = llm_grid(best_height, best_width, p, config.vision_downsample_ratio);
318    if num_image_tokens(n_h, n_w) > config.vision_max_n_token {
319        let (h, w) = solve_resize_ratio_f64(
320            height_f,
321            width_f,
322            p,
323            config.vision_downsample_ratio,
324            config.vision_max_n_token,
325        );
326        best_height = h;
327        best_width = w;
328        (n_h, n_w) = llm_grid(best_height, best_width, p, config.vision_downsample_ratio);
329        if num_image_tokens(n_h, n_w) > config.vision_max_n_token {
330            return Err(format!(
331                "image grid {}x{} costs {} tokens, cap {}",
332                n_h,
333                n_w,
334                num_image_tokens(n_h, n_w),
335                config.vision_max_n_token
336            ));
337        }
338    }
339    Ok((n_h, n_w, best_height, best_width))
340}
341
342/// The exact span type order: start, each row's image tokens plus newline,
343/// end.
344pub fn image_token_types(n_llm_h: usize, n_llm_w: usize) -> Vec<i8> {
345    let mut types = Vec::with_capacity(num_image_tokens(n_llm_h, n_llm_w));
346    types.push(IMAGE_START);
347    for _ in 0..n_llm_h {
348        types.extend(std::iter::repeat_n(IMAGE, n_llm_w));
349        types.push(IMAGE_NEW_LINE);
350    }
351    types.push(IMAGE_END);
352    types
353}
354
355/// Moved to [`crate::media`] so every vision front end shares one fetcher.
356pub use crate::media::load_image_bytes;
357
358fn resize_fit(image: &RgbImage, width: u32, height: u32) -> RgbImage {
359    let scale = (width as f64 / image.width() as f64).min(height as f64 / image.height() as f64);
360    let resized_width = (image.width() as f64 * scale).round().max(1.0) as u32;
361    let resized_height = (image.height() as f64 * scale).round().max(1.0) as u32;
362    imageops::resize(image, resized_width, resized_height, FilterType::CatmullRom)
363}
364
365fn pad_to(image: &RgbImage, width: u32, height: u32) -> RgbImage {
366    let resized = resize_fit(image, width, height);
367    let mut output = RgbImage::from_pixel(width, height, Rgb([127, 127, 127]));
368    let left = (width.saturating_sub(resized.width())) / 2;
369    let top = (height.saturating_sub(resized.height())) / 2;
370    imageops::overlay(&mut output, &resized, i64::from(left), i64::from(top));
371    output
372}
373
374/// PyTorch converts the normalized image tensor to bfloat16 before the ViT
375/// sees it.  The rest of the CPU engine works in f32, so round-trip each
376/// patch value through BF16 at this boundary instead of silently retaining
377/// extra image precision.
378#[inline]
379fn bf16_roundtrip(value: f32) -> f32 {
380    let bits = value.to_bits();
381    let round = 0x7fff + ((bits >> 16) & 1);
382    f32::from_bits((bits.wrapping_add(round) & 0xffff_0000))
383}
384
385/// Decode, resize/pad, normalize, and patchify one image record.
386pub fn load_image(
387    record: &Value,
388    config: &VisionConfig,
389) -> Result<(Vec<f32>, usize, usize, usize, usize), String> {
390    config.validate()?;
391    if !config.vision_enabled() {
392        return Err("image input requires a model with vision_n_layers > 0".to_string());
393    }
394    let bytes = load_image_bytes(record)?;
395    let decoded = image::load_from_memory(&bytes)
396        .map_err(|e| format!("image decode failed: {e}"))?
397        .to_rgb8();
398    let (width, height) = decoded.dimensions();
399    let (n_llm_h, n_llm_w, best_height, best_width) =
400        plan_image_grid(width as usize, height as usize, config)?;
401    let p = config.vision_patch_size as u32;
402    let target_width = best_width as u32;
403    let target_height = best_height as u32;
404    let transformed = if config
405        .vision_max_wh_ratio
406        .is_some_and(|ratio| width as f64 >= ratio * height as f64)
407    {
408        imageops::resize(
409            &decoded,
410            target_width,
411            target_height,
412            FilterType::CatmullRom,
413        )
414    } else {
415        pad_to(&decoded, target_width, target_height)
416    };
417    let n_vit_h = best_height / config.vision_patch_size;
418    let n_vit_w = best_width / config.vision_patch_size;
419    let patch_values = config
420        .vision_patch_size
421        .saturating_mul(config.vision_patch_size)
422        .saturating_mul(3);
423    let mut patches =
424        Vec::with_capacity(n_vit_h.saturating_mul(n_vit_w).saturating_mul(patch_values));
425    // Equivalent to PyTorch:
426    // x.reshape(3,n_h,p,n_w,p).permute(1,3,0,2,4).reshape(n_h*n_w,3,p,p)
427    for patch_y in 0..n_vit_h {
428        for patch_x in 0..n_vit_w {
429            for channel in 0..3 {
430                for dy in 0..config.vision_patch_size {
431                    for dx in 0..config.vision_patch_size {
432                        let pixel = transformed.get_pixel(
433                            (patch_x * config.vision_patch_size + dx) as u32,
434                            (patch_y * config.vision_patch_size + dy) as u32,
435                        );
436                        let value = (pixel[channel] as f32 / 255.0 - 0.5) / 0.5;
437                        patches.push(bf16_roundtrip(value));
438                    }
439                }
440            }
441        }
442    }
443    debug_assert_eq!(patches.len(), n_vit_h * n_vit_w * patch_values);
444    Ok((patches, n_vit_h, n_vit_w, n_llm_h, n_llm_w))
445}
446
447/// Tokenize a prompt and replace each image placeholder with its official
448/// span.  The tokenizer must expose `<|deepseek_image|>` as one added token;
449/// a mismatched known id is rejected rather than silently feeding text tokens.
450pub fn prepare_vl_inputs(
451    prompt: &str,
452    images: &[Value],
453    tokenizer: &Tokenizer,
454    config: &VisionConfig,
455) -> Result<PreparedVlInputs, String> {
456    config.validate()?;
457    let image_token_id = config.image_token_id;
458    if let Some(placeholder_id) = tokenizer.token_to_id(crate::dsv41_encoding::IMAGE_PLACEHOLDER) {
459        if placeholder_id != image_token_id {
460            return Err(format!(
461                "tokenizer image placeholder id {} != config image_token_id {}",
462                placeholder_id, image_token_id
463            ));
464        }
465    }
466    let prompt_tokens = tokenizer.encode(prompt);
467    let placeholders = prompt_tokens
468        .iter()
469        .filter(|&&token| token == image_token_id)
470        .count();
471    if placeholders != images.len() {
472        return Err(format!(
473            "found {placeholders} image tokens but received {} images",
474            images.len()
475        ));
476    }
477    if placeholders > 0 && !config.vision_enabled() {
478        return Err("prompt contains images but the model has no vision tower".to_string());
479    }
480    let mut token_ids = Vec::with_capacity(prompt_tokens.len());
481    let mut token_types = Vec::with_capacity(prompt_tokens.len());
482    let mut image_inputs = Vec::with_capacity(images.len());
483    let mut image_index = 0;
484    for token in prompt_tokens {
485        if token != image_token_id {
486            token_ids.push(token);
487            token_types.push(TEXT);
488            continue;
489        }
490        let (patches, n_vit_h, n_vit_w, n_llm_h, n_llm_w) =
491            load_image(&images[image_index], config)?;
492        let types = image_token_types(n_llm_h, n_llm_w);
493        image_inputs.push(ImageInput {
494            start: token_ids.len(),
495            patches,
496            n_vit_h,
497            n_vit_w,
498            n_llm_h,
499            n_llm_w,
500            types: types.clone(),
501        });
502        token_ids.extend(std::iter::repeat_n(image_token_id, types.len()));
503        token_types.extend(types);
504        image_index += 1;
505    }
506    Ok(PreparedVlInputs {
507        token_ids,
508        token_types,
509        images: image_inputs,
510    })
511}
512
513pub struct VisionLinear {
514    pub weight: QTensor,
515    pub bias: Option<Vec<f32>>,
516}
517
518impl fmt::Debug for VisionLinear {
519    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
520        f.debug_struct("VisionLinear")
521            .field("rows", &self.weight.rows())
522            .field("cols", &self.weight.cols())
523            .field("has_bias", &self.bias.is_some())
524            .finish()
525    }
526}
527
528impl VisionLinear {
529    fn new(weight: QTensor, bias: Option<Vec<f32>>) -> Result<Self, String> {
530        if let Some(values) = &bias {
531            if values.len() != weight.rows() {
532                return Err(format!(
533                    "linear bias length {} != output rows {}",
534                    values.len(),
535                    weight.rows()
536                ));
537            }
538        }
539        Ok(Self { weight, bias })
540    }
541
542    fn apply_many(&self, input: &[f32], batch: usize, output: &mut [f32], pool: Option<&Pool>) {
543        assert_eq!(input.len(), batch * self.weight.cols());
544        assert!(output.len() >= batch * self.weight.rows());
545
546        // F16 vision matrices are owned as f32 by QTensor after the one-time
547        // CMF decode.  Reuse the existing dense f32 NT GEMM on an explicitly
548        // enabled discrete backend for the large image batch; small calls
549        // and every backend refusal retain the portable QTensor path.
550        let rows = self.weight.rows();
551        let cols = self.weight.cols();
552        if batch >= 8
553            && batch.saturating_mul(rows).saturating_mul(cols) >= (1 << 22)
554            && crate::gpu::enabled_here()
555            && let Some(weight) = self.weight.as_f32()
556            && crate::gpu::gemm_nt_f32(input, weight, output, batch, cols, rows)
557        {
558            GPU_DENSE_GEMM_DISPATCHES.fetch_add(1, Ordering::Relaxed);
559            if let Some(bias) = &self.bias {
560                for row in output[..batch * rows].chunks_exact_mut(rows) {
561                    for (value, &b) in row.iter_mut().zip(bias) {
562                        *value += b;
563                    }
564                }
565            }
566            return;
567        }
568        self.weight.matmat(input, batch, output, pool);
569        if let Some(bias) = &self.bias {
570            for row in output[..batch * self.weight.rows()].chunks_exact_mut(self.weight.rows()) {
571                for (value, &b) in row.iter_mut().zip(bias) {
572                    *value += b;
573                }
574            }
575        }
576    }
577}
578
579#[derive(Debug)]
580pub struct VisionAttention {
581    pub wqkv: VisionLinear,
582    pub wo: VisionLinear,
583}
584
585#[derive(Debug)]
586pub struct VisionMlp {
587    pub w1: VisionLinear,
588    pub w2: VisionLinear,
589}
590
591#[derive(Debug)]
592pub struct VisionBlock {
593    pub norm1: Vec<f32>,
594    pub attn: VisionAttention,
595    pub norm2: Vec<f32>,
596    pub mlp: VisionMlp,
597}
598
599/// Loaded V4.1 vision tower plus projector/marker embeddings.
600#[derive(Debug)]
601pub struct VisionModel {
602    pub config: VisionConfig,
603    pub patch_embed: VisionLinear,
604    pub blocks: Vec<VisionBlock>,
605    pub norm: Vec<f32>,
606    pub aligner_w1: VisionLinear,
607    pub aligner_w2: VisionLinear,
608    pub image_start: Vec<f32>,
609    pub image_end: Vec<f32>,
610    pub image_newline: Vec<f32>,
611}
612
613fn load_tensor(model: &Arc<CmfModel>, name: &str) -> Result<QTensor, String> {
614    QTensor::from_model(model, name)
615}
616
617fn load_vector(model: &Arc<CmfModel>, name: &str, expected: usize) -> Result<Vec<f32>, String> {
618    let entry = model
619        .tensor(name)
620        .ok_or_else(|| format!("tensor '{name}' not found"))?;
621    if entry.shape.iter().product::<usize>() != expected {
622        return Err(format!(
623            "tensor '{name}' has shape {:?}, expected {} elements",
624            entry.shape, expected
625        ));
626    }
627    let mut data = vec![0.0f32; expected];
628    cortiq_core::quant::dequant_tensor(entry, model.entry_bytes(entry), &mut data)?;
629    Ok(data)
630}
631
632fn load_optional_vector(
633    model: &Arc<CmfModel>,
634    name: &str,
635    expected: usize,
636) -> Result<Option<Vec<f32>>, String> {
637    model
638        .tensor(name)
639        .map(|_| load_vector(model, name, expected))
640        .transpose()
641}
642
643fn required_norm(model: &Arc<CmfModel>, name: &str, dim: usize) -> Result<Vec<f32>, String> {
644    load_vector(model, name, dim)
645}
646
647/// The pinned aligner uses `F.pad(x, (0, -n_w % r, 0, -n_h % r))`.
648/// Python's modulo is non-negative, so the apparently negative arguments add
649/// zero padding up to the next multiple rather than cropping a partial cell.
650fn padded_vit_grid(n_vit_h: usize, n_vit_w: usize, ratio: usize) -> (usize, usize) {
651    (
652        n_vit_h.div_ceil(ratio) * ratio,
653        n_vit_w.div_ceil(ratio) * ratio,
654    )
655}
656
657/// Materialize the channel-major windows consumed by the reference
658/// `F.unfold`.  The source first pads the `[dim, n_vit_h, n_vit_w]` feature
659/// map on the bottom/right, then unfolds non-overlapping `ratio×ratio`
660/// windows.  Keeping this operation separate makes its boundary behavior
661/// directly testable without loading the 1 GB vision component.
662fn unfold_padded(
663    x: &[f32],
664    n_vit_h: usize,
665    n_vit_w: usize,
666    vision_dim: usize,
667    ratio: usize,
668) -> (Vec<f32>, usize, usize) {
669    let (h, w) = padded_vit_grid(n_vit_h, n_vit_w, ratio);
670    let rows = (h / ratio) * (w / ratio);
671    let in_dim = vision_dim * ratio * ratio;
672    debug_assert_eq!(x.len(), n_vit_h * n_vit_w * vision_dim);
673    let mut input = vec![0.0f32; rows * in_dim];
674    for block_y in 0..h / ratio {
675        for block_x in 0..w / ratio {
676            let row = block_y * (w / ratio) + block_x;
677            let mut at = 0;
678            // F.unfold after CHW permutation is channel-major, then the
679            // ratio×ratio values in row-major order.
680            for channel in 0..vision_dim {
681                for dy in 0..ratio {
682                    for dx in 0..ratio {
683                        let patch_y = block_y * ratio + dy;
684                        let patch_x = block_x * ratio + dx;
685                        input[row * in_dim + at] = if patch_y < n_vit_h && patch_x < n_vit_w {
686                            let patch = patch_y * n_vit_w + patch_x;
687                            x[patch * vision_dim + channel]
688                        } else {
689                            0.0
690                        };
691                        at += 1;
692                    }
693                }
694            }
695        }
696    }
697    (input, h / ratio, w / ratio)
698}
699
700impl VisionModel {
701    /// Load canonical V4.1 names from a CMF model.  The marker vectors are
702    /// required whenever the tower is enabled because they replace the
703    /// image-start/end/newline token embeddings in the text hidden state.
704    pub fn from_model(model: &Arc<CmfModel>, config: VisionConfig) -> Result<Self, String> {
705        config.validate()?;
706        if !config.vision_enabled() {
707            return Err("cannot load a disabled vision tower".to_string());
708        }
709        let patch_in = 3 * config.vision_patch_size * config.vision_patch_size;
710        let patch_embed = VisionLinear::new(
711            load_tensor(model, "vision.patch_embed.proj.weight")?,
712            load_optional_vector(model, "vision.patch_embed.proj.bias", config.vision_dim)?,
713        )?;
714        if patch_embed.weight.rows() != config.vision_dim || patch_embed.weight.cols() != patch_in {
715            return Err(format!(
716                "patch embedding shape {}x{}, expected {}x{}",
717                patch_embed.weight.rows(),
718                patch_embed.weight.cols(),
719                config.vision_dim,
720                patch_in
721            ));
722        }
723        let mut blocks = Vec::with_capacity(config.vision_n_layers);
724        for layer in 0..config.vision_n_layers {
725            let prefix = format!("vision.blocks.{layer}");
726            let norm1 = required_norm(model, &format!("{prefix}.norm1.weight"), config.vision_dim)?;
727            let wqkv = VisionLinear::new(
728                load_tensor(model, &format!("{prefix}.attn.wqkv.weight"))?,
729                load_optional_vector(
730                    model,
731                    &format!("{prefix}.attn.wqkv.bias"),
732                    3 * config.vision_dim,
733                )?,
734            )?;
735            let wo = VisionLinear::new(
736                load_tensor(model, &format!("{prefix}.attn.wo.weight"))?,
737                load_optional_vector(model, &format!("{prefix}.attn.wo.bias"), config.vision_dim)?,
738            )?;
739            let norm2 = required_norm(model, &format!("{prefix}.norm2.weight"), config.vision_dim)?;
740            let w1 = VisionLinear::new(
741                load_tensor(model, &format!("{prefix}.mlp.w1.weight"))?,
742                load_optional_vector(
743                    model,
744                    &format!("{prefix}.mlp.w1.bias"),
745                    2 * config.vision_inter_dim,
746                )?,
747            )?;
748            let w2 = VisionLinear::new(
749                load_tensor(model, &format!("{prefix}.mlp.w2.weight"))?,
750                load_optional_vector(model, &format!("{prefix}.mlp.w2.bias"), config.vision_dim)?,
751            )?;
752            if wqkv.weight.rows() != 3 * config.vision_dim
753                || wqkv.weight.cols() != config.vision_dim
754                || wo.weight.rows() != config.vision_dim
755                || wo.weight.cols() != config.vision_dim
756                || w1.weight.rows() != 2 * config.vision_inter_dim
757                || w1.weight.cols() != config.vision_dim
758                || w2.weight.rows() != config.vision_dim
759                || w2.weight.cols() != config.vision_inter_dim
760            {
761                return Err(format!(
762                    "vision block {layer} has a non-reference linear shape"
763                ));
764            }
765            blocks.push(VisionBlock {
766                norm1,
767                attn: VisionAttention { wqkv, wo },
768                norm2,
769                mlp: VisionMlp { w1, w2 },
770            });
771        }
772        let norm = required_norm(model, "vision.norm.weight", config.vision_dim)?;
773        let aligner_in = config.vision_dim * config.vision_downsample_ratio.pow(2);
774        let aligner_w1 = VisionLinear::new(
775            load_tensor(model, "aligner.w1.weight")?,
776            load_optional_vector(model, "aligner.w1.bias", config.text_dim)?,
777        )?;
778        let aligner_w2 = VisionLinear::new(
779            load_tensor(model, "aligner.w2.weight")?,
780            load_optional_vector(model, "aligner.w2.bias", config.text_dim)?,
781        )?;
782        if aligner_w1.weight.rows() != config.text_dim
783            || aligner_w1.weight.cols() != aligner_in
784            || aligner_w2.weight.rows() != config.text_dim
785            || aligner_w2.weight.cols() != config.text_dim
786        {
787            return Err("aligner linear shapes do not match vision config".to_string());
788        }
789        let image_start = load_vector(model, "image_start", config.text_dim)?;
790        let image_end = load_vector(model, "image_end", config.text_dim)?;
791        let image_newline = load_vector(model, "image_newline", config.text_dim)?;
792        Ok(Self {
793            config,
794            patch_embed,
795            blocks,
796            norm,
797            aligner_w1,
798            aligner_w2,
799            image_start,
800            image_end,
801            image_newline,
802        })
803    }
804
805    /// Compute a vision image embedding with the official ViT and aligner.
806    /// The returned rows correspond only to `IMAGE` positions, in reading
807    /// order; marker vectors remain separate for the text runtime to insert.
808    pub fn encode_image(
809        &self,
810        image: &ImageInput,
811        pool: Option<&Pool>,
812    ) -> Result<Vec<f32>, String> {
813        if image.n_vit_h == 0 || image.n_vit_w == 0 {
814            return Err("image patch grid must be non-empty".to_string());
815        }
816        let patch_dim = 3 * self.config.vision_patch_size * self.config.vision_patch_size;
817        let n = image.n_vit_h * image.n_vit_w;
818        if image.patches.len() != n * patch_dim {
819            return Err(format!(
820                "patch payload has {} values, expected {}",
821                image.patches.len(),
822                n * patch_dim
823            ));
824        }
825        let mut x = vec![0.0f32; n * self.config.vision_dim];
826        self.patch_embed.apply_many(&image.patches, n, &mut x, pool);
827        let (cos, sin) = get_vision_cos_sin(
828            image.n_vit_h,
829            image.n_vit_w,
830            self.config.rope_dim(),
831            self.config.vision_rope_theta,
832        );
833        for block in &self.blocks {
834            let mut normed = vec![0.0f32; x.len()];
835            for (src, dst) in x
836                .chunks_exact(self.config.vision_dim)
837                .zip(normed.chunks_exact_mut(self.config.vision_dim))
838            {
839                rms_norm(src, &block.norm1, 1e-6, dst);
840            }
841            let attention = attention_forward(
842                &normed,
843                &block.attn,
844                &cos,
845                &sin,
846                self.config.vision_dim,
847                self.config.vision_n_heads,
848                pool,
849            );
850            for (dst, update) in x
851                .chunks_exact_mut(self.config.vision_dim)
852                .zip(attention.chunks_exact(self.config.vision_dim))
853            {
854                for (v, &u) in dst.iter_mut().zip(update) {
855                    *v += u;
856                }
857            }
858            let mut normed = vec![0.0f32; x.len()];
859            for (src, dst) in x
860                .chunks_exact(self.config.vision_dim)
861                .zip(normed.chunks_exact_mut(self.config.vision_dim))
862            {
863                rms_norm(src, &block.norm2, 1e-6, dst);
864            }
865            let inter = block.mlp.w1.weight.rows() / 2;
866            let mut hidden = vec![0.0f32; n * 2 * inter];
867            block.mlp.w1.apply_many(&normed, n, &mut hidden, pool);
868            let mut activated = vec![0.0f32; n * inter];
869            for (source, output) in hidden
870                .chunks_exact(2 * inter)
871                .zip(activated.chunks_exact_mut(inter))
872            {
873                for i in 0..inter {
874                    let gate = source[i];
875                    output[i] = gate / (1.0 + (-gate).exp()) * source[inter + i];
876                }
877            }
878            let mut mlp_out = vec![0.0f32; x.len()];
879            block.mlp.w2.apply_many(&activated, n, &mut mlp_out, pool);
880            for (dst, update) in x
881                .chunks_exact_mut(self.config.vision_dim)
882                .zip(mlp_out.chunks_exact(self.config.vision_dim))
883            {
884                for (v, &u) in dst.iter_mut().zip(update) {
885                    *v += u;
886                }
887            }
888        }
889        for src in x.chunks_exact_mut(self.config.vision_dim).take(n) {
890            let copy = src.to_vec();
891            rms_norm(&copy, &self.norm, 1e-6, src);
892        }
893        self.align(image, &x, pool)
894    }
895
896    /// Insert image marker/projector rows into a text hidden-state span.
897    /// `span` must have the exact `ImageInput::types` length and be laid out
898    /// with the text hidden size in its last dimension.
899    pub fn fill_image_span(
900        &self,
901        image: &ImageInput,
902        span: &mut [f32],
903        pool: Option<&Pool>,
904    ) -> Result<(), String> {
905        let dim = self.config.text_dim;
906        if span.len() != image.types.len() * dim {
907            return Err(format!(
908                "image span has {} values, expected {}",
909                span.len(),
910                image.types.len() * dim
911            ));
912        }
913        let embeds = self.encode_image(image, pool)?;
914        let mut image_row = 0;
915        for (kind, row) in image.types.iter().zip(span.chunks_exact_mut(dim)) {
916            match *kind {
917                IMAGE_START => row.copy_from_slice(&self.image_start),
918                IMAGE_END => row.copy_from_slice(&self.image_end),
919                IMAGE_NEW_LINE => row.copy_from_slice(&self.image_newline),
920                IMAGE => {
921                    let source = embeds
922                        .get(image_row * dim..(image_row + 1) * dim)
923                        .ok_or_else(|| "aligner/image token count mismatch".to_string())?;
924                    row.copy_from_slice(source);
925                    image_row += 1;
926                }
927                TEXT => return Err("TEXT type cannot occur inside an image span".to_string()),
928                other => return Err(format!("unknown image token type {other}")),
929            }
930        }
931        if image_row != embeds.len() / dim {
932            return Err("aligner produced a different number of image rows".to_string());
933        }
934        Ok(())
935    }
936
937    /// Return one complete text-hidden image span (markers plus projected
938    /// image rows) for callers that do not already own a preallocated hidden
939    /// buffer.  The runtime's batched prefill can copy these rows directly
940    /// over the corresponding placeholder positions.
941    pub fn image_span(&self, image: &ImageInput, pool: Option<&Pool>) -> Result<Vec<f32>, String> {
942        let mut span = vec![0.0f32; image.types.len() * self.config.text_dim];
943        self.fill_image_span(image, &mut span, pool)?;
944        Ok(span)
945    }
946
947    fn align(
948        &self,
949        image: &ImageInput,
950        x: &[f32],
951        pool: Option<&Pool>,
952    ) -> Result<Vec<f32>, String> {
953        let r = self.config.vision_downsample_ratio;
954        let (input, out_h, out_w) =
955            unfold_padded(x, image.n_vit_h, image.n_vit_w, self.config.vision_dim, r);
956        let rows = out_h * out_w;
957        let mut out = vec![0.0f32; rows * self.config.text_dim];
958        let mut hidden = vec![0.0f32; rows * self.config.text_dim];
959        self.aligner_w1.apply_many(&input, rows, &mut hidden, pool);
960        for value in &mut hidden {
961            *value = gelu_exact(*value);
962        }
963        self.aligner_w2.apply_many(&hidden, rows, &mut out, pool);
964        if out.len() != image.image_positions() * self.config.text_dim {
965            return Err(format!(
966                "aligner produced {} rows but span requests {} image positions",
967                rows,
968                image.image_positions()
969            ));
970        }
971        Ok(out)
972    }
973}
974
975/// Official 2-D RoPE grid.  Returned vectors are `[tokens, rope_dim]`, where
976/// `rope_dim` is half a head and is broadcast over each q/k half.
977pub fn get_vision_cos_sin(n_h: usize, n_w: usize, dim: usize, theta: f32) -> (Vec<f32>, Vec<f32>) {
978    let mut inv_freq = Vec::with_capacity(dim / 2);
979    for i in (0..dim).step_by(2) {
980        inv_freq.push(1.0 / theta.powf(i as f32 / dim.max(1) as f32));
981    }
982    let mut cos = Vec::with_capacity(n_h * n_w * dim);
983    let mut sin = Vec::with_capacity(n_h * n_w * dim);
984    for h in 0..n_h {
985        for w in 0..n_w {
986            for &position in &[h as f32, w as f32] {
987                for &frequency in &inv_freq {
988                    let angle = position * frequency;
989                    cos.push(angle.cos());
990                    sin.push(angle.sin());
991                }
992            }
993        }
994    }
995    debug_assert_eq!(cos.len(), n_h * n_w * dim);
996    debug_assert_eq!(sin.len(), cos.len());
997    (cos, sin)
998}
999
1000pub fn apply_rotary(x: &mut [f32], cos: &[f32], sin: &[f32]) {
1001    assert_eq!(x.len(), cos.len() * 2);
1002    let half = x.len() / 2;
1003    let left = x[..half].to_vec();
1004    let right = x[half..].to_vec();
1005    for i in 0..half {
1006        x[i] = left[i] * cos[i] - right[i] * sin[i];
1007        x[half + i] = right[i] * cos[i] + left[i] * sin[i];
1008    }
1009}
1010
1011pub fn rms_norm(input: &[f32], weight: &[f32], eps: f32, output: &mut [f32]) {
1012    assert_eq!(input.len(), weight.len());
1013    assert!(output.len() >= input.len());
1014    let mean = input.iter().map(|v| v * v).sum::<f32>() / input.len().max(1) as f32;
1015    let scale = (mean + eps).sqrt().recip();
1016    for ((dst, &value), &factor) in output.iter_mut().zip(input).zip(weight) {
1017        *dst = value * scale * factor;
1018    }
1019}
1020
1021fn gelu_exact(value: f32) -> f32 {
1022    // Abramowitz-Stegun erf approximation (maximum error ~1.5e-7), matching
1023    // torch.nn.functional.gelu(..., approximate="none") closely in f32.
1024    let sign = if value < 0.0 { -1.0 } else { 1.0 };
1025    let x = value.abs() / std::f32::consts::SQRT_2;
1026    let t = 1.0 / (1.0 + 0.3275911 * x);
1027    let polynomial = (((((1.061_405_4 * t - 1.453_152_1) * t) + 1.421_413_8) * t - 0.284_496_72)
1028        * t
1029        + 0.254_829_6)
1030        * t;
1031    let erf = sign * (1.0 - polynomial * (-x * x).exp());
1032    0.5 * value * (1.0 + erf)
1033}
1034
1035fn attention_forward(
1036    input: &[f32],
1037    attention: &VisionAttention,
1038    cos: &[f32],
1039    sin: &[f32],
1040    dim: usize,
1041    heads: usize,
1042    pool: Option<&Pool>,
1043) -> Vec<f32> {
1044    let n = input.len() / dim;
1045    let head_dim = dim / heads;
1046    let rope_dim = head_dim / 2;
1047    // The reference applies qkv and output projections token by token.  The
1048    // mathematical result is the same when the existing QTensor GEMM path
1049    // handles the complete image batch, while the weight stream is read once
1050    // per projection (and Q4TP can use its GPU matmat kernel).
1051    let mut qkv = vec![0.0f32; n * 3 * dim];
1052    attention.wqkv.apply_many(input, n, &mut qkv, pool);
1053    let mut q = vec![0.0f32; n * dim];
1054    let mut k = vec![0.0f32; n * dim];
1055    let mut v = vec![0.0f32; n * dim];
1056    for (token, source) in qkv.chunks_exact(3 * dim).enumerate() {
1057        q[token * dim..(token + 1) * dim].copy_from_slice(&source[..dim]);
1058        k[token * dim..(token + 1) * dim].copy_from_slice(&source[dim..2 * dim]);
1059        v[token * dim..(token + 1) * dim].copy_from_slice(&source[2 * dim..]);
1060        let cos_row = &cos[token * rope_dim..(token + 1) * rope_dim];
1061        let sin_row = &sin[token * rope_dim..(token + 1) * rope_dim];
1062        for head in 0..heads {
1063            let offset = token * dim + head * head_dim;
1064            apply_rotary(&mut q[offset..offset + head_dim], cos_row, sin_row);
1065            apply_rotary(&mut k[offset..offset + head_dim], cos_row, sin_row);
1066        }
1067    }
1068    let scale = (head_dim as f32).sqrt().recip();
1069
1070    // The CPU reference below is intentionally retained as the portable
1071    // fallback, but its O(heads * n² * head_dim) loop is not viable for the
1072    // 3072-token V4.1 image grid.  The existing backend attention kernel
1073    // consumes head-major panels and returns the original token-major
1074    // layout, so transpose only when the caller has explicitly enabled a
1075    // live backend.  A refusal falls through to the exact same CPU path.
1076    if n >= 128 && crate::gpu::enabled_here() {
1077        let panel_len = n * heads * head_dim;
1078        let mut qh = vec![0.0f32; panel_len];
1079        let mut kh = vec![0.0f32; panel_len];
1080        let mut vh = vec![0.0f32; panel_len];
1081        for token in 0..n {
1082            for head in 0..heads {
1083                let src = token * dim + head * head_dim;
1084                let dst = head * n * head_dim + token * head_dim;
1085                qh[dst..dst + head_dim].copy_from_slice(&q[src..src + head_dim]);
1086                kh[dst..dst + head_dim].copy_from_slice(&k[src..src + head_dim]);
1087                vh[dst..dst + head_dim].copy_from_slice(&v[src..src + head_dim]);
1088            }
1089        }
1090        let mut context = vec![0.0f32; panel_len];
1091        if crate::gpu::dit_attention(
1092            &qh,
1093            &kh,
1094            &vh,
1095            heads,
1096            heads,
1097            n,
1098            head_dim,
1099            scale,
1100            &mut context,
1101        ) {
1102            GPU_ATTENTION_DISPATCHES.fetch_add(1, Ordering::Relaxed);
1103            let mut output = vec![0.0f32; n * dim];
1104            attention.wo.apply_many(&context, n, &mut output, pool);
1105            return output;
1106        }
1107    }
1108
1109    let mut context = vec![0.0f32; n * dim];
1110    let mut scores = vec![0.0f32; n];
1111    for head in 0..heads {
1112        for query in 0..n {
1113            let qrow = &q[query * dim + head * head_dim..query * dim + (head + 1) * head_dim];
1114            let mut max_score = f32::NEG_INFINITY;
1115            for key in 0..n {
1116                let krow = &k[key * dim + head * head_dim..key * dim + (head + 1) * head_dim];
1117                let score = qrow.iter().zip(krow).map(|(a, b)| a * b).sum::<f32>() * scale;
1118                scores[key] = score;
1119                max_score = max_score.max(score);
1120            }
1121            let mut denominator = 0.0f32;
1122            for score in &mut scores {
1123                *score = (*score - max_score).exp();
1124                denominator += *score;
1125            }
1126            let inv = denominator.recip();
1127            let out =
1128                &mut context[query * dim + head * head_dim..query * dim + (head + 1) * head_dim];
1129            for key in 0..n {
1130                let probability = scores[key] * inv;
1131                let vrow = &v[key * dim + head * head_dim..key * dim + (head + 1) * head_dim];
1132                for (dst, &value) in out.iter_mut().zip(vrow) {
1133                    *dst += probability * value;
1134                }
1135            }
1136        }
1137    }
1138    let mut output = vec![0.0f32; n * dim];
1139    attention.wo.apply_many(&context, n, &mut output, pool);
1140    output
1141}
1142
1143#[cfg(test)]
1144mod tests {
1145    use super::*;
1146
1147    #[test]
1148    fn image_grid_and_types_match_reference() {
1149        let config = VisionConfig {
1150            vision_n_layers: 1,
1151            ..VisionConfig::default()
1152        };
1153        let (h, w, best_h, best_w) = plan_image_grid(544, 544, &config).unwrap();
1154        assert_eq!((h, w), (13, 13));
1155        assert_eq!((best_h, best_w), (546, 546));
1156        assert_eq!(num_image_tokens(h, w), 184);
1157        let types = image_token_types(h, w);
1158        assert_eq!(types.first(), Some(&IMAGE_START));
1159        assert_eq!(types.last(), Some(&IMAGE_END));
1160        assert_eq!(types.iter().filter(|&&v| v == IMAGE_NEW_LINE).count(), h);
1161        assert_eq!(types.iter().filter(|&&v| v == IMAGE).count(), h * w);
1162    }
1163
1164    #[test]
1165    fn rope_grid_has_reference_first_rows() {
1166        let (cos, sin) = get_vision_cos_sin(2, 2, 4, 10_000.0);
1167        assert_eq!(&cos[..4], &[1.0, 1.0, 1.0, 1.0]);
1168        assert_eq!(&sin[..4], &[0.0, 0.0, 0.0, 0.0]);
1169        assert!((cos[6] - 0.5403023).abs() < 1e-6);
1170        assert!((sin[6] - 0.8414710).abs() < 1e-6);
1171    }
1172
1173    #[test]
1174    fn exact_gelu_and_rms_are_finite() {
1175        assert!((gelu_exact(1.0) - 0.8413447).abs() < 2e-6);
1176        let mut out = [0.0; 2];
1177        rms_norm(&[3.0, 4.0], &[1.0, 2.0], 1e-6, &mut out);
1178        assert!(out.iter().all(|v| v.is_finite()));
1179        let scale = (12.5_f32 + 1e-6).sqrt().recip();
1180        assert!((out[0] - 3.0 * scale).abs() < 1e-4);
1181        assert!((out[1] - 8.0 * scale).abs() < 1e-4);
1182    }
1183
1184    #[test]
1185    fn aligner_zero_pads_nonmultiple_vit_grid() {
1186        // Python's `-n % r` is positive for a nonmultiple n.  Thus a 35x46
1187        // ViT grid becomes 36x48 before F.unfold and yields the same 12x16
1188        // image rows advertised by the processor, rather than dropping the
1189        // final partial cells.
1190        assert_eq!(padded_vit_grid(35, 46, 3), (36, 48));
1191        assert_eq!(llm_grid(36 * 14, 48 * 14, 14, 3), (12, 16));
1192        assert_eq!(
1193            12 * 16,
1194            image_token_types(12, 16)
1195                .iter()
1196                .filter(|&&v| v == IMAGE)
1197                .count()
1198        );
1199
1200        // Also pin the actual channel-major unfold at both right and bottom
1201        // boundaries.  The second window contains the final real column and
1202        // two zero columns; its bottom two rows are zero as well.
1203        let x: Vec<f32> = (0..8).map(|v| v as f32).collect();
1204        let (windows, out_h, out_w) = unfold_padded(&x, 2, 4, 1, 3);
1205        assert_eq!((out_h, out_w), (1, 2));
1206        assert_eq!(
1207            &windows[..9],
1208            &[0.0, 1.0, 2.0, 4.0, 5.0, 6.0, 0.0, 0.0, 0.0]
1209        );
1210        assert_eq!(
1211            &windows[9..],
1212            &[3.0, 0.0, 0.0, 7.0, 0.0, 0.0, 0.0, 0.0, 0.0]
1213        );
1214    }
1215}