File size: 8,488 Bytes
b269527
1f441a8
bee1996
 
 
 
 
b269527
bee1996
 
 
8a432f7
bee1996
8a432f7
bee1996
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8a432f7
bee1996
 
 
 
 
 
 
 
 
 
 
8a432f7
bee1996
 
 
 
 
 
 
 
8a432f7
 
 
bee1996
5dcf26e
 
 
bee1996
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f425156
 
 
 
bee1996
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f425156
 
bee1996
 
 
 
f425156
 
 
bee1996
 
 
8a432f7
bee1996
 
 
 
 
 
 
 
 
 
 
 
 
f425156
 
 
 
 
 
 
bee1996
 
 
 
 
 
 
 
 
 
 
 
 
 
1f441a8
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
---
license: apache-2.0
library_name: diffusers
tags:
  - vae
  - autoencoder
  - latent-diffusion
---

# Primal VAE

Primal VAE is a VAE that only does 2x spatial down to 1 channel (or 2). If you
patch 4 the latent, you end up with 8x at 16 channels, the same as most modern VAEs, without the need for deep
compression. The DiT does the patching, like it already does, and the VAE stays small.

The latent is trained to keep its norm in line with noise, so there is no scaling factor, no shift factor, and
no latent normalization to carry around. Encode, patch 4, diffuse.

| model | latent | patch 4 | same shape as |
|---|---|---|---|
| `2x1` | 1 ch @ 2x | 16 ch @ 8x | Flux.1 VAE (16 ch @ 8x) |
| `2x2` | 2 ch @ 2x | 32 ch @ 8x | Flux.2 VAE (32 ch @ 8x) |

Both are ~80M parameters. Image sides need to be multiples of 16.

## Usage

```python
import torch
from diffusers import AutoModel

vae = AutoModel.from_pretrained("ostris/primal_vae", variant="2x1", trust_remote_code=True, torch_dtype=torch.bfloat16).to("cuda")

x = ...                      # [B, 3, H, W] in [-1, 1], H and W multiples of 16
z = vae.encode(x)            # [B, 1, H/2, W/2], ~N(0, 1). No .latent_dist, no sampling.
x_hat = vae.decode(z)        # [B, 3, H, W]
```

### Matching the Flux.1 latent (2x1)

```python
vae = AutoModel.from_pretrained("ostris/primal_vae", variant="2x1", trust_remote_code=True, torch_dtype=torch.bfloat16).to("cuda")

tokens = vae.patchify(vae.encode(x), compression=8)   # [B, 16, H/8, W/8]: the Flux.1 VAE latent shape
# drop `tokens` in wherever the Flux.1 VAE latent went. No scaling_factor / shift_factor, it is already unit normal.
# e.g. FluxPipeline._pack_latents(tokens, B, 16, H // 8, W // 8) -> [B, (H/16) * (W/16), 64]

x_hat = vae.decode(vae.unpatchify(tokens))
```

### Matching the Flux.2 latent (2x2)

```python
vae = AutoModel.from_pretrained("ostris/primal_vae", variant="2x2", trust_remote_code=True, torch_dtype=torch.bfloat16).to("cuda")

tokens = vae.patchify(vae.encode(x), compression=8)   # [B, 32, H/8, W/8]: the Flux.2 VAE latent shape
# drop `tokens` in where the Flux.2 VAE latent went, before its own 2x2 patchify to 128-channel tokens.
# Skip the Flux.2 batch-norm latent normalization, it is already unit normal per image and channel.

x_hat = vae.decode(vae.unpatchify(tokens))
```

`variant` picks the weight file, `primal_vae_<variant>.safetensors`. The architecture is read from the tensor
shapes, so `weights_name="my_finetune.safetensors"` loads any Primal VAE checkpoint out of ai-toolkit as-is.
`patchify` / `unpatchify` take the total `compression` you want and are just `pixel_unshuffle` /
`pixel_shuffle` by `compression / 2`, so `compression=8` is a 4x4 patch. `vae.token_channels(8)` and
`vae.image_multiple` give you the numbers above.

