blog

Hand-Writing a Vision-Language Model, Part 3: The ViT (SigLIP) and the First Bridge to Qwen

Reconsidering the Plan

The original plan was to load the ViT via plain AutoModel.from_pretrained and treat it as a black box. Only the Qwen side was supposed to be reimplemented by hand.

Mid-build, that felt wrong. The whole point of this project is to understand what is happening at every layer, not just the language model side. So the ViT got the exact same treatment as Qwen: hand-written architecture, real pretrained weights loaded in, verified against AutoModel as ground truth, then frozen for use in the glue class.

ViT choice: google/siglip-base-patch16-224 — approximately 93M parameters, hidden size 768, 196 patch tokens. Same "develop small first" reasoning as Qwen3-0.6B versus 4B, now that both RAM and VRAM are confirmed 8GB constraints.

Inspecting the Checkpoint First

Same discipline as Qwen: read the weights before writing any code.

First genuine surprise: the downloaded siglip/model.safetensors is 812MB. A 93M-parameter vision-only model at fp32 should be approximately 372MB. The repo ships the full dual-encoder SiglipModel, both a vision tower and a text tower, since SigLIP is trained as a CLIP-style contrastive image-text model. 408 tensors total. Only 208 (vision_model.*) are needed. The other half (text_model.*, logit_scale, logit_bias) gets ignored entirely.

Second wrinkle: config.json's vision_config only lists patch_size: 16. Everything else is inherited from SiglipVisionConfig's library defaults and is not written to the file at all. Reading the raw JSON directly, as worked cleanly for Qwen, is not enough here. Resolving the effective config requires SiglipVisionConfig.from_pretrained(model_dir), which merges the file's overrides with the class defaults.

Resolved config:

ParameterValue
num_hidden_layers12
hidden_size768
num_attention_heads12 (head_dim 64, standard hidden_size / num_heads)
intermediate_size3072
patch_size / image_size16 / 224 (196 patches, 14×14 grid)
hidden_actgelu_pytorch_tanh
layer_norm_eps1e-6

Key differences from Qwen confirmed from real tensor shapes before writing any code:

MultiHeadSelfAttention: Attention in Its More Traditional Form

class MultiHeadSelfAttention(nn.Module):
    def __init__(self, hidden_size, num_heads, bias=True):
        super().__init__()
        self.num_heads = num_heads
        self.head_dim = hidden_size // num_heads
        self.scale = self.head_dim**-0.5
        self.q_proj = nn.Linear(hidden_size, hidden_size, bias=bias)
        self.k_proj = nn.Linear(hidden_size, hidden_size, bias=bias)
        self.v_proj = nn.Linear(hidden_size, hidden_size, bias=bias)
        self.out_proj = nn.Linear(hidden_size, hidden_size, bias=bias)

    def forward(self, x):
        batch, seq_len, hidden_size = x.shape
        q = self.q_proj(x).view(batch, seq_len, self.num_heads, self.head_dim).transpose(1, 2)
        k = self.k_proj(x).view(batch, seq_len, self.num_heads, self.head_dim).transpose(1, 2)
        v = self.v_proj(x).view(batch, seq_len, self.num_heads, self.head_dim).transpose(1, 2)
        attn_weights = (q @ k.transpose(-2, -1)) * self.scale
        attn_weights = torch.softmax(attn_weights.float(), dim=-1).to(q.dtype)
        attn_output = attn_weights @ v
        attn_output = attn_output.transpose(1, 2).contiguous().view(batch, seq_len, hidden_size)
        return self.out_proj(attn_output)

Writing this after the Qwen attention block made the contrast immediate. This is much closer to the original textbook attention mechanism. Side by side with qwen3/modules.py, here is everything Qwen3 added that is simply absent here.

No GQA. Equal Q/K/V head counts (12 each), no repeat_kv, no KV-head sharing at all.

No RoPE. Position was injected once via the added position embedding at the input. Nothing rotates Q/K per layer.

No causal mask. Every patch attends to every other patch. There is no "future" to hide in an image the way there is in left-to-right generated text. The whole image is visible at once.

No q_norm or k_norm. That per-head RMSNorm was a Qwen3-specific stabilisation trick, not part of the original attention design.

A useful reminder: GQA, RoPE, and QK-norm are all relatively recent refinements. None of them are load-bearing for attention to function at all. They are optimisations for a specific regime (long autoregressive text generation) that a bidirectional image encoder simply does not need.

Verified: output shape and parameter count (2,362,368) both match.

MLP (Plain GELU) and EncoderLayer

class MLP(nn.Module):
    def __init__(self, hidden_size, intermediate_size, bias=True):
        super().__init__()
        self.fc1 = nn.Linear(hidden_size, intermediate_size, bias=bias)
        self.fc2 = nn.Linear(intermediate_size, hidden_size, bias=bias)

    def forward(self, x):
        x = self.fc1(x)
        x = torch.nn.functional.gelu(x, approximate="tanh")
        return self.fc2(x)


class EncoderLayer(nn.Module):
    def __init__(self, hidden_size, num_heads, intermediate_size, layer_norm_eps, bias=True):
        super().__init__()
        self.layer_norm1 = nn.LayerNorm(hidden_size, eps=layer_norm_eps)
        self.self_attn = MultiHeadSelfAttention(hidden_size, num_heads, bias=bias)
        self.layer_norm2 = nn.LayerNorm(hidden_size, eps=layer_norm_eps)
        self.mlp = MLP(hidden_size, intermediate_size, bias=bias)

    def forward(self, x):
        residual = x
        x = self.self_attn(self.layer_norm1(x))
        x = residual + x
        residual = x
        x = self.mlp(self.layer_norm2(x))
        x = residual + x
        return x

