metrale_model_layers/layers/vision_encoder/enc_impl/
init.rs1use anyhow::Result;
10use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend};
11
12use super::super::{MergerLayer, PATCH_DIM, ViTBlock, VisionEncoder, VisionScratch};
13
14pub const FALLBACK_MAX_PATCHES: usize = 6400;
17
18pub const CEILING_MAX_PATCHES: usize = 16384;
26
27pub fn derive_max_patches(max_pixels: Option<usize>, patch_size: usize) -> (usize, Option<usize>) {
35 let Some(area) = max_pixels.filter(|&a| a > 0) else {
36 return (FALLBACK_MAX_PATCHES, None);
37 };
38 let per_patch = patch_size.max(1) * patch_size.max(1);
39 let wanted = (area / per_patch).max(1);
40 if wanted > CEILING_MAX_PATCHES {
41 (CEILING_MAX_PATCHES, Some(wanted))
42 } else {
43 (wanted.max(FALLBACK_MAX_PATCHES.min(wanted)), None)
44 }
45}
46
47impl VisionEncoder {
48 #[allow(clippy::too_many_arguments)]
49 pub fn new(
50 patch_embed_w: DevicePtr,
51 patch_embed_b: DevicePtr,
52 pos_embed: DevicePtr,
53 num_position_embeddings: usize,
54 blocks: Vec<ViTBlock>,
55 deepstack: Vec<MergerLayer>,
56 deepstack_indexes: Vec<usize>,
57 merger: MergerLayer,
58 hidden_size: usize,
59 num_heads: usize,
60 spatial_merge_size: usize,
61 out_hidden_size: usize,
62 intermediate_size: usize,
63 patch_size: usize,
64 max_pixels: Option<usize>,
65 gpu: &dyn GpuBackend,
66 ) -> Result<Self> {
67 let head_dim = hidden_size / num_heads;
68 let (p_max, asked_for) = derive_max_patches(max_pixels, patch_size);
69 match asked_for {
70 Some(wanted) => tracing::warn!(
71 "Vision encoder capacity {p_max} patches ({}x{} px) — the resolved area bound \
72 wanted {wanted} patches, clamped: the ViT score matrix is O(patches^2) and \
73 {wanted} would need ~{:.1} GB of scratch. Lower --vision-max-pixels to reclaim \
74 memory, or raise CEILING_MAX_PATCHES only alongside tiled ViT attention.",
75 (p_max as f64).sqrt() as usize * patch_size,
76 (p_max as f64).sqrt() as usize * patch_size,
77 ((wanted * wanted * 6) as f64) / (1024.0 * 1024.0 * 1024.0),
78 ),
79 None => tracing::info!(
80 "Vision encoder capacity {p_max} patches ({}x{} px){}",
81 (p_max as f64).sqrt() as usize * patch_size,
82 (p_max as f64).sqrt() as usize * patch_size,
83 if max_pixels.is_some() {
84 " from the resolved area bound"
85 } else {
86 " (no bound declared — historical fallback)"
87 }
88 ),
89 }
90
91 let num_grid_per_side = (num_position_embeddings as f64).sqrt().round() as usize;
94 anyhow::ensure!(
95 num_grid_per_side * num_grid_per_side == num_position_embeddings,
96 "non-square pos_embed: {num_position_embeddings} is not a perfect square"
97 );
98
99 let pos_n = num_position_embeddings * hidden_size;
102 let mut pe_bytes = vec![0u8; pos_n * 2];
103 gpu.copy_d2h(pos_embed, &mut pe_bytes)?;
104 let pos_embed_host_f32: Vec<f32> = pe_bytes
105 .chunks_exact(2)
106 .map(|c| {
107 let bits = u16::from_le_bytes([c[0], c[1]]);
108 f32::from_bits((bits as u32) << 16)
109 })
110 .collect();
111
112 let rope_dim = head_dim / 2;
116 let rope_half = rope_dim / 2;
117 let theta: f32 = 10_000.0;
118 let rope_inv_freq: Vec<f32> = (0..rope_half)
119 .map(|k| 1.0 / theta.powf(2.0 * k as f32 / rope_dim as f32))
120 .collect();
121
122 Ok(Self {
123 patch_embed_w,
124 patch_embed_b,
125 pos_embed,
126 blocks,
127 deepstack,
128 deepstack_indexes,
129 merger,
130 k_gemm: gpu.kernel("vision_encoder", "vision_gemm_bias")?,
131 k_gemm_pipelined: crate::layers::try_kernel(gpu, "gemm", "dense_gemm_bf16_pipelined"),
134 k_add_bias: crate::layers::try_kernel(gpu, "vision_encoder", "vision_add_bias"),
135 k_norm: gpu.kernel("vision_encoder", "vision_layer_norm")?,
136 k_add: gpu.kernel("vision_encoder", "vision_add_inplace")?,
137 k_gelu: gpu.kernel("vision_encoder", "vision_gelu")?,
138 k_attn: gpu.kernel("vision_encoder", "vision_attention_rope")?,
142 k_rope_deint: crate::layers::try_kernel(gpu, "vision_encoder", "vit_rope_deinterleave"),
147 k_softmax: crate::layers::try_kernel(gpu, "vision_encoder", "vit_softmax_rows"),
148 k_scatter_head: crate::layers::try_kernel(gpu, "vision_encoder", "vit_scatter_head"),
149 k_gemm_f32: crate::layers::try_kernel(gpu, "gemm", "dense_gemm_bf16_f32out"),
152 k_merge: gpu.kernel("vision_encoder", "vision_spatial_merge")?,
153 k_f32_bf16: gpu.kernel("vision_encoder", "vision_f32_to_bf16")?,
154 k_copy: gpu.kernel("vision_encoder", "vision_bf16_copy")?,
155 hidden_size,
156 num_heads,
157 head_dim,
158 spatial_merge_size,
159 out_hidden_size,
160 intermediate_size,
161 p_max,
162 num_grid_per_side,
163 scratch: std::sync::OnceLock::new(),
164 pos_embed_host_f32,
165 rope_inv_freq,
166 })
167 }
168}
169
170impl VisionEncoder {
171 fn build_scratch(&self, gpu: &dyn GpuBackend) -> Result<VisionScratch> {
173 let p_max = self.p_max;
174 let hidden_size = self.hidden_size;
175 let intermediate_size = self.intermediate_size;
176 let out_hidden_size = self.out_hidden_size;
177 let merger_in_dim = self.spatial_merge_size * self.spatial_merge_size * hidden_size;
178 let num_heads = self.num_heads;
179 let head_dim = self.head_dim;
180 let buf_f32 = gpu.alloc(p_max * PATCH_DIM * 4)?;
181 let buf_h1 = gpu.alloc(p_max * hidden_size * 2)?;
182 let buf_h2 = gpu.alloc(p_max * hidden_size * 2)?;
183 let buf_wide = gpu.alloc(p_max * intermediate_size * 2)?;
184 let buf_merge_in = gpu.alloc((p_max / 4) * merger_in_dim * 2)?;
185 let buf_merge_fc1 = gpu.alloc((p_max / 4) * merger_in_dim * 2)?;
186 let buf_out = gpu.alloc(p_max * out_hidden_size * 2)?;
187 let buf_pos_resampled = gpu.alloc(p_max * hidden_size * 2)?;
188 let buf_rope_cos = gpu.alloc(p_max * head_dim * 2)?;
189 let buf_rope_sin = gpu.alloc(p_max * head_dim * 2)?;
190
191 let attn_max = p_max;
196 let qkv_head_elems = p_max * num_heads * head_dim;
197 let buf_qr = gpu.alloc(qkv_head_elems * 2)?;
198 let buf_kr = gpu.alloc(qkv_head_elems * 2)?;
199 let buf_vt = gpu.alloc(qkv_head_elems * 2)?;
200 let buf_scores = gpu.alloc(attn_max * attn_max * 4)?;
201 let buf_probs = gpu.alloc(attn_max * attn_max * 2)?;
202 let buf_o_stage = gpu.alloc(p_max * head_dim * 2)?;
203 Ok(VisionScratch {
204 buf_f32,
205 buf_h1,
206 buf_h2,
207 buf_wide,
208 buf_merge_in,
209 buf_merge_fc1,
210 buf_out,
211 buf_pos_resampled,
212 buf_rope_cos,
213 buf_rope_sin,
214 buf_qr,
215 buf_kr,
216 buf_vt,
217 buf_scores,
218 buf_probs,
219 buf_o_stage,
220 })
221 }
222
223 pub(crate) fn scratch_init(&self, gpu: &dyn GpuBackend) -> Result<()> {
227 if self.scratch.get().is_none() {
228 let s = self.build_scratch(gpu)?;
229 let _ = self.scratch.set(s);
232 tracing::info!(
233 "Vision scratch allocated on first image: {} patches",
234 self.p_max
235 );
236 }
237 Ok(())
238 }
239
240 pub(crate) fn scratch(&self) -> &VisionScratch {
243 self.scratch
244 .get()
245 .expect("vision scratch: encode entry must call scratch_init(gpu) first")
246 }
247}
248
249#[cfg(test)]
250mod derive_tests {
251 use super::*;
252
253 const Q38_BOUND: usize = 16_777_216;
255
256 #[test]
257 fn no_bound_keeps_the_historical_capacity() {
258 assert_eq!(derive_max_patches(None, 16), (FALLBACK_MAX_PATCHES, None));
260 assert_eq!(
261 derive_max_patches(Some(0), 16),
262 (FALLBACK_MAX_PATCHES, None)
263 );
264 }
265
266 #[test]
267 fn a_declared_bound_over_the_ceiling_is_clamped_and_reported() {
268 let (got, asked) = derive_max_patches(Some(Q38_BOUND), 16);
271 assert_eq!(got, CEILING_MAX_PATCHES);
272 assert_eq!(
273 asked,
274 Some(65_536),
275 "the caller must be able to report the ask"
276 );
277 }
278
279 #[test]
280 fn a_low_operator_bound_shrinks_the_allocation() {
281 let (got, asked) = derive_max_patches(Some(512 * 512), 16);
283 assert_eq!(asked, None, "under the ceiling, nothing was clamped");
284 assert_eq!(got, 1024, "512x512 at patch 16 is 32x32 = 1024 patches");
285 assert!(
286 got < FALLBACK_MAX_PATCHES,
287 "a low bound must allocate LESS than the historical default"
288 );
289 }
290
291 #[test]
292 fn capacity_tracks_patch_size() {
293 let (at16, _) = derive_max_patches(Some(1024 * 1024), 16);
296 let (at14, _) = derive_max_patches(Some(1024 * 1024), 14);
297 assert!(
298 at14 > at16,
299 "finer patches need more rows: {at14} vs {at16}"
300 );
301 }
302
303 #[test]
304 fn the_ceiling_matches_the_measured_affordable_rung() {
305 assert_eq!(CEILING_MAX_PATCHES, 16_384);
307 let side = (CEILING_MAX_PATCHES as f64).sqrt() as usize * 16;
308 assert_eq!(side, 2048, "the ceiling should be a clean square image");
309 }
310
311 #[test]
312 fn degenerate_inputs_do_not_produce_a_zero_allocation() {
313 assert_eq!(derive_max_patches(Some(1), 16), (1, None));
315 assert_eq!(derive_max_patches(Some(1024), 0), (1024, None));
316 }
317}