To backprop through `decode` (a pixel-space loss on a diffusion model's prediction), `vae.enable_gradient_checkpointing()`
checkpoints every residual block of both U-Nets; inference under `torch.no_grad()` is unaffected.

## Architecture

Both models are the same network, they only differ in output channels.

- Input is `pixel_unshuffle(2)`: RGB becomes 12 channels at 1/2 resolution. Everything runs at latent
  resolution or below, so the latent is a spatially honest map of the image.
- Encoder and decoder are mirrored conv U-Nets, 4 levels at 1/2, 1/4, 1/8 and 1/16 of the image, widths
  96 / 192 / 384 / 384, residual block depths 1 / 1 / 2 / 4 down and 1 / 1 / 2 up. Downs and ups are
  `pixel_unshuffle` / `pixel_shuffle` plus a 3x3 conv. Residual blocks cycle dilations 1, 2, 4.
- Normalization is RMSNorm over channels at each position. No spatial statistics, no attention, so tiles
  encode and decode the same as the full image given some overlap.
- The encoder ends in a plain 3x3 conv to 1 or 2 channels. No posterior, no KL, no quant convs.

## Keeping the latent in line with noise

A diffusion model is trained against Gaussian noise, so the latent should look like Gaussian noise. Instead of a
KL term and a scaling factor someone measures after the fact, I put the statistics directly in the loss on the
encoder output.

- **Moment loss**: `mean^2 + (std - 1)^2`, weight 0.1, taken per image and per channel, so every single image
  comes out mean 0 / std 1 in each channel, not just the dataset on average. The 2x1 spent its first 53k steps
  with this pooled over the batch and switched to per image for the last 9k.
- **Gaussian order-statistic loss**: sort the latent values of each image and channel and MSE them
  against the expected order statistics of a standard normal of the same size (Blom's approximation). This
  pins the whole distribution shape, not just the first two moments, so a flat gray image still encodes to
  something that looks like noise instead of a constant.

## Examples

Each row is input, reconstruction, latent, |error| × 4. Under each Primal VAE row is the Flux VAE with the
same latent layout, on the same image. Latents are shown at ±3σ. The 1-channel latent is gray; the 2-channel
one has channel 0 on red and channel 1 on blue. The Flux latents show their first 3 of 16 / 32 channels after
the scaling the DiT actually sees (Flux.1: shift and scale factor; Flux.2: batch-norm statistics). Mean and std
are over the full latent tensor.

![2x1 vs Flux.1](assets/compare_2x1.svg)

![2x2 vs Flux.2](assets/compare_2x2.svg)

PSNR on these three 512 px images (dB):

| image | Primal 2x1 | Flux.1 VAE | Primal 2x2 | Flux.2 VAE |
|---|---|---|---|---|
| img01 (people, studio) | 32.1 | 35.7 | 37.5 | 37.2 |
| img02 (picnic, foliage) | 22.0 | 24.2 | 27.1 | 26.5 |
| img03 (UI screenshot) | 21.3 | 24.7 | 27.4 | 25.9 |

The 2x2 matches or beats the Flux.2 VAE here at the same information budget (both are 32 values per 8×8 pixel
block). Three images and PSNR alone are not a benchmark, and a KL VAE pays a reconstruction tax for its posterior
noise that a deterministic one does not. The 2x1 trails the Flux.1 VAE on textured content. One channel at 2x
is a 12:1 reduction with no slack, and it has well under half the training steps of the 2x2 (62k vs 147k). Its
per-image latent std still drifts (1.07 to 1.21 here) because it only got the per-image normalization for its
last 9k steps. The 2x2, trained with it throughout, holds std within a few percent of 1.

## Training

Trained in [ai-toolkit](https://github.com/ostris/ai-toolkit) with a custom extension on
aspect-bucketed images. The weight files here are the training checkpoints, unchanged.

Reconstruction is MAE (0.5) and LPIPS (1.0), plus an L1 on the log-magnitude 2D spectrum (0.1) that pushes
high-frequency energy without demanding exact phase.

The tricks that should make the latent space easier to diffuse:

- **Latent flips and rotations (from EQ-VAE).** On 50% of steps I flip or rotate the latent by 90 degrees
  and decode it against the same flipped or rotated image. The latent has to be a picture of the image, not an
  arbitrary code. I tried downscale equivariance too and dropped it, it slowed down color learning.
- **Noise-perturbed decode.** On 20% of steps the decoder gets `z + sigma * noise`, `sigma` drawn per image
  from `U(0, 0.5)`, and still has to reconstruct the clean image. The decoder learns to tolerate diffusion
  model error and the latent gets smooth: nearby codes decode to nearby images.
- **Solid colors**: 5% of images are swapped for a random flat color. Flat regions are the hard case for a
  per-image normalized latent.
- **Critic** (`2x1` only, last 9k steps): a frozen SigLIP2 NaFlex ViT gives patch features from layers 4, 8
  and 12 of the real and reconstructed image at native resolution. Small spectral-normalized conv heads on
  those features train with a hinge loss plus R1, and the decoder gets the generator term at weight 0.1,
  ramped in over the first 1000 steps. It sharpens the texture that MAE and LPIPS leave soft. The 2x2 was
  trained without a critic, it seemed to converge fine without one.

Every step is one encode and one decode. On a flip/rotate step the transformed pair replaces the clean pair
instead of adding a second decoder pass.

## Files

```
primal_vae_2x1.safetensors   1 ch @ 2x (fp32)
primal_vae_2x2.safetensors   2 ch @ 2x (fp32)
modeling_primal_vae.py       the diffusers model (remote code)
config.json                  points AutoModel at it
```

Load with `torch_dtype=torch.bfloat16` for inference, it costs ~0.05 dB.

## License

Apache 2.0, Copyright 2026 Ostris, LLC. See `LICENSE`.