Two matrices (fc1 and fc2), not SwiGLU's gated three. Plain Linear to GELU to Linear, no gating branch at all. The EncoderLayer reuses the exact same pre-norm and residual skeleton as Qwen's DecoderLayer, x = residual + sublayer(norm(x)), twice. That pattern turns out to be architecture-agnostic. It carries over unchanged from a causal decoder to a bidirectional encoder.

torch.nn.LayerNorm was used directly rather than hand-writing it. Its mechanics (mean-subtract, divide by std, scale and shift) were already the contrast point when RMSNorm was built in Part 1. Writing it again would not have added new understanding.

Verified: 7,087,872 parameters per layer, matching attention plus MLP plus two LayerNorms.

VisionTransformer: Patches, Position, and the Encoder Stack

class VisionTransformer(nn.Module):
    def __init__(self, hidden_size, num_layers, num_heads, intermediate_size,
                 patch_size, image_size, num_channels=3, layer_norm_eps=1e-6, bias=True):
        super().__init__()
        num_patches = (image_size // patch_size) ** 2
        self.patch_embedding = nn.Conv2d(num_channels, hidden_size,
                                          kernel_size=patch_size, stride=patch_size, bias=bias)
        self.position_embedding = nn.Embedding(num_patches, hidden_size)
        self.layers = nn.ModuleList([
            EncoderLayer(hidden_size, num_heads, intermediate_size, layer_norm_eps, bias=bias)
            for _ in range(num_layers)
        ])
        self.post_layernorm = nn.LayerNorm(hidden_size, eps=layer_norm_eps)

    def forward(self, pixel_values):
        patch_embeds = self.patch_embedding(pixel_values).flatten(2).transpose(1, 2)
        num_patches = patch_embeds.shape[1]
        position_ids = torch.arange(num_patches, device=pixel_values.device)
        x = patch_embeds + self.position_embedding(position_ids)
        for layer in self.layers:
            x = layer(x)
        return self.post_layernorm(x)

The patch embedding is the one genuinely new mechanical idea here versus anything in the Qwen side. A single strided Conv2d with kernel_size == stride == patch_size slices a 224x224 image into a 14x14 grid of non-overlapping 16x16 patches and linearly projects each one to a 768-dimensional vector in one operation. The convolution's stride ensures patches never overlap, so each output position corresponds to exactly one patch.

The from_config and from_pretrained classmethods follow the exact same factory pattern as qwen3.model.Model. Third time reusing that pattern now.

Loaded real weights, ran a forward pass on a (1, 3, 224, 224) dummy tensor. Output shape (1, 196, 768) as expected.

Verification Against SiglipVisionModel

Same style as the Qwen verification: run both models on the same input, compare outputs.

HuggingFace's loader prints a wall of "UNEXPECTED key" warnings when loading SiglipVisionModel from this checkpoint directory. That is just it noting it ignored the text-tower weights, expected given the dual-encoder checkpoint. Not a problem with the hand-written code.

Verified: max absolute difference 5.5e-5, allclose(atol=1e-4) passes.

The Projector: The First Bridge

class Projector(nn.Module):
    def __init__(self, vision_hidden_size, qwen_hidden_size):
        super().__init__()
        self.fc1 = nn.Linear(vision_hidden_size, qwen_hidden_size)
        self.fc2 = nn.Linear(qwen_hidden_size, qwen_hidden_size)

    def forward(self, x):
        x = self.fc1(x)
        x = torch.nn.functional.gelu(x)
        return self.fc2(x)

This is the first piece of code in the entire project that touches both halves of the VLM. It maps SigLIP's 768-dimensional patch embeddings into Qwen3-0.6B's 1024-dimensional embedding space. Everything upstream (ViT) and downstream (Qwen) stays frozen. This small MLP is the only thing that gets trained in stage 1: image-caption alignment.

The architecture is deliberately minimal: two linear layers with a GELU activation between them. The ViT and Qwen stacks both have hundreds of millions of parameters encoding rich representations. The projector's job is not to understand vision or language. It is to learn a mapping between two representation spaces that were trained independently. A small trainable bridge between two large frozen systems.

Verified: output shape (2, 196, 1024), parameter count matches.

What Is Next

Extend qwen3.model.Model.forward to accept inputs_embeds directly, bypassing its own token-embedding lookup. Then the VLM glue class that concatenates projected vision embeddings with Qwen's text embeddings and runs the combined sequence through the frozen Qwen stack.

That is the actual moment the two halves of this project meet.

part 2
Hand-Writing a Vision-Language Model, Part 2: SwiGLU, DecoderLayer, and a RAM Lesson
part 1
Hand-Writing a Vision-Language Model, Part 1: Qwen3-4B's Attention Stack
foundation
Learning NLP From the Ground Up
related read
Going Below PyTorch: Learning Raw CUDA Kernel Development
Python PyTorch safetensors HuggingFace Transformers GTX 1080 Pascal CC 6.1 CUDA Toolkit 12.5 Ubuntu 24.04