use anyhow::Result;
use super::vision_encoder::{
VisionEncoderConfig, VisionEncoderWeights, extract_patch, interpolate_pos_embed_2d,
pixel_shuffle,
};
use crate::model::weights::MmapWeight;
pub const MAX_VIT_TOKENS: usize = 1024;
#[cfg(test)]
mod const_sync_tests {
use super::MAX_VIT_TOKENS;
#[test]
fn max_vit_tokens_matches_attention_shader_scratch() {
let wgsl = include_str!("../backend/shaders/vit_attention.wgsl");
let metal = include_str!("../backend/shaders/vit_attention.metal");
let wgsl_decl = format!("const MAX_TOKENS: u32 = {MAX_VIT_TOKENS}u;");
let metal_decl = format!("constant uint MAX_TOKENS = {MAX_VIT_TOKENS}u;");
assert!(
wgsl.contains(&wgsl_decl),
"vit_attention.wgsl MAX_TOKENS != MAX_VIT_TOKENS ({MAX_VIT_TOKENS}); \
update the shader's `scores` array size to match"
);
assert!(
metal.contains(&metal_decl),
"vit_attention.metal MAX_TOKENS != MAX_VIT_TOKENS ({MAX_VIT_TOKENS}); \
update the shader's `scores` array size to match"
);
}
}
pub trait VitGpuOps {
type Buf;
type Weight;
fn upload(&self, data: &[f32]) -> Self::Buf;
fn download(&self, buf: &Self::Buf, len: usize) -> Vec<f32>;
fn upload_weight(&self, w: &MmapWeight) -> Self::Weight;
fn upload_weight_f32(&self, data: &[f32], out_dim: usize, in_dim: usize) -> Self::Weight;
fn linear(
&self,
x: &Self::Buf,
w: &Self::Weight,
tokens: usize,
out_dim: usize,
in_dim: usize,
) -> Self::Buf;
fn bias_add(&self, x: &Self::Buf, bias: &Self::Buf, rows: usize, dim: usize);
fn layernorm(
&self,
src: &Self::Buf,
weight: &Self::Buf,
bias: &Self::Buf,
eps: f32,
rows: usize,
dim: usize,
) -> Self::Buf;
fn gelu(&self, x: &Self::Buf, len: usize);
fn attention(
&self,
q: &Self::Buf,
k: &Self::Buf,
v: &Self::Buf,
tokens: usize,
n_head: usize,
head_dim: usize,
) -> Self::Buf;
fn add(&self, dst: &Self::Buf, src: &Self::Buf, len: usize);
fn sync(&self) {}
}
#[cfg(any(
feature = "gpu",
all(feature = "metal", any(target_os = "macos", target_os = "ios"))
))]
fn dequant_weight(w: &MmapWeight) -> Vec<f32> {
if let Some(f) = w.try_as_f32() {
return f.to_vec();
}
let mut out = vec![0f32; w.rows * w.cols];
for r in 0..w.rows {
w.dequantize_row(r, &mut out[r * w.cols..(r + 1) * w.cols]);
}
out
}
pub struct GpuVitBlock<O: VitGpuOps> {
ln1_w: O::Buf,
ln1_b: O::Buf,
q_w: O::Weight,
q_b: O::Buf,
k_w: O::Weight,
k_b: O::Buf,
v_w: O::Weight,
v_b: O::Buf,
o_w: O::Weight,
o_b: O::Buf,
ln2_w: O::Buf,
ln2_b: O::Buf,
ffn_up_w: O::Weight,
ffn_up_b: O::Buf,
ffn_down_w: O::Weight,
ffn_down_b: O::Buf,
}
pub struct GpuVitWeights<O: VitGpuOps> {
cfg: VisionEncoderConfig,
position_embed: Vec<f32>,
patch_conv_wt: O::Weight,
patch_conv_b: O::Buf,
blocks: Vec<GpuVitBlock<O>>,
post_ln_w: O::Buf,
post_ln_b: O::Buf,
mm1_w: O::Weight,
mm1_b: O::Buf,
mm2_w: O::Weight,
mm2_b: O::Buf,
proj_intermediate: usize,
}
impl<O: VitGpuOps> GpuVitWeights<O> {
pub fn build(ops: &O, w: &VisionEncoderWeights) -> Self {
let cfg = w.config.clone();
let p = cfg.patch_size;
let in_dim = 3 * p * p;
let out_dim = cfg.n_embd;
let src = &w.patch_embed.conv_w;
let mut convt = vec![0f32; in_dim * out_dim];
for i in 0..in_dim {
for o in 0..out_dim {
convt[o * in_dim + i] = src[i * out_dim + o];
}
}
let blocks = w
.blocks
.iter()
.map(|b| GpuVitBlock {
ln1_w: ops.upload(&b.ln1_w),
ln1_b: ops.upload(&b.ln1_b),
q_w: ops.upload_weight(&b.q_w),
q_b: ops.upload(&b.q_b),
k_w: ops.upload_weight(&b.k_w),
k_b: ops.upload(&b.k_b),
v_w: ops.upload_weight(&b.v_w),
v_b: ops.upload(&b.v_b),
o_w: ops.upload_weight(&b.o_w),
o_b: ops.upload(&b.o_b),
ln2_w: ops.upload(&b.ln2_w),
ln2_b: ops.upload(&b.ln2_b),
ffn_up_w: ops.upload_weight(&b.ffn_up_w),
ffn_up_b: ops.upload(&b.ffn_up_b),
ffn_down_w: ops.upload_weight(&b.ffn_down_w),
ffn_down_b: ops.upload(&b.ffn_down_b),
})
.collect();
GpuVitWeights {
position_embed: w.position_embed.clone(),
patch_conv_wt: ops.upload_weight_f32(&convt, out_dim, in_dim),
patch_conv_b: ops.upload(&w.patch_embed.conv_b),
blocks,
post_ln_w: ops.upload(&w.post_ln_w),
post_ln_b: ops.upload(&w.post_ln_b),
mm1_w: ops.upload_weight(&w.projector.mm1_w),
mm1_b: ops.upload(&w.projector.mm1_b),
mm2_w: ops.upload_weight(&w.projector.mm2_w),
mm2_b: ops.upload(&w.projector.mm2_b),
proj_intermediate: w.projector.mm1_w.rows,
cfg,
}
}
}
fn im2col_patches(
image: &[f32],
cfg: &VisionEncoderConfig,
grid_w: usize,
grid_h: usize,
) -> Vec<f32> {
let p = cfg.patch_size;
let in_dim = 3 * p * p;
let target_w = grid_w * p;
let target_h = grid_h * p;
let h_stride = target_w;
let c_stride = target_h * target_w;
let n_patches = grid_w * grid_h;
let mut patches = vec![0f32; n_patches * in_dim];
for patch_idx in 0..n_patches {
let base = patch_idx * in_dim;
extract_patch(
image,
&mut patches[base..base + in_dim],
patch_idx,
grid_w,
p,
h_stride,
c_stride,
);
}
patches
}
struct VitProfiler {
spans: std::cell::RefCell<Vec<(&'static str, std::time::Duration, u32)>>,
}
impl VitProfiler {
fn enabled() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENABLED.get_or_init(|| {
std::env::var("CERA_VIT_PROFILE").is_ok_and(|v| !v.is_empty() && v != "0")
})
}
fn from_env() -> Option<Self> {
Self::enabled().then(|| Self {
spans: std::cell::RefCell::new(Vec::new()),
})
}
fn record(&self, label: &'static str, d: std::time::Duration) {
let mut spans = self.spans.borrow_mut();
match spans.iter_mut().find(|s| s.0 == label) {
Some(s) => {
s.1 += d;
s.2 += 1;
}
None => spans.push((label, d, 1)),
}
}
fn report(&self, n_patches: usize, n_layer: usize) {
let mut spans = self.spans.borrow().clone();
spans.sort_by_key(|s| std::cmp::Reverse(s.1));
let total_ms: f64 = spans.iter().map(|s| s.1.as_secs_f64() * 1e3).sum();
eprintln!(
"\nViT GPU profile — {n_patches} patches, {n_layer} layers \
(per-op sync; wgpu sum is serialized, not the pipelined total)"
);
eprintln!(
" {:<16} {:>10} {:>7} {:>6} {:>10}",
"stage", "total ms", "%", "calls", "mean ms"
);
for (label, dur, count) in &spans {
let ms = dur.as_secs_f64() * 1e3;
let pct = if total_ms > 0.0 {
ms / total_ms * 100.0
} else {
0.0
};
let mean = ms / *count as f64;
eprintln!(" {label:<16} {ms:>10.2} {pct:>6.1}% {count:>6} {mean:>10.3}");
}
eprintln!(" {:<16} {total_ms:>10.2} {:>6.1}%", "TOTAL", 100.0);
}
}
pub fn encode_image_gpu<O: VitGpuOps>(
ops: &O,
gpu_w: &GpuVitWeights<O>,
pixels: &[f32],
grid_w: usize,
grid_h: usize,
) -> Result<Vec<f32>> {
let cfg = &gpu_w.cfg;
anyhow::ensure!(grid_w > 0 && grid_h > 0, "grid dims must be > 0");
anyhow::ensure!(
cfg.scale_factor > 0,
"vision encoder config has scale_factor=0"
);
anyhow::ensure!(
grid_w % cfg.scale_factor == 0 && grid_h % cfg.scale_factor == 0,
"grid {grid_w}×{grid_h} not divisible by scale_factor ({})",
cfg.scale_factor,
);
let p = cfg.patch_size;
let in_dim = 3 * p * p;
let n_embd = cfg.n_embd;
let n_ff = cfg.n_ff;
let n_head = cfg.n_head;
let head_dim = n_embd / n_head;
let n_patches = grid_w * grid_h;
let eps = cfg.eps;
anyhow::ensure!(
pixels.len() == 3 * grid_w * p * grid_h * p,
"encode_image_gpu: pixels.len() {} != 3·target_w·target_h",
pixels.len()
);
anyhow::ensure!(
n_patches <= MAX_VIT_TOKENS,
"encode_image_gpu: {n_patches} patches exceeds GPU MAX_VIT_TOKENS ({MAX_VIT_TOKENS}); \
caller should fall back to CPU",
);
let prof = VitProfiler::from_env();
macro_rules! timed {
($label:literal, $e:expr) => {{
match &prof {
Some(p) => {
let __t = std::time::Instant::now();
let __r = $e;
ops.sync();
p.record($label, __t.elapsed());
__r
}
None => $e,
}
}};
}
macro_rules! timed_cpu {
($label:literal, $e:expr) => {{
match &prof {
Some(p) => {
let __t = std::time::Instant::now();
let __r = $e;
p.record($label, __t.elapsed());
__r
}
None => $e,
}
}};
}
let patches = timed_cpu!("im2col", im2col_patches(pixels, cfg, grid_w, grid_h));
let patches_buf = timed!("upload", ops.upload(&patches));
let tokens = timed!(
"linear",
ops.linear(
&patches_buf,
&gpu_w.patch_conv_wt,
n_patches,
n_embd,
in_dim
)
);
timed!(
"bias_add",
ops.bias_add(&tokens, &gpu_w.patch_conv_b, n_patches, n_embd)
);
let trained_side = (cfg.n_trained_patches as f64).sqrt().round() as usize;
anyhow::ensure!(
trained_side * trained_side == cfg.n_trained_patches,
"non-square trained pos-embed grid ({} patches) is not supported",
cfg.n_trained_patches,
);
let pos: std::borrow::Cow<[f32]> = if grid_w == trained_side && grid_h == trained_side {
std::borrow::Cow::Borrowed(&gpu_w.position_embed)
} else {
timed_cpu!(
"posembed_interp",
std::borrow::Cow::Owned(interpolate_pos_embed_2d(
&gpu_w.position_embed,
trained_side,
trained_side,
grid_h,
grid_w,
n_embd,
))
)
};
let pos_buf = timed!("upload", ops.upload(&pos));
timed!("add", ops.add(&tokens, &pos_buf, n_patches * n_embd));
for blk in &gpu_w.blocks {
let normed = timed!(
"layernorm",
ops.layernorm(&tokens, &blk.ln1_w, &blk.ln1_b, eps, n_patches, n_embd)
);
let q = timed!(
"linear",
ops.linear(&normed, &blk.q_w, n_patches, n_embd, n_embd)
);
timed!("bias_add", ops.bias_add(&q, &blk.q_b, n_patches, n_embd));
let k = timed!(
"linear",
ops.linear(&normed, &blk.k_w, n_patches, n_embd, n_embd)
);
timed!("bias_add", ops.bias_add(&k, &blk.k_b, n_patches, n_embd));
let v = timed!(
"linear",
ops.linear(&normed, &blk.v_w, n_patches, n_embd, n_embd)
);
timed!("bias_add", ops.bias_add(&v, &blk.v_b, n_patches, n_embd));
let attn = timed!(
"attention",
ops.attention(&q, &k, &v, n_patches, n_head, head_dim)
);
let proj = timed!(
"linear",
ops.linear(&attn, &blk.o_w, n_patches, n_embd, n_embd)
);
timed!("bias_add", ops.bias_add(&proj, &blk.o_b, n_patches, n_embd));
timed!("add", ops.add(&tokens, &proj, n_patches * n_embd));
let normed2 = timed!(
"layernorm",
ops.layernorm(&tokens, &blk.ln2_w, &blk.ln2_b, eps, n_patches, n_embd)
);
let mid = timed!(
"linear",
ops.linear(&normed2, &blk.ffn_up_w, n_patches, n_ff, n_embd)
);
timed!(
"bias_add",
ops.bias_add(&mid, &blk.ffn_up_b, n_patches, n_ff)
);
timed!("gelu", ops.gelu(&mid, n_patches * n_ff));
let down = timed!(
"linear",
ops.linear(&mid, &blk.ffn_down_w, n_patches, n_embd, n_ff)
);
timed!(
"bias_add",
ops.bias_add(&down, &blk.ffn_down_b, n_patches, n_embd)
);
timed!("add", ops.add(&tokens, &down, n_patches * n_embd));
}
let tokens = timed!(
"layernorm",
ops.layernorm(
&tokens,
&gpu_w.post_ln_w,
&gpu_w.post_ln_b,
eps,
n_patches,
n_embd
)
);
let tok_cpu = timed!("download", ops.download(&tokens, n_patches * n_embd));
let pooled = timed_cpu!(
"pixel_shuffle",
pixel_shuffle(&tok_cpu, cfg, grid_w, grid_h)
);
let pooled_in_dim = n_embd * cfg.scale_factor * cfg.scale_factor;
let n_out = pooled.len() / pooled_in_dim;
let mid_dim = gpu_w.proj_intermediate;
let pooled_buf = timed!("upload", ops.upload(&pooled));
let proj_dim = cfg.projection_dim;
let mid = timed!(
"linear",
ops.linear(&pooled_buf, &gpu_w.mm1_w, n_out, mid_dim, pooled_in_dim)
);
timed!("bias_add", ops.bias_add(&mid, &gpu_w.mm1_b, n_out, mid_dim));
timed!("gelu", ops.gelu(&mid, n_out * mid_dim));
let out = timed!(
"linear",
ops.linear(&mid, &gpu_w.mm2_w, n_out, proj_dim, mid_dim)
);
timed!(
"bias_add",
ops.bias_add(&out, &gpu_w.mm2_b, n_out, proj_dim)
);
let result = timed!("download", ops.download(&out, n_out * proj_dim));
if let Some(p) = &prof {
p.report(n_patches, gpu_w.blocks.len());
}
Ok(result)
}
#[cfg(feature = "gpu")]
const VIT_MM_WG_M: u32 = 8;
#[cfg(feature = "gpu")]
const VIT_MM_WG_N: u32 = 32;
#[cfg(feature = "gpu")]
const VIT_MM_TILE_M: u32 = 4;
#[cfg(feature = "gpu")]
const VIT_MM_TILE_N: u32 = 1;
#[cfg(feature = "gpu")]
const VIT_MM_TILE_K: u32 = 32;
#[cfg(feature = "gpu")]
pub enum WgpuVitWeight {
Dense(wgpu::Buffer),
Quant {
buf: wgpu::Buffer,
dtype: crate::tensor::DType,
},
}
#[cfg(feature = "gpu")]
pub struct WgpuVitOps {
ctx: crate::backend::wgpu::GpuContext,
p_linear: wgpu::ComputePipeline,
p_mul_mat_q8_0: wgpu::ComputePipeline,
p_mul_mat_q4_0: wgpu::ComputePipeline,
p_bias: wgpu::ComputePipeline,
p_layernorm: wgpu::ComputePipeline,
p_gelu: wgpu::ComputePipeline,
p_attn: wgpu::ComputePipeline,
p_attn_tiled: wgpu::ComputePipeline,
p_add: wgpu::ComputePipeline,
}
#[cfg(feature = "gpu")]
impl WgpuVitOps {
pub fn new(ctx: crate::backend::wgpu::GpuContext) -> Result<Self> {
use crate::backend::wgpu::shaders;
std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
let p_linear = ctx.create_pipeline_with_defines(
shaders::MUL_MAT_REG_TILE,
"main",
"vit_linear",
&[
("SCALAR", ""),
("SRC0_INNER_TYPE", "f32"),
("SRC1_INNER_TYPE", "f32"),
("INIT_SRC0_SHMEM_FLOAT", ""),
("INIT_SRC1_SHMEM_FLOAT", ""),
("WORKGROUP_SIZE_M", "8u"),
("WORKGROUP_SIZE_N", "8u"),
("TILE_M", "4u"),
("TILE_N", "4u"),
("TILE_K", "32u"),
],
);
let (wg_m, wg_n) = (format!("{VIT_MM_WG_M}u"), format!("{VIT_MM_WG_N}u"));
let (tile_m, tile_n) = (format!("{VIT_MM_TILE_M}u"), format!("{VIT_MM_TILE_N}u"));
let tile_k = format!("{VIT_MM_TILE_K}u");
let mk_quant = |label: &str, init_src0: &str| {
ctx.create_pipeline_with_defines(
shaders::MUL_MAT_REG_TILE,
"main",
label,
&[
("SCALAR", ""),
("SRC0_INNER_TYPE", "u32"),
("SRC1_INNER_TYPE", "f32"),
(init_src0, ""),
("INIT_SRC1_SHMEM_FLOAT", ""),
("WORKGROUP_SIZE_M", &wg_m),
("WORKGROUP_SIZE_N", &wg_n),
("TILE_M", &tile_m),
("TILE_N", &tile_n),
("TILE_K", &tile_k),
],
)
};
let p_mul_mat_q8_0 = mk_quant("vit_mul_mat_q8_0", "INIT_SRC0_SHMEM_Q8_0");
let p_mul_mat_q4_0 = mk_quant("vit_mul_mat_q4_0", "INIT_SRC0_SHMEM_Q4_0");
Self {
p_bias: ctx.create_pipeline(shaders::BIAS_ADD, "bias_add", "vit_bias_add"),
p_layernorm: ctx.create_pipeline(
shaders::LAYERNORM_BATCH,
"layernorm_batch",
"vit_layernorm",
),
p_gelu: ctx.create_pipeline(shaders::GELU, "gelu_inplace", "vit_gelu"),
p_attn: ctx.create_pipeline(
shaders::VIT_ATTENTION,
"vit_attention",
"vit_attention",
),
p_attn_tiled: ctx.create_pipeline(
shaders::VIT_ATTENTION_TILED,
"vit_attention_tiled",
"vit_attention_tiled",
),
p_add: ctx.create_pipeline(shaders::ELEMENTWISE, "add_inplace", "vit_add"),
p_mul_mat_q8_0,
p_mul_mat_q4_0,
p_linear,
ctx,
}
}))
.map_err(|_| {
anyhow::anyhow!("wgpu ViT pipeline creation failed (shader compile/validation)")
})
}
fn dispatch(
&self,
pipeline: &wgpu::ComputePipeline,
bufs: &[&wgpu::Buffer],
workgroups: (u32, u32, u32),
) {
let entries: Vec<wgpu::BindGroupEntry> = bufs
.iter()
.enumerate()
.map(|(i, b)| wgpu::BindGroupEntry {
binding: i as u32,
resource: b.as_entire_binding(),
})
.collect();
let bind_group = self
.ctx
.device
.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &entries,
});
let mut enc = self
.ctx
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
{
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: None,
timestamp_writes: None,
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &bind_group, &[]);
pass.dispatch_workgroups(workgroups.0, workgroups.1, workgroups.2);
}
self.ctx.queue.submit(Some(enc.finish()));
}
#[allow(clippy::too_many_arguments)]
fn run_mul_mat_tiled(
&self,
pipe: &wgpu::ComputePipeline,
wq: &wgpu::Buffer,
x: &wgpu::Buffer,
y: &wgpu::Buffer,
tokens: usize,
out_dim: usize,
in_dim: usize,
) {
let params: [u32; 5] = [
out_dim as u32,
in_dim as u32,
tokens as u32,
in_dim as u32,
out_dim as u32,
];
let p_buf = self
.ctx
.upload_storage(bytemuck::cast_slice(¶ms), "vit_mul_mat_params");
let wg_m = (out_dim as u32).div_ceil(VIT_MM_WG_M * VIT_MM_TILE_M);
let wg_n = (tokens as u32).div_ceil(VIT_MM_WG_N * VIT_MM_TILE_N);
self.dispatch(pipe, &[wq, x, y, &p_buf], (wg_m, wg_n, 1));
}
}
#[cfg(feature = "gpu")]
impl VitGpuOps for WgpuVitOps {
type Buf = wgpu::Buffer;
type Weight = WgpuVitWeight;
fn upload(&self, data: &[f32]) -> Self::Buf {
self.ctx.upload_f32(data, "vit")
}
fn download(&self, buf: &Self::Buf, len: usize) -> Vec<f32> {
self.ctx.download_f32(buf, len)
}
fn upload_weight(&self, w: &MmapWeight) -> Self::Weight {
use crate::tensor::DType;
match w.dtype {
DType::Q8_0 | DType::Q4_0 => WgpuVitWeight::Quant {
buf: self.ctx.upload_storage(w.data(), "vit_wq"),
dtype: w.dtype,
},
_ => WgpuVitWeight::Dense(self.ctx.upload_f32(&dequant_weight(w), "vit_w")),
}
}
fn upload_weight_f32(&self, data: &[f32], _out_dim: usize, _in_dim: usize) -> Self::Weight {
WgpuVitWeight::Dense(self.ctx.upload_f32(data, "vit_w"))
}
fn linear(
&self,
x: &Self::Buf,
w: &Self::Weight,
tokens: usize,
out_dim: usize,
in_dim: usize,
) -> Self::Buf {
let y = self
.ctx
.create_storage_rw((tokens * out_dim * 4) as u64, "vit_linear_out");
match w {
WgpuVitWeight::Quant { buf, dtype } => {
let pipe = match dtype {
crate::tensor::DType::Q8_0 => &self.p_mul_mat_q8_0,
crate::tensor::DType::Q4_0 => &self.p_mul_mat_q4_0,
other => {
unreachable!("WgpuVitWeight::Quant holds only Q8_0/Q4_0, got {other:?}")
}
};
self.run_mul_mat_tiled(pipe, buf, x, &y, tokens, out_dim, in_dim);
}
WgpuVitWeight::Dense(buf) => {
let params: [u32; 5] = [
out_dim as u32,
in_dim as u32,
tokens as u32,
in_dim as u32,
out_dim as u32,
];
let p_buf = self
.ctx
.upload_storage(bytemuck::cast_slice(¶ms), "vit_linear_params");
let wg_m = (out_dim as u32).div_ceil(32);
let wg_n = (tokens as u32).div_ceil(32);
self.dispatch(&self.p_linear, &[buf, x, &y, &p_buf], (wg_m, wg_n, 1));
}
}
y
}
fn bias_add(&self, x: &Self::Buf, bias: &Self::Buf, rows: usize, dim: usize) {
let total = (rows * dim) as u32;
let params: [u32; 2] = [total, dim as u32];
let p_buf = self
.ctx
.upload_storage(bytemuck::cast_slice(¶ms), "vit_bias_params");
self.dispatch(
&self.p_bias,
&[x, bias, &p_buf],
(total.div_ceil(256), 1, 1),
);
}
fn layernorm(
&self,
src: &Self::Buf,
weight: &Self::Buf,
bias: &Self::Buf,
eps: f32,
rows: usize,
dim: usize,
) -> Self::Buf {
let dst = self
.ctx
.create_storage_rw((rows * dim * 4) as u64, "vit_ln_out");
let params: [u32; 4] = [dim as u32, eps.to_bits(), dim as u32, dim as u32];
let p_buf = self
.ctx
.upload_storage(bytemuck::cast_slice(¶ms), "vit_ln_params");
self.dispatch(
&self.p_layernorm,
&[src, &dst, weight, bias, &p_buf],
(rows as u32, 1, 1),
);
dst
}
fn gelu(&self, x: &Self::Buf, len: usize) {
let params: [u32; 2] = [len as u32, 0];
let p_buf = self
.ctx
.upload_storage(bytemuck::cast_slice(¶ms), "vit_gelu_params");
self.dispatch(
&self.p_gelu,
&[x, &p_buf],
((len as u32).div_ceil(256), 1, 1),
);
}
fn attention(
&self,
q: &Self::Buf,
k: &Self::Buf,
v: &Self::Buf,
tokens: usize,
n_head: usize,
head_dim: usize,
) -> Self::Buf {
let dim = n_head * head_dim;
let out = self
.ctx
.create_storage_rw((tokens * dim * 4) as u64, "vit_attn_out");
let scale = 1.0f32 / (head_dim as f32).sqrt();
let params: [u32; 4] = [
tokens as u32,
n_head as u32,
head_dim as u32,
scale.to_bits(),
];
let p_buf = self
.ctx
.upload_storage(bytemuck::cast_slice(¶ms), "vit_attn_params");
const VIT_ATTN_TILED_Q: u32 = 256;
const VIT_ATTN_TILED_MAX_HEAD_DIM: usize = 64;
if head_dim <= VIT_ATTN_TILED_MAX_HEAD_DIM {
self.dispatch(
&self.p_attn_tiled,
&[q, k, v, &out, &p_buf],
((tokens as u32).div_ceil(VIT_ATTN_TILED_Q), n_head as u32, 1),
);
} else {
self.dispatch(
&self.p_attn,
&[q, k, v, &out, &p_buf],
(tokens as u32, n_head as u32, 1),
);
}
out
}
fn add(&self, dst: &Self::Buf, src: &Self::Buf, len: usize) {
let params: [u32; 2] = [len as u32, 0];
let p_buf = self
.ctx
.upload_storage(bytemuck::cast_slice(¶ms), "vit_add_params");
self.dispatch(
&self.p_add,
&[dst, src, &p_buf],
((len as u32).div_ceil(256), 1, 1),
);
}
fn sync(&self) {
self.ctx.device.poll(wgpu::Maintain::Wait);
}
}
#[cfg(all(feature = "metal", any(target_os = "macos", target_os = "ios")))]
pub enum MetalVitWeight {
Dense(metal::Buffer),
Quant {
buf: metal::Buffer,
dtype: crate::tensor::DType,
},
}
#[cfg(all(feature = "metal", any(target_os = "macos", target_os = "ios")))]
pub struct MetalVitOps {
ctx: crate::backend::metal::MetalContext,
p_linear: metal::ComputePipelineState,
p_gemm_q8_0: metal::ComputePipelineState,
p_gemm_q4_0: metal::ComputePipelineState,
p_bias: metal::ComputePipelineState,
p_layernorm: metal::ComputePipelineState,
p_gelu: metal::ComputePipelineState,
p_attn: metal::ComputePipelineState,
p_attn_mma: metal::ComputePipelineState,
p_attn_mma_hd64: metal::ComputePipelineState,
p_add: metal::ComputePipelineState,
}
#[cfg(all(feature = "metal", any(target_os = "macos", target_os = "ios")))]
impl MetalVitOps {
pub fn new(ctx: crate::backend::metal::MetalContext) -> Result<Self> {
use crate::backend::metal::shaders;
Ok(Self {
p_linear: ctx.create_pipeline(shaders::VIT_LINEAR, "vit_linear")?,
p_gemm_q8_0: ctx.create_pipeline(shaders::GEMM_Q8_0, "gemm_q8_0")?,
p_gemm_q4_0: ctx.create_pipeline(shaders::GEMM_Q4_0, "gemm_q4_0")?,
p_bias: ctx.create_pipeline(shaders::BIAS_ADD, "bias_add")?,
p_layernorm: ctx.create_pipeline(shaders::LAYERNORM_BATCH, "layernorm_batch")?,
p_gelu: ctx.create_pipeline(shaders::GELU, "gelu_inplace")?,
p_attn: ctx.create_pipeline(shaders::VIT_ATTENTION, "vit_attention")?,
p_attn_mma: ctx.create_pipeline(shaders::VIT_ATTENTION_MMA, "vit_attention_mma")?,
p_attn_mma_hd64: ctx
.create_pipeline(shaders::VIT_ATTENTION_MMA, "vit_attention_mma_hd64")?,
p_add: ctx.create_pipeline(shaders::ELEMENTWISE, "add_inplace")?,
ctx,
})
}
fn run(
&self,
pipe: &metal::ComputePipelineState,
bufs: &[&metal::Buffer],
params: &[u8],
grid: metal::MTLSize,
threads: metal::MTLSize,
) {
let cb = self.ctx.queue.new_command_buffer();
let enc = cb.new_compute_command_encoder();
enc.set_compute_pipeline_state(pipe);
for (i, b) in bufs.iter().enumerate() {
enc.set_buffer(i as u64, Some(b), 0);
}
enc.set_bytes(
bufs.len() as u64,
params.len() as u64,
params.as_ptr() as *const _,
);
enc.dispatch_thread_groups(grid, threads);
enc.end_encoding();
cb.commit();
cb.wait_until_completed();
}
#[allow(clippy::too_many_arguments)]
fn run_gemm_quant(
&self,
pipe: &metal::ComputePipelineState,
wq: &metal::Buffer,
x: &metal::Buffer,
y: &metal::Buffer,
tokens: usize,
out_dim: usize,
in_dim: usize,
) {
let params: [u32; 6] = [
out_dim as u32,
in_dim as u32,
tokens as u32,
in_dim as u32,
out_dim as u32,
0,
];
let cb = self.ctx.queue.new_command_buffer();
let enc = cb.new_compute_command_encoder();
enc.set_compute_pipeline_state(pipe);
enc.set_buffer(0, Some(wq), 0);
enc.set_buffer(1, Some(x), 0);
enc.set_buffer(2, Some(y), 0);
enc.set_bytes(
3,
std::mem::size_of_val(¶ms) as u64,
params.as_ptr() as *const _,
);
enc.set_threadgroup_memory_length(0, 8192); enc.dispatch_thread_groups(
metal::MTLSize::new(
(tokens as u64).div_ceil(32),
(out_dim as u64).div_ceil(64),
1,
),
metal::MTLSize::new(128, 1, 1),
);
enc.end_encoding();
cb.commit();
cb.wait_until_completed();
}
#[allow(clippy::too_many_arguments)]
fn run_attn_mma(
&self,
q: &metal::Buffer,
k: &metal::Buffer,
v: &metal::Buffer,
out: &metal::Buffer,
tokens: usize,
n_head: usize,
head_dim: usize,
) {
const Q_PER_TG: u64 = 8;
let scale = 1.0f32 / (head_dim as f32).sqrt();
let params: [u32; 4] = [
tokens as u32,
n_head as u32,
head_dim as u32,
scale.to_bits(),
];
let shmem = 176 * head_dim as u64 + 2144;
let pipe = if head_dim == 64 {
&self.p_attn_mma_hd64
} else {
&self.p_attn_mma
};
let cb = self.ctx.queue.new_command_buffer();
let enc = cb.new_compute_command_encoder();
enc.set_compute_pipeline_state(pipe);
enc.set_buffer(0, Some(q), 0);
enc.set_buffer(1, Some(k), 0);
enc.set_buffer(2, Some(v), 0);
enc.set_buffer(3, Some(out), 0);
enc.set_bytes(
4,
std::mem::size_of_val(¶ms) as u64,
params.as_ptr() as *const _,
);
enc.set_threadgroup_memory_length(0, shmem);
enc.dispatch_thread_groups(
metal::MTLSize::new(n_head as u64 * (tokens as u64).div_ceil(Q_PER_TG), 1, 1),
metal::MTLSize::new(256, 1, 1),
);
enc.end_encoding();
cb.commit();
cb.wait_until_completed();
}
}
#[cfg(all(feature = "metal", any(target_os = "macos", target_os = "ios")))]
impl VitGpuOps for MetalVitOps {
type Buf = metal::Buffer;
type Weight = MetalVitWeight;
fn upload(&self, data: &[f32]) -> Self::Buf {
self.ctx.upload_f32(data)
}
fn download(&self, buf: &Self::Buf, len: usize) -> Vec<f32> {
self.ctx.read_f32(buf, len)
}
fn upload_weight(&self, w: &MmapWeight) -> Self::Weight {
use crate::tensor::DType;
match w.dtype {
DType::Q8_0 | DType::Q4_0 => MetalVitWeight::Quant {
buf: self.ctx.upload_bytes(w.data()),
dtype: w.dtype,
},
_ => MetalVitWeight::Dense(self.ctx.upload_f32(&dequant_weight(w))),
}
}
fn upload_weight_f32(&self, data: &[f32], _out_dim: usize, _in_dim: usize) -> Self::Weight {
MetalVitWeight::Dense(self.ctx.upload_f32(data))
}
fn linear(
&self,
x: &Self::Buf,
w: &Self::Weight,
tokens: usize,
out_dim: usize,
in_dim: usize,
) -> Self::Buf {
let y = self.ctx.create_buffer((tokens * out_dim * 4) as u64);
match w {
MetalVitWeight::Quant { buf, dtype } => {
let pipe = match dtype {
crate::tensor::DType::Q8_0 => &self.p_gemm_q8_0,
_ => &self.p_gemm_q4_0,
};
self.run_gemm_quant(pipe, buf, x, &y, tokens, out_dim, in_dim);
}
MetalVitWeight::Dense(wbuf) => {
let params: [u32; 4] = [out_dim as u32, in_dim as u32, tokens as u32, 0];
self.run(
&self.p_linear,
&[wbuf, x, &y],
bytemuck::cast_slice(¶ms),
metal::MTLSize::new(out_dim as u64, tokens as u64, 1),
metal::MTLSize::new(32, 1, 1),
);
}
}
y
}
fn bias_add(&self, x: &Self::Buf, bias: &Self::Buf, rows: usize, dim: usize) {
let total = (rows * dim) as u32;
let params: [u32; 2] = [total, dim as u32];
self.run(
&self.p_bias,
&[x, bias],
bytemuck::cast_slice(¶ms),
metal::MTLSize::new(total.div_ceil(256) as u64, 1, 1),
metal::MTLSize::new(256, 1, 1),
);
}
fn layernorm(
&self,
src: &Self::Buf,
weight: &Self::Buf,
bias: &Self::Buf,
eps: f32,
rows: usize,
dim: usize,
) -> Self::Buf {
let dst = self.ctx.create_buffer((rows * dim * 4) as u64);
let params: [u32; 4] = [dim as u32, eps.to_bits(), dim as u32, dim as u32];
self.run(
&self.p_layernorm,
&[src, &dst, weight, bias],
bytemuck::cast_slice(¶ms),
metal::MTLSize::new(rows as u64, 1, 1),
metal::MTLSize::new(256, 1, 1),
);
dst
}
fn gelu(&self, x: &Self::Buf, len: usize) {
let params: [u32; 2] = [len as u32, 0];
self.run(
&self.p_gelu,
&[x],
bytemuck::cast_slice(¶ms),
metal::MTLSize::new((len as u64).div_ceil(256), 1, 1),
metal::MTLSize::new(256, 1, 1),
);
}
fn attention(
&self,
q: &Self::Buf,
k: &Self::Buf,
v: &Self::Buf,
tokens: usize,
n_head: usize,
head_dim: usize,
) -> Self::Buf {
let dim = n_head * head_dim;
let out = self.ctx.create_buffer((tokens * dim * 4) as u64);
if head_dim % 8 == 0 && head_dim <= 128 {
self.run_attn_mma(q, k, v, &out, tokens, n_head, head_dim);
} else {
let scale = 1.0f32 / (head_dim as f32).sqrt();
let params: [u32; 4] = [
tokens as u32,
n_head as u32,
head_dim as u32,
scale.to_bits(),
];
self.run(
&self.p_attn,
&[q, k, v, &out],
bytemuck::cast_slice(¶ms),
metal::MTLSize::new(tokens as u64, n_head as u64, 1),
metal::MTLSize::new(256, 1, 1),
);
}
out
}
fn add(&self, dst: &Self::Buf, src: &Self::Buf, len: usize) {
let params: [u32; 2] = [len as u32, 0];
self.run(
&self.p_add,
&[dst, src],
bytemuck::cast_slice(¶ms),
metal::MTLSize::new((len as u64).div_ceil(256), 1, 1),
metal::MTLSize::new(256, 1, 1),
);
}
}
pub trait VisionGpuEncode: Send + Sync {
fn encode_image(&self, pixels: &[f32], grid_w: usize, grid_h: usize) -> Result<Vec<f32>>;
}
#[cfg(feature = "gpu")]
struct WgpuVisionEncoder {
ops: WgpuVitOps,
weights: GpuVitWeights<WgpuVitOps>,
}
#[cfg(feature = "gpu")]
impl VisionGpuEncode for WgpuVisionEncoder {
fn encode_image(&self, pixels: &[f32], grid_w: usize, grid_h: usize) -> Result<Vec<f32>> {
encode_image_gpu(&self.ops, &self.weights, pixels, grid_w, grid_h)
}
}
#[cfg(all(feature = "metal", any(target_os = "macos", target_os = "ios")))]
struct MetalVisionEncoder {
ops: MetalVitOps,
weights: GpuVitWeights<MetalVitOps>,
}
#[cfg(all(feature = "metal", any(target_os = "macos", target_os = "ios")))]
impl VisionGpuEncode for MetalVisionEncoder {
fn encode_image(&self, pixels: &[f32], grid_w: usize, grid_h: usize) -> Result<Vec<f32>> {
encode_image_gpu(&self.ops, &self.weights, pixels, grid_w, grid_h)
}
}
pub fn build_gpu_vision_encoder(
weights: &VisionEncoderWeights,
backend: crate::engine::BackendPreference,
) -> Option<std::sync::Arc<dyn VisionGpuEncode>> {
use crate::engine::BackendPreference as BP;
match backend {
BP::Cpu => None,
BP::Metal => try_metal_vision_encoder(weights),
BP::Gpu => try_wgpu_vision_encoder(weights),
BP::Auto => try_metal_vision_encoder(weights).or_else(|| try_wgpu_vision_encoder(weights)),
}
}
#[cfg(feature = "gpu")]
fn try_wgpu_vision_encoder(
weights: &VisionEncoderWeights,
) -> Option<std::sync::Arc<dyn VisionGpuEncode>> {
let ctx = crate::backend::wgpu::GpuContext::new().ok()?;
let ops = WgpuVitOps::new(ctx).ok()?;
let gpu_w = GpuVitWeights::build(&ops, weights);
tracing::info!("vision encoder: using wgpu GPU backend");
Some(std::sync::Arc::new(WgpuVisionEncoder {
ops,
weights: gpu_w,
}))
}
#[cfg(not(feature = "gpu"))]
fn try_wgpu_vision_encoder(
_weights: &VisionEncoderWeights,
) -> Option<std::sync::Arc<dyn VisionGpuEncode>> {
None
}
#[cfg(all(feature = "metal", any(target_os = "macos", target_os = "ios")))]
fn try_metal_vision_encoder(
weights: &VisionEncoderWeights,
) -> Option<std::sync::Arc<dyn VisionGpuEncode>> {
let ctx = crate::backend::metal::MetalContext::new().ok()?;
let ops = MetalVitOps::new(ctx).ok()?;
let gpu_w = GpuVitWeights::build(&ops, weights);
tracing::info!("vision encoder: using native Metal backend");
Some(std::sync::Arc::new(MetalVisionEncoder {
ops,
weights: gpu_w,
}))
}
#[cfg(not(all(feature = "metal", any(target_os = "macos", target_os = "ios"))))]
fn try_metal_vision_encoder(
_weights: &VisionEncoderWeights,
) -> Option<std::sync::Arc<dyn VisionGpuEncode>> {
None
}
#[cfg(all(
test,
any(
feature = "gpu",
all(feature = "metal", any(target_os = "macos", target_os = "ios"))
)
))]
mod tests {
use super::*;
use crate::model::vision_encoder::{PatchEmbedWeights, ProjectorWeights, VitBlockWeights};
use crate::model::weights::MmapWeight;
use crate::tensor::DType;
fn rnd(n: usize, seed: usize) -> Vec<f32> {
(0..n)
.map(|i| (((i + seed) * 1103515245 + 12345) % 1000) as f32 / 1000.0 - 0.5)
.collect()
}
fn f32_weight(rows: usize, cols: usize, seed: usize) -> MmapWeight {
let data = rnd(rows * cols, seed);
MmapWeight::from_owned_bytes(bytemuck::cast_slice(&data).to_vec(), DType::F32, rows, cols)
}
fn q8_0_weight(rows: usize, cols: usize, seed: usize) -> MmapWeight {
assert_eq!(cols % 32, 0, "Q8_0 cols must be a multiple of 32");
let data = rnd(rows * cols, seed);
let mut bytes = Vec::with_capacity(rows * (cols / 32) * 34);
for block in data.chunks_exact(32) {
let amax = block.iter().fold(0f32, |m, &x| m.max(x.abs()));
let d = amax / 127.0;
let id = if d != 0.0 { 1.0 / d } else { 0.0 };
bytes.extend_from_slice(&half::f16::from_f32(d).to_bits().to_le_bytes());
for &x in block {
bytes.push((x * id).round().clamp(-127.0, 127.0) as i8 as u8);
}
}
MmapWeight::from_owned_bytes(bytes, DType::Q8_0, rows, cols)
}
fn q4_0_weight(rows: usize, cols: usize, seed: usize) -> MmapWeight {
assert_eq!(cols % 32, 0, "Q4_0 cols must be a multiple of 32");
let data = rnd(rows * cols, seed);
let mut bytes = Vec::with_capacity(rows * (cols / 32) * 18);
for block in data.chunks_exact(32) {
let max_abs = block.iter().map(|x| x.abs()).fold(0.0f32, f32::max);
let scale = max_abs / 7.0;
let inv = if scale > 0.0 { 1.0 / scale } else { 0.0 };
bytes.extend_from_slice(&half::f16::from_f32(scale).to_bits().to_le_bytes());
for qi in 0..16 {
let lo = ((block[qi] * inv).round() + 8.0).clamp(0.0, 15.0) as u8;
let hi = ((block[qi + 16] * inv).round() + 8.0).clamp(0.0, 15.0) as u8;
bytes.push(lo | (hi << 4));
}
}
MmapWeight::from_owned_bytes(bytes, DType::Q4_0, rows, cols)
}
fn synth_encoder() -> VisionEncoderWeights {
synth_encoder_quant(None)
}
fn synth_encoder_quant(quant: Option<DType>) -> VisionEncoderWeights {
let lin = |rows: usize, cols: usize, seed: usize| match quant {
Some(DType::Q8_0) => q8_0_weight(rows, cols, seed),
Some(DType::Q4_0) => q4_0_weight(rows, cols, seed),
Some(d) => panic!("synth_encoder_quant: unsupported dtype {d:?}"),
None => f32_weight(rows, cols, seed),
};
let patch_size = 8;
let n_embd = 32;
let n_head = 4;
let n_ff = 64;
let n_layer = 2;
let scale_factor = 2;
let projection_dim = 16;
let intermediate = 64;
let trained_side = 8;
let n_trained_patches = trained_side * trained_side;
let image_size = trained_side * patch_size;
let in_dim = 3 * patch_size * patch_size;
let ppt = (patch_size * scale_factor) * (patch_size * scale_factor);
let cfg = VisionEncoderConfig {
n_layer,
n_embd,
n_ff,
n_head,
eps: 1e-5,
image_size,
patch_size,
n_trained_patches,
projection_dim,
scale_factor,
image_mean: [0.5, 0.5, 0.5],
image_std: [0.5, 0.5, 0.5],
image_min_pixels: ppt,
image_max_pixels: ppt * n_trained_patches,
};
let blocks = (0..n_layer)
.map(|l| {
let s = l * 100 + 1;
VitBlockWeights {
ln1_w: rnd(n_embd, s + 1),
ln1_b: rnd(n_embd, s + 2),
q_w: lin(n_embd, n_embd, s + 3),
q_b: rnd(n_embd, s + 4),
k_w: lin(n_embd, n_embd, s + 5),
k_b: rnd(n_embd, s + 6),
v_w: lin(n_embd, n_embd, s + 7),
v_b: rnd(n_embd, s + 8),
o_w: lin(n_embd, n_embd, s + 9),
o_b: rnd(n_embd, s + 10),
ln2_w: rnd(n_embd, s + 11),
ln2_b: rnd(n_embd, s + 12),
ffn_up_w: lin(n_ff, n_embd, s + 13),
ffn_up_b: rnd(n_ff, s + 14),
ffn_down_w: lin(n_embd, n_ff, s + 15),
ffn_down_b: rnd(n_embd, s + 16),
}
})
.collect();
VisionEncoderWeights {
patch_embed: PatchEmbedWeights {
conv_w: rnd(in_dim * n_embd, 50),
conv_b: rnd(n_embd, 51),
},
position_embed: rnd(n_trained_patches * n_embd, 52),
blocks,
post_ln_w: rnd(n_embd, 53),
post_ln_b: rnd(n_embd, 54),
projector: ProjectorWeights {
mm1_w: lin(intermediate, n_embd * scale_factor * scale_factor, 55),
mm1_b: rnd(intermediate, 56),
mm2_w: lin(projection_dim, intermediate, 57),
mm2_b: rnd(projection_dim, 58),
},
config: cfg,
}
}
fn run_parity<O: VitGpuOps>(ops: &O, enc: &VisionEncoderWeights, atol: f32, rtol: f32) {
let cfg = &enc.config;
let grid_w = 8;
let grid_h = 8;
let target_w = grid_w * cfg.patch_size;
let target_h = grid_h * cfg.patch_size;
let pixels = rnd(3 * target_h * target_w, 999);
let cpu_out = enc.encode_image(&pixels, grid_w, grid_h).unwrap();
let gpu_w = GpuVitWeights::build(ops, enc);
let gpu_out = encode_image_gpu(ops, &gpu_w, &pixels, grid_w, grid_h).unwrap();
assert_eq!(cpu_out.len(), gpu_out.len(), "output length mismatch");
let mut max_diff = 0.0f32;
let mut max_rel = 0.0f32;
for (i, (c, g)) in cpu_out.iter().zip(gpu_out.iter()).enumerate() {
let d = (c - g).abs();
max_diff = max_diff.max(d);
max_rel = max_rel.max(d / (c.abs() + 1e-6));
let limit = atol + rtol * c.abs();
assert!(
d <= limit,
"encode_image parity mismatch at {i}: cpu={c}, gpu={g}, diff={d} \
(limit={limit}, atol={atol}, rtol={rtol})"
);
}
println!(
"ViT encode parity: max_diff={max_diff:.6}, max_rel={max_rel:.6}, {} values",
cpu_out.len()
);
}
#[cfg(feature = "gpu")]
#[test]
fn test_gpu_encode_image_parity() {
let ctx = match crate::backend::wgpu::GpuContext::new() {
Ok(ctx) => ctx,
Err(_) => return, };
run_parity(
&WgpuVitOps::new(ctx).expect("build wgpu vit ops"),
&synth_encoder(),
2e-3,
0.0, );
}
#[cfg(all(feature = "metal", any(target_os = "macos", target_os = "ios")))]
#[test]
fn test_metal_encode_image_parity() {
let ctx = match crate::backend::metal::MetalContext::new() {
Ok(ctx) => ctx,
Err(_) => return, };
run_parity(&MetalVitOps::new(ctx).unwrap(), &synth_encoder(), 2e-3, 0.0);
}
#[cfg(all(feature = "metal", any(target_os = "macos", target_os = "ios")))]
#[test]
fn test_metal_encode_image_parity_q8_0() {
let ctx = match crate::backend::metal::MetalContext::new() {
Ok(ctx) => ctx,
Err(_) => return, };
run_parity(
&MetalVitOps::new(ctx).unwrap(),
&synth_encoder_quant(Some(DType::Q8_0)),
5e-2, 2e-2, );
}
#[cfg(feature = "gpu")]
#[test]
fn test_gpu_encode_image_parity_q8_0() {
let ctx = match crate::backend::wgpu::GpuContext::new() {
Ok(ctx) => ctx,
Err(_) => return, };
run_parity(
&WgpuVitOps::new(ctx).expect("build wgpu vit ops"),
&synth_encoder_quant(Some(DType::Q8_0)),
5e-2,
2e-2, );
}
#[cfg(feature = "gpu")]
#[test]
fn test_gpu_encode_image_parity_q4_0() {
let ctx = match crate::backend::wgpu::GpuContext::new() {
Ok(ctx) => ctx,
Err(_) => return, };
run_parity(
&WgpuVitOps::new(ctx).expect("build wgpu vit ops"),
&synth_encoder_quant(Some(DType::Q4_0)),
2e-1,
4e-2, );
}
}