← Deep Learning · Architectures · CLIP
Deep Learning · Architectures · Vision–Language Backbones CLIP Architecture Deep Dive

CLIP Architecture from First Principles: Dual-Tower Alignment, Modified ResNet-D (AttentionPool2d), Pre-Norm ViT (ln_pre), Causal [EOS] Text Transformer & Symmetric InfoNCE

OpenAI's CLIP (Contrastive Language–Image Pre-training, Radford et al., ICML 2021) established the canonical dual-tower architecture for modern multimodal representation learning. Instead of fusing image patches and text tokens inside a heavy cross-attention encoder or training an autoregressive decoder to predict captions word-by-word, CLIP trains two completely independent encoders—a Vision Tower $f_\theta(I)$ and a Causal Text Tower $g_\phi(T)$—that meet only at the very last layer through bias-free linear projections $W_i, W_t$ and $\ell_2$ normalization onto a shared unit hypersphere $\mathbb{S}^{d_e - 1}$. This article dissects the complete end-to-end architecture of CLIP: every layer of its Modified ResNet-D and Pre-Norm ViT vision towers, its 12-layer causal BPE text tower pooled at [EOS], its distributed $32{,}768 \times 32{,}768$ symmetric InfoNCE objective, and a complete PyTorch implementation.

Complete architecture diagram of OpenAI CLIP showing Vision Tower (Modified ResNet-D with AttentionPool2d and Pre-Norm ViT with ln_pre), Causal BPE Text Tower pooled at [EOS], bias-free linear projections W_i and W_t, L2 hypersphere normalization, and symmetric N by N InfoNCE loss.
Figure 1: Complete architectural blueprint of OpenAI CLIP across both Vision Tower families (Modified ResNet-D + AttentionPool2d and Pre-Norm ViT + ln_pre), the 12-layer Causal BPE Text Tower pooled at [EOS], and the $N \times N$ symmetric InfoNCE contrastive alignment head.

1. Why a Dual-Tower Architecture? Decoupled $O(N)$ Encoding + $O(N^2 d_e)$ Dot Products

Before CLIP, vision–language models typically fell into two architectural paradigms:

  1. Cross-Modal Fusion Encoders (e.g., ViLBERT, UNITER, LXMERT): Concatenate visual region features and text tokens into a single Transformer with cross-attention layers. Scoring $N$ images against $N$ texts requires $N^2$ full Transformer forward passes—making million-scale zero-shot retrieval or $N = 32{,}768$ contrastive batches computationally impossible.
  2. Autoregressive Caption Generators (e.g., VirTex, ICMLM): Pass visual features into a causal Transformer decoder trained with teacher-forced cross-entropy to predict exact word sequences. As Radford et al. showed in Figure 2 of the CLIP paper, predicting exact words wastes model capacity on phrasing, punctuation, and syntax, requiring **$12\times$ more training compute** than contrastive alignment to reach the same zero-shot ImageNet accuracy.

CLIP's Dual-Tower (Bi-Encoder) Architecture solves both bottlenecks simultaneously:

2. Vision Tower Family A: Modified ResNet-D & AttentionPool2d

OpenAI trained nine CLIP checkpoints in total: five convolutional models (RN50, RN101, RN50x4, RN50x16, RN50x64) and four Vision Transformer models. Rather than using a vanilla He et al. (2015) ResNet, CLIP's `ModifiedResNet` introduces three critical architectural upgrades:

2.1 ResNet-D Deep Stem & Anti-Aliased Blur Pooling

2.2 Replacing Global Average Pooling with AttentionPool2d

The most important modification in CLIP's ResNet family happens at the final stage. A standard ResNet-50 outputs a spatial feature map $\mathbf{X} \in \mathbb{R}^{B \times 2048 \times 7 \times 7}$ (for $224 \times 224$ input) and collapses the $7 \times 7 = 49$ spatial locations via unweighted global average pooling $\bar{\mathbf{x}} = \frac{1}{49}\sum_{r,c} \mathbf{X}_{:, r, c}$. Unweighted spatial averaging treats background sky/grass pixels identically to the foreground object and destroys spatial relationships.

