use anyhow::{anyhow, Context as _, Result};
use crate::inference::vision::mmproj::VisionFamily;
use crate::inference::vision::vit_gpu::{compute_vision_embeddings_gpu_dispatch, VisionInput};
use crate::serve::api::engine::SoftTokenData;
use crate::serve::api::state::LoadedMmproj;
use tokenizers::Tokenizer;
#[derive(Debug, Clone)]
pub struct VisionPipelineOutput {
pub embeddings: Vec<Vec<f32>>,
pub family: VisionFamily,
pub per_row_floats: usize,
pub qwen3vl_image_grids: Vec<(u32, u32)>,
pub forward_ms: u64,
}
impl VisionPipelineOutput {
pub fn n_image_tokens(&self, i: usize) -> usize {
self.embeddings[i].len() / self.per_row_floats
}
pub fn total_image_tokens(&self) -> usize {
(0..self.embeddings.len())
.map(|i| self.n_image_tokens(i))
.sum()
}
}
pub fn run_vit_forward(
preprocessed_inputs: &[VisionInput],
mmproj: &LoadedMmproj,
hidden_size: usize,
) -> Result<VisionPipelineOutput> {
if preprocessed_inputs.is_empty() {
return Ok(VisionPipelineOutput {
embeddings: Vec::new(),
family: mmproj.arch.vision_family(),
per_row_floats: hidden_size,
qwen3vl_image_grids: Vec::new(),
forward_ms: 0,
});
}
let head_dim_f = (mmproj.config.hidden_size / mmproj.config.num_attention_heads) as f32;
let scale = 1.0f32 / head_dim_f.sqrt();
let t0 = std::time::Instant::now();
let embeddings = compute_vision_embeddings_gpu_dispatch(
preprocessed_inputs,
mmproj.arch,
&mmproj.weights,
&mmproj.config,
scale,
)
.context("ViT GPU forward failed")?;
let forward_ms = t0.elapsed().as_millis() as u64;
let family = mmproj.arch.vision_family();
let n_deepstack = mmproj
.config
.deepstack_indexes
.as_ref()
.map(|v| v.len())
.unwrap_or(0);
let per_row_floats = match family {
VisionFamily::Gemma => hidden_size,
VisionFamily::Qwen3Vl => hidden_size.saturating_mul(1 + n_deepstack),
VisionFamily::Unknown => hidden_size,
};
for (i, e) in embeddings.iter().enumerate() {
if per_row_floats == 0 || e.is_empty() || e.len() % per_row_floats != 0 {
return Err(anyhow!(
"vision embedding [{i}] length {} is not a positive multiple \
of per_row_floats {per_row_floats} (family={family:?}, \
hidden={hidden_size}, n_deepstack={n_deepstack})",
e.len()
));
}
}
let qwen3vl_image_grids: Vec<(u32, u32)> = if matches!(family, VisionFamily::Qwen3Vl) {
let stride = mmproj
.config
.patch_size
.saturating_mul(mmproj.config.spatial_merge_size.unwrap_or(1));
preprocessed_inputs
.iter()
.map(|input| match input {
VisionInput::Siglip49(p) => {
let (pw, ph) = p.pixel_grid();
let nx = if stride > 0 { pw / stride } else { 0 };
let ny = if stride > 0 { ph / stride } else { 0 };
(nx, ny)
}
VisionInput::Gemma4v(_) => (0, 0),
})
.collect()
} else {
Vec::new()
};
let n_images = embeddings.len();
let embed_dim = embeddings.first().map(|e| e.len()).unwrap_or(0);
tracing::info!(
n_images,
embed_dim,
forward_ms,
arch = mmproj.arch.as_str(),
"Vision embeddings computed via GPU ViT forward"
);
Ok(VisionPipelineOutput {
embeddings,
family,
per_row_floats,
qwen3vl_image_grids,
forward_ms,
})
}
pub fn expand_image_placeholders(
tokenizer: &Tokenizer,
prompt_tokens: &[u32],
embeddings: &[Vec<f32>],
family: VisionFamily,
per_row_floats: usize,
hidden_size: usize,
) -> Result<(Vec<u32>, Vec<SoftTokenData>, Vec<Vec<u32>>)> {
let n_images = embeddings.len();
if hidden_size == 0 || per_row_floats == 0 {
return Err(anyhow!(
"expand_image_placeholders: degenerate hidden ({hidden_size}) or \
per_row_floats ({per_row_floats})"
));
}
let placeholder_literal = family.placeholder_token_literal().ok_or_else(|| {
anyhow!(
"expand_image_placeholders: VisionFamily::{:?} has no placeholder \
token literal — caller should have rejected this profile upstream",
family
)
})?;
let img_token_id: u32 = tokenizer.token_to_id(placeholder_literal).ok_or_else(|| {
anyhow!(
"tokenizer has no `{placeholder_literal}` special-token id; the \
loaded chat model does not support vision input through hf2q's \
soft-token path"
)
})?;
let placeholder_positions: Vec<usize> = prompt_tokens
.iter()
.enumerate()
.filter_map(|(p, t)| if *t == img_token_id { Some(p) } else { None })
.collect();
if placeholder_positions.len() != n_images {
return Err(anyhow!(
"rendered prompt has {} `{placeholder_literal}` placeholder(s) but \
request carries {} image(s); the chat template likely dropped or \
duplicated image markers — check `tokenizer_config.json` and the \
GGUF chat template",
placeholder_positions.len(),
n_images
));
}
for (i, e) in embeddings.iter().enumerate() {
if e.len() % per_row_floats != 0 || e.is_empty() {
return Err(anyhow!(
"vision embedding [{i}] length {} is not a positive multiple \
of per_row_floats {per_row_floats} (family={family:?}, \
hidden={hidden_size})",
e.len()
));
}
}
let mlx_dev =
mlx_native::MlxDevice::new().map_err(|e| anyhow!("MlxDevice init failed: {e}"))?;
let total_extra: usize = embeddings
.iter()
.map(|e| e.len() / per_row_floats)
.sum::<usize>()
.saturating_sub(n_images);
let mut prompt_expanded: Vec<u32> = Vec::with_capacity(prompt_tokens.len() + total_extra);
let mut soft_tokens: Vec<SoftTokenData> = Vec::with_capacity(n_images);
let mut image_token_positions: Vec<Vec<u32>> = Vec::with_capacity(n_images);
let mut last_pos = 0usize;
for (i, &pos) in placeholder_positions.iter().enumerate() {
prompt_expanded.extend_from_slice(&prompt_tokens[last_pos..pos]);
let n_image_tokens = embeddings[i].len() / per_row_floats;
let start = prompt_expanded.len();
for _ in 0..n_image_tokens {
prompt_expanded.push(img_token_id);
}
let end = prompt_expanded.len();
let byte_len = n_image_tokens * hidden_size * std::mem::size_of::<f32>();
let mut buf = mlx_dev
.alloc_buffer(
byte_len,
mlx_native::DType::F32,
vec![n_image_tokens, hidden_size],
)
.map_err(|e| anyhow!("soft-token buffer alloc failed (image {i}): {e}"))?;
{
let dst = buf
.as_mut_slice::<f32>()
.map_err(|e| anyhow!("soft-token buffer mut slice failed (image {i}): {e}"))?;
debug_assert_eq!(dst.len(), n_image_tokens * hidden_size);
if per_row_floats == hidden_size {
dst.copy_from_slice(&embeddings[i]);
} else {
for row in 0..n_image_tokens {
let src_base = row * per_row_floats;
let dst_base = row * hidden_size;
dst[dst_base..dst_base + hidden_size]
.copy_from_slice(&embeddings[i][src_base..src_base + hidden_size]);
}
}
}
soft_tokens.push(SoftTokenData {
range: start..end,
embeddings: buf,
});
image_token_positions.push((start..end).map(|p| p as u32).collect());
last_pos = pos + 1;
}
prompt_expanded.extend_from_slice(&prompt_tokens[last_pos..]);
Ok((prompt_expanded, soft_tokens, image_token_positions))
}