spark_model/layers/
vision_tower.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! The vision tower a built model holds, whichever family bound it.
4//!
5//! Atlas serves two structurally different towers: the Qwen3-VL-shaped
6//! [`VisionEncoder`] that six loaders bind, and GLM-5.3's [`GlmVit`]. They
7//! agree on exactly three things, and those three are the whole interface the
8//! rest of the engine needs:
9//!
10//! * `forward_batched(images) -> per-image (post_h, post_w, merged_p)`,
11//! * a packed `[Σmerged_p, out_hidden_size]` BF16 output buffer, image-ordered,
12//! * `out_hidden_size` itself.
13//!
14//! So the enum lives here rather than a trait object: two variants, three
15//! methods, no dynamic dispatch on a path that already costs a GEMM, and —
16//! more to the point — no way for a third tower to be added without deciding
17//! out loud what its output contract is.
18
19use anyhow::Result;
20use spark_runtime::gpu::{DevicePtr, GpuBackend};
21
22use super::glm_vit::GlmVit;
23use super::vision_encoder::VisionEncoder;
24
25pub enum VisionTower {
26    /// Qwen3-VL / Qwen3.5 / Qwen3.6 / Qwen4 / LongCat / Mistral.
27    Qwen(Box<VisionEncoder>),
28    /// GLM-5.3-Flash (`glm5_next`).
29    Glm(Box<GlmVit>),
30}
31
32impl VisionTower {
33    pub fn qwen(encoder: VisionEncoder) -> Self {
34        Self::Qwen(Box::new(encoder))
35    }
36
37    pub fn glm(encoder: GlmVit) -> Self {
38        Self::Glm(Box::new(encoder))
39    }
40
41    /// Encode a batch of `(pixels, grid_h, grid_w)` temporal groups. Returns
42    /// `(post_merge_h, post_merge_w, merged_patches)` per group, in order.
43    pub fn forward_batched(
44        &self,
45        images: &[(&[f32], usize, usize)],
46        gpu: &dyn GpuBackend,
47        stream: u64,
48    ) -> Result<Vec<(usize, usize, usize)>> {
49        match self {
50            Self::Qwen(e) => e.forward_batched(images, gpu, stream),
51            Self::Glm(e) => e.forward_batched(images, gpu, stream),
52        }
53    }
54
55    /// Width of one merged embedding row — the LLM's hidden size.
56    pub fn out_hidden_size(&self) -> usize {
57        match self {
58            Self::Qwen(e) => e.out_hidden_size,
59            Self::Glm(e) => e.out_hidden_size,
60        }
61    }
62
63    /// Allocate the encoder's device scratch if it is not allocated yet.
64    ///
65    /// Normally the first image does this. A TENSOR-PARALLEL WORKER never sees
66    /// one — it receives the merged rows over NCCL instead — so it has to ask
67    /// for the buffers explicitly before `out_row` can name a destination.
68    pub fn ensure_scratch(&self, gpu: &dyn GpuBackend) -> Result<()> {
69        match self {
70            Self::Qwen(e) => e.scratch_init(gpu),
71            Self::Glm(e) => e.scratch_init(gpu),
72        }
73    }
74
75    /// Device pointer to merged row `row` of the packed output. Valid only
76    /// after `forward_batched` or `ensure_scratch` has allocated the scratch.
77    pub fn out_row(&self, row: usize) -> DevicePtr {
78        match self {
79            Self::Qwen(e) => e.scratch().buf_out.offset(row * e.out_hidden_size * 2),
80            Self::Glm(e) => e.out_row(row),
81        }
82    }
83}