CLIP replaces global average pooling with a Transformer-style QKV cross-attention pooling head called AttentionPool2d:

  1. Flatten the spatial feature map $\mathbf{X} \in \mathbb{R}^{B \times C \times H_f \times W_f}$ into $N_f = H_f W_f$ spatial tokens $\mathbf{X}_{\text{flat}} \in \mathbb{R}^{B \times N_f \times C}$ (for RN50 at $224^2$, $N_f = 7 \times 7 = 49$, $C = 2048$).
  2. Compute the global spatial mean $\bar{\mathbf{x}} = \frac{1}{N_f}\sum_{p=1}^{N_f} \mathbf{X}_{\text{flat}}[:, p, :] \in \mathbb{R}^{B \times 1 \times C}$ and prepend it at index $0$, forming a sequence of $N_f + 1 = 50$ tokens.
  3. Add a learnable 2D positional embedding table $\mathbf{E}_{\text{pos}} \in \mathbb{R}^{(N_f + 1) \times C}$:
    $$\mathbf{Z} = \begin{bmatrix} \bar{\mathbf{x}} \\ \mathbf{X}_{\text{flat}} \end{bmatrix} + \mathbf{E}_{\text{pos}} \in \mathbb{R}^{B \times (N_f + 1) \times C}$$
  4. Apply a single Multi-Head Attention ($\operatorname{MHA}$) layer where the query is only token $0$ ($\mathbf{Q} = \mathbf{Z}[:, 0:1, :] W_Q \in \mathbb{R}^{B \times 1 \times C}$), while keys and values span all $N_f + 1$ tokens ($\mathbf{K} = \mathbf{Z} W_K$, $\mathbf{V} = \mathbf{Z} W_V$). The MHA output projection $W_O \in \mathbb{R}^{C \times d_e}$ projects directly from $C = 2048$ to the shared multimodal embedding dimension $d_e = 1024$!

3. Vision Tower Family B: Pre-Norm Vision Transformer (ViT) with ln_pre

OpenAI's highest-performing CLIP models (ViT-B/32, ViT-B/16, ViT-L/14, and ViT-L/14@336px) replace the ResNet backbone with a Pre-Norm Vision Transformer. (See Chapter 2 of the ViT Series: The Vision Transformer Used in CLIP for the full 6-modification diff against Dosovitskiy et al., 2020.) Given an RGB image $\mathbf{I} \in \mathbb{R}^{B \times 3 \times H \times W}$ and patch size $P \in \{32, 16, 14\}$:

$$\begin{aligned} \mathbf{X}_p &= \operatorname{Flatten}_{H,W}\!\left(\operatorname{Conv2d}_{3 \to d_i,\; k=P,\; s=P,\; \text{bias=False}}(\mathbf{I})\right)^\top \in \mathbb{R}^{B \times N_p \times d_i}, \qquad N_p = \frac{H}{P}\cdot\frac{W}{P} \\[4pt] \mathbf{Z}_0 &= \operatorname{LN}_{\text{pre}}\!\left(\begin{bmatrix} \mathbf{x}_{\text{cls}} \\ \mathbf{X}_p \end{bmatrix} + \mathbf{E}_{\text{pos}}^{\text{vis}}\right) \in \mathbb{R}^{B \times (N_p + 1) \times d_i} \\[4pt] \mathbf{Z}'_\ell &= \mathbf{Z}_{\ell-1} + \operatorname{MSA}_\ell\!\left(\operatorname{LN}_1^{(\ell)}(\mathbf{Z}_{\ell-1})\right), \qquad \ell = 1, \dots, L_i \\[4pt] \mathbf{Z}_\ell &= \mathbf{Z}'_\ell + \operatorname{MLP}_{\ell,\text{QuickGELU}}\!\left(\operatorname{LN}_2^{(\ell)}(\mathbf{Z}'_\ell)\right), \qquad \ell = 1, \dots, L_i \\[4pt] \mathbf{v}_I &= \operatorname{LN}_{\text{post}}\!\left(\mathbf{Z}_{L_i}[:, 0, :]\right) W_i \in \mathbb{R}^{B \times d_e}, \qquad \hat{\mathbf{i}} = \frac{\mathbf{v}_I}{\|\mathbf{v}_I\|_2} \in \mathbb{S}^{d_e - 1} \end{aligned}$$

4. The Text Tower: 12-Layer Causal BPE Transformer Pooled at [EOS]

Regardless of whether the Vision Tower is a Modified ResNet or a ViT, CLIP pairs it with a 12-layer Pre-Norm Causal Transformer (`Transformer(width=d_t, layers=12, heads=d_t//64)`) modeled after GPT-2 (Radford et al., 2019):

  1. Lowercase Byte-Pair Encoding (BPE) Tokenizer: Uses a vocabulary of $49{,}152$ subword merges plus two special bracket tokens:
    • [SOS] (Start of Sequence) = token ID $49{,}406$
    • [EOS] (End of Sequence) = token ID $49{,}407$
    • Zero-padding = token ID $0$ up to a fixed context length $L_{\text{ctx}} = 77$.
    Thus total embedding table size is `vocab_size = 49408`, and every tokenized prompt $\mathbf{T} \in \{0, \dots, 49407\}^{B \times 77}$ starts with $49406$, contains up to $75$ BPE subwords, ends with $49407$, and is right-padded with $0$s.
  2. Causal Self-Attention Mask ($77 \times 77$): Even though the Text Tower is an encoder producing a single vector per caption, OpenAI applies an additive upper-triangular causal mask $M_{uv} = 0$ if $u \ge v$ else $-\infty$ inside every self-attention block:
    $$\mathbf{H}_0 = W_{\text{tok}}[\mathbf{T}] + \mathbf{E}_{\text{pos}}^{\text{text}} \in \mathbb{R}^{B \times 77 \times d_t}, \qquad \mathbf{H}_\ell = \operatorname{CausalBlock}_\ell(\mathbf{H}_{\ell-1}), \quad \ell = 1, \dots, 12$$
    Why use a causal mask instead of a BERT-style bidirectional mask? First, causal masking prevents information leakage from padding positions after `[EOS]` without requiring a per-sample padding mask tensor. Second, it makes the text tower architecture directly compatible with autoregressive language modeling initializations and extensions (such as CoCa).
  3. Pooling at `[EOS]` via `text.argmax(dim=-1)`: Because of the causal mask, token $0$ (`[SOS]`) only sees itself, whereas the `[EOS]` token at index $e_b$ is the first token that has attended to the entire prompt prefix $(0, 1, \dots, e_b)$! Furthermore, because `[EOS]` has the largest token ID ($49{,}407$) in the entire vocabulary, OpenAI's implementation finds the exact position of `[EOS]` for every sequence in the batch with a single `text.argmax(dim=-1)` call:
    $$\mathbf{h}_T^{(b)} = \operatorname{LN}_{\text{final}}(\mathbf{H}_{12})\!\left[b,\; \arg\max_{t} T_{b, t},\; :\right] \in \mathbb{R}^{d_t}, \qquad \mathbf{u}_T = \mathbf{h}_T W_t \in \mathbb{R}^{B \times d_e}, \qquad \hat{\mathbf{t}} = \frac{\mathbf{u}_T}{\|\mathbf{u}_T\|_2} \in \mathbb{S}^{d_e - 1}$$

5. Unit-Hypersphere Geometry, Learnable Temperature $\exp(t)$ & Distributed Symmetric InfoNCE

Once a minibatch of $N$ image–text pairs is encoded and $\ell_2$-normalized into $\hat{\mathbf{I}} \in \mathbb{R}^{N \times d_e}$ and $\hat{\mathbf{T}} \in \mathbb{R}^{N \times d_e}$ (where every row has $\|\hat{\mathbf{i}}_m\|_2 = \|\hat{\mathbf{t}}_n\|_2 = 1$), their matrix product $\hat{\mathbf{I}} \hat{\mathbf{T}}^\top \in [-1, 1]^{N \times N}$ contains all $N^2$ pairwise cosine similarities.

Because raw cosine similarities lie in $[-1, 1]$, applying a standard softmax $\frac{\exp(s_{mm})}{\sum_n \exp(s_{mn})}$ with temperature $\tau = 1$ cannot produce sharp probabilities: even if the true match has $s_{mm} = 1.0$ and all $N-1 = 32{,}767$ negatives have $s_{mn} = 0.0$, the positive probability would be at most $\frac{e^1}{e^1 + 32767 \cdot e^0} \approx 0.000083$!

Therefore, CLIP multiplies the cosine similarities by a learnable inverse temperature scalar $\exp(t) = 1/\tau$, where $t = \texttt{logit\_scale} \in \mathbb{R}$ is parameterized in log-space, initialized to $\ln(1/0.07) \approx 2.6593$ ($\exp(t) \approx 14.29$), and clamped in-place so $\exp(t) \le 100.0$ ($t \le \ln(100) \approx 4.6052$):

$$L_{mn} = \min\!\left(\exp(t), 100\right) \cdot \langle \hat{\mathbf{i}}_m, \hat{\mathbf{t}}_n \rangle, \qquad \mathcal{L}_{\text{CLIP}} = \frac{1}{2N}\sum_{m=1}^{N} \left[ -\ln \frac{\exp(L_{mm})}{\sum_{n=1}^{N} \exp(L_{mn})} - \ln \frac{\exp(L_{mm})}{\sum_{k=1}^{N} \exp(L_{km})} \right]$$

How Distributed Contrastive `AllGather` Works Across $D$ GPUs

A single GPU cannot hold the intermediate activations for $N = 32{,}768$ images and texts simultaneously. Instead, each of the $D$ GPUs (e.g., $D = 256$ GPUs with local batch size $B_{\text{local}} = 128$) runs the Vision Tower and Text Tower forward pass on its local shard $(\hat{\mathbf{I}}_d, \hat{\mathbf{T}}_d) \in \mathbb{R}^{128 \times d_e}$, then performs an `all_gather` of the compact $d_e$-dimensional unit vectors across all $D$ ranks to assemble the global $\hat{\mathbf{I}}_{\text{global}}, \hat{\mathbf{T}}_{\text{global}} \in \mathbb{R}^{32768 \times d_e}$. Crucially, because `torch.distributed.all_gather` does not propagate gradients by default, rank $d$ overwrites slice $d$ of the gathered tensor with its local tensor (`gathered_I[rank] = local_I`) so autograd flows back into rank $d$'s local Vision and Text encoders!

6. Complete Architectural Table of All 9 Official OpenAI CLIP Models

Architectural parameters across all five Modified ResNet and four Vision Transformer checkpoints in OpenAI CLIP (Radford et al., 2021).
Model Name Input Resolution Vision Backbone ($L_i$, Width $d_i$) Text Tower ($L_t = 12$, Width $d_t$, Heads) Joint Dim $d_e$ Zero-Shot ImageNet Top-1
CLIP RN50 $224 \times 224$ ResNet-50D ($[3,4,6,3]$, $d_i=2048$, $7\times 7$ AttentionPool2d) $L_t=12, d_t=512, H_t=8$ $1024$ $59.6\%$
CLIP RN101 $224 \times 224$ ResNet-101D ($[3,4,23,3]$, $d_i=2048$, $7\times 7$ AttentionPool2d) $L_t=12, d_t=512, H_t=8$ $512$ $62.2\%$
CLIP RN50x4 $288 \times 288$ Scaled ResNet-D ($[4,6,10,6]$, $d_i=2560$, $9\times 9$ AttentionPool2d) $L_t=12, d_t=640, H_t=10$ $640$ $66.2\%$
CLIP RN50x16 $384 \times 384$ Scaled ResNet-D ($[6,8,18,8]$, $d_i=3072$, $12\times 12$ AttentionPool2d) $L_t=12, d_t=768, H_t=12$ $768$ $70.7\%$
CLIP RN50x64 $448 \times 448$ Scaled ResNet-D ($[3,15,36,10]$, $d_i=4096$, $14\times 14$ AttentionPool2d) $L_t=12, d_t=1024, H_t=16$ $1024$ $73.6\%$
CLIP ViT-B/32 $224 \times 224$ Pre-Norm ViT ($P=32$, $L_i=12, d_i=768, H_i=12$, $50$ tokens) $L_t=12, d_t=512, H_t=8$ $512$ $63.2\%$
CLIP ViT-B/16 $224 \times 224$ Pre-Norm ViT ($P=16$, $L_i=12, d_i=768, H_i=12$, $197$ tokens) $L_t=12, d_t=512, H_t=8$ $512$ $68.6\%$
CLIP ViT-L/14 $224 \times 224$ Pre-Norm ViT ($P=14$, $L_i=24, d_i=1024, H_i=16$, $257$ tokens) $L_t=12, d_t=768, H_t=12$ $768$ $75.5\%$
CLIP ViT-L/14@336px $336 \times 336$ Pre-Norm ViT ($P=14$, $L_i=24, d_i=1024, H_i=16$, $577$ tokens) $L_t=12, d_t=768, H_t=12$ $768$ $76.2\%$

7. Complete End-to-End PyTorch Implementation of OpenAI CLIP

import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F

class AttentionPool2d(nn.Module):
    """Multi-head QKV cross-attention pooling head used in CLIP's ModifiedResNet."""
    def __init__(self, spacial_dim: int, embed_dim: int, num_heads: int, output_dim: int = None):
        super().__init__()
        self.positional_embedding = nn.Parameter(
            torch.randn(spacial_dim ** 2 + 1, embed_dim) / embed_dim ** 0.5
        )
        self.k_proj = nn.Linear(embed_dim, embed_dim)
        self.q_proj = nn.Linear(embed_dim, embed_dim)
        self.v_proj = nn.Linear(embed_dim, embed_dim)
        self.c_proj = nn.Linear(embed_dim, output_dim or embed_dim)
        self.num_heads = num_heads

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # x: [B, C, H, W] -> flatten spatial to [HW, B, C]
        x = x.flatten(start_dim=2).permute(2, 0, 1)
        # Prepend global spatial mean at index 0 -> [HW + 1, B, C]
        x = torch.cat([x.mean(dim=0, keepdim=True), x], dim=0)
        x = x + self.positional_embedding[:, None, :].to(x.dtype)
        # Query is ONLY token 0 (the global mean); keys & values are all HW + 1 tokens
        x, _ = F.multi_head_attention_forward(
            query=x[:1], key=x, value=x,
            embed_dim_to_check=x.shape[-1],
            num_heads=self.num_heads,
            q_proj_weight=self.q_proj.weight,
            k_proj_weight=self.k_proj.weight,
            v_proj_weight=self.v_proj.weight,
            in_proj_weight=None,
            in_proj_bias=torch.cat([self.q_proj.bias, self.k_proj.bias, self.v_proj.bias]),
            bias_k=None, bias_v=None, add_zero_attn=False,
            dropout_p=0.0,
            out_proj_weight=self.c_proj.weight,
            out_proj_bias=self.c_proj.bias,
            use_separate_proj_weight=True,
            training=self.training,
            need_weights=False,
        )
        return x.squeeze(0)  # [B, output_dim]

class CLIPDualTower(nn.Module):
    """
    End-to-end CLIP Dual-Tower wrapper combining Vision Tower, Causal Text Tower,
    learnable clamped logit_scale, and symmetric InfoNCE loss.
    """
    def __init__(
        self,
        vision_encoder: nn.Module,
        text_transformer: nn.Module,
        vocab_size: int = 49408,
        context_length: int = 77,
        transformer_width: int = 512,
        embed_dim: int = 512,
    ):
        super().__init__()
        self.visual = vision_encoder
        self.transformer = text_transformer
        self.context_length = context_length

        self.token_embedding = nn.Embedding(vocab_size, transformer_width)
        self.positional_embedding = nn.Parameter(torch.empty(context_length, transformer_width))
        self.ln_final = nn.LayerNorm(transformer_width)
        self.text_projection = nn.Parameter(torch.empty(transformer_width, embed_dim))

        # Initialized at ln(1 / 0.07) ≈ 2.65926
        self.logit_scale = nn.Parameter(torch.ones([]) * np.log(1 / 0.07))

    def encode_image(self, image: torch.Tensor) -> torch.Tensor:
        x = self.visual(image)
        return F.normalize(x, dim=-1)

    def encode_text(self, text: torch.Tensor) -> torch.Tensor:
        # text: [B, 77] where [EOS]=49407 is always the maximum token ID in each row
        x = self.token_embedding(text) + self.positional_embedding
        x = self.transformer(x)  # 12 causal-masked Pre-Norm blocks
        x = self.ln_final(x)

        # Select [EOS] token features via text.argmax(dim=-1) and project to d_e
        eos_features = x[torch.arange(x.shape[0], device=x.device), text.argmax(dim=-1)]
        text_embeds = eos_features @ self.text_projection
        return F.normalize(text_embeds, dim=-1)

    def forward(self, image: torch.Tensor, text: torch.Tensor):
        image_features = self.encode_image(image)
        text_features = self.encode_text(text)

        # Clamp exp(t) <= 100.0 for numerical stability
        logit_scale = self.logit_scale.exp().clamp(max=100.0)
        logits_per_image = logit_scale * (image_features @ text_features.t())
        logits_per_text = logits_per_image.t()

        labels = torch.arange(image.shape[0], device=image.device)
        loss_i = F.cross_entropy(logits_per_image, labels)
        loss_t = F.cross_entropy(logits_per_text, labels)
        loss = 0.5 * (loss_i + loss_t)
        return logits_per_image, logits_per_text, loss