~/satyajit

Sana: the autoencoder does more of the work than the linear attention

mdjsonmcp

2026-08-26 · 24 min · diffusion · linear-attention · autoencoders · distillation · nvidia · explainer

Sana is usually introduced as the linear-attention image model. The paper is titled Efficient High-Resolution Image Synthesis with Linear Diffusion Transformer; the repo's own summary leads with "Linear Attention: Replace vanilla attention in DiT with linear attention for efficiency at high resolutions"; the headline is 20× smaller and 100× faster than FLUX-12B.

I cloned NVlabs/Sana at commit 5498e5b and read the parts that would have to be true for that story to hold: diffusion/model/nets/sana_blocks.py where the linear attention lives, the YAML configs that define every released checkpoint, the DC-AE model zoo, and train_scripts/train_scm_ladd.py where Sana-Sprint gets distilled. Then I rebuilt the paper's own FLOP accounting from those configs.

The linear attention is real and it is nicely implemented. It is also the second-largest thing Sana does — at 512px, at 1024px, at 2048px and, by a narrowing margin, at 4096px. The largest is an autoencoder that throws away four times more tokens than the one FLUX and SD3 use, before the transformer has started.

Two-panel Sana architecture figure. Left: a Complex Human Instruction block feeding a Small LLM, alongside a Time Embedding, with a Positional Embedding box crossed out in red; an image passes through a frozen Deep Compression AutoEncoder at 32× into an N-block Linear DiT whose block is Linear Attn, Cross Attn, Mix-FFN with a residual add. Right: the Linear Attention module — Q, K and V each pass a Linear layer, Q and K through ReLU, then a d×d MatMul of K and V followed by an n×d MatMul with Q and a Scale, labelled cost O(n); and the Mix-FFN — 1×1 ConvLayer, 3×3 ConvLayer, ReLU gate multiplied back in, 1×1 ConvLayer.
Sana's pipeline and its Linear DiT block. Note the crossed-out positional embedding, and that the d×d matmul happens before the query is applied — that reordering is the whole O(n) trick. (Sana, arXiv:2410.10629, Figure 5.)
What it isNVlabs/Sana at 5498e5b — an efficiency-first family of image and video diffusion models with full training + inference code
Actually in this tree todaySana 1.0, Sana-1.5, Sana-Sprint, SANA-Video / LongSANA, SANA-WM, SANA-Streaming, Sol-RL configs
Not in this treeSol-Engine — the README's newest headline (3.95× on GB200) lives on a separate sol-engine branch
Image DiTSanaMS_600M_P1_D28 = 28 layers × 1152 · SanaMS_1600M_P1_D20 = 20 × 2240 · SanaMS_4800M_P1_D60 = 60 × 2240
Self-attentionLiteLA — ReLU-kernel linear attention, 70 heads of dim 32 at hidden 2240
Cross-attentionsoftmax, xformers memory_efficient_attention, 20 heads of dim 112
TokenizerDC-AE-F32C32 — 32× spatial downsample, 32 latent channels, DiT patch size 1
Text encoderGemma-2-2B-IT with the LM head stripped (.get_decoder()), caption_channels: 2304
Positional encodingnone at 512/1024px (use_pe: false); sincos with interpolation at 2K/4K
Samplingflow matching, flow_shift: 3.0, Flow-DPM-Solver, 20 steps · Sprint: TrigFlow + sCM + LADD, 1–4 steps
Licencecode Apache-2.0; released weights also Apache-2.0 — but the bundled text encoder is Gemma
StackPython ≥ 3.11, torch 2.9.1 / cu128, xformers 0.0.33

Two levers, and only one of them is the famous one

Every latent diffusion model has a token count it never gets to argue with. An autoencoder with downsample factor FF maps an H×WH \times W image to HF×WF\frac{H}{F} \times \frac{W}{F} latents, and the DiT then groups those into P×PP \times P patches, so the transformer sees

N=(HFP)×(WFP)N = \left(\frac{H}{F \cdot P}\right) \times \left(\frac{W}{F \cdot P}\right)

tokens. PixArt, SD3 and FLUX all use F=8F = 8 with P=2P = 2: an effective stride of 16, so a 1024px image is 4096 tokens. Sana's configs say something different:

# configs/sana_config/1024ms/Sana_1600M_img1024.yaml
model:
  model: SanaMS_1600M_P1_D20   # patch_size = 1
vae:
  vae_type: AutoencoderDC
  vae_pretrained: mit-han-lab/dc-ae-f32c32-sana-1.1-diffusers
  vae_latent_dim: 32
  vae_downsample_rate: 32

F=32F = 32, P=1P = 1: an effective stride of 32, so the same image is 1024 tokens. That is four times fewer before any attention runs, and the paper is explicit about why it prefers spending the compression in the autoencoder rather than the patchifier — the AE should "take full responsibility for compression, allowing the latent diffusion models to focus solely on denoising."

The second lever is what happens to those NN tokens. Softmax self-attention costs O(N2)O(N^2); Sana's LiteLA costs O(N)O(N). The two levers multiply, and the interesting part is that they multiply unevenly — drag the resolution and watch which one is carrying the load:

Sana-0.6B DiT · one forward pass at 1024×1024linear · 1.12 TFLOP · 5.8× cheaper than the baseline
Four token-and-attention budgets for a Sana-0.6B DiT at 1024 by 1024. AE-F8 · patch 2 · softmax: 4,096 tokens, 6.48 TFLOP; AE-F8 · patch 2 · linear: 4,096 tokens, 4.34 TFLOP; DC-AE-F32 · patch 1 · softmax: 1,024 tokens, 1.25 TFLOP; DC-AE-F32 · patch 1 · linear: 1,024 tokens, 1.12 TFLOP. The selected configuration is DC-AE-F32 · patch 1 · linear, 5.8 times cheaper than the AE-F8 softmax baseline.configurationself-attentionprojections + Mix-FFN + cross-attentionAE-F8 · patch 2 · softmax4,096 tokens · the SD3 / PixArt / FLUX shape6.48 TFLOPAE-F8 · patch 2 · linear4,096 tokens · linear attention alone4.34 TFLOP1.5× cheaperDC-AE-F32 · patch 1 · softmax1,024 tokens · deep compression alone1.25 TFLOP5.2× cheaperDC-AE-F32 · patch 1 · linear1,024 tokens · Sana — both levers1.12 TFLOP5.8× cheaper
tokens into the DiT
1,024
self-attention share
0.4%
DiT forward
1.12 TFLOP
vs AE-F8 + softmax
5.8×

Click a row. The token count is set entirely upstream of the transformer: N = (px / (f·p))², where f is the autoencoder’s downsample factor and p the DiT’s patch size. An AE-F8 with patch 2 has an effective stride of 16; Sana’s DC-AE-F32 with patch 1 has 32, so it sees four times fewer tokens at every resolution.

Now watch the two levers separate. Compare row 3 against row 1 and you get what the autoencoder is worth on its own; row 2 against row 1 is what linear attention is worth on its own. At 512px that is 3.9× against 1.1×. At 1024px, 5.2× against 1.5×. Even at 4096px the autoencoder is still ahead, 12× against 9× — the quadratic term only overtakes it somewhere past 5000px. And row 4 is 36× at 4K, because compression shrinks N and linear attention shrinks the exponent on it.

FLOPs are the DiT forward pass only, counted from the shapes in diffusion/model/nets/sana_multi_scale.py — no VAE, no text encoder. Against the paper’s own Table 8 the four comparable rows land within 4.2%.

The cleanest way to read it is marginally: given the other lever, what does each one still buy? The autoencoder always buys the same thing — 4× fewer tokens, so asymptotically a flat — and linear attention buys 1.02× at 512px, 1.12× at 1024px, 1.50× at 2048px and 3.01× at 4096px. It does not overtake the autoencoder until about 5000px, roughly 25,000 tokens. Every resolution Sana ships at is on the wrong side of that line.

The combined numbers are still large, because they compound: 5.8× at 1024px and 36× at 4096px against an f8/patch-2 softmax DiT of identical width and depth. But the split matters if you are deciding what to copy.

This is not my reinterpretation of the paper. It is the paper's own Table 8, which ablates the block design at 1024px on an A100:

BlocksAEMACs (T)Throughput (/s)Latency (ms)
FullAttn & FFNF8C4P26.480.492250
  + LinearAttnF8C4P24.300.521931
    + MixFFNF8C4P24.190.462425
      + Kernel FusionF8C4P24.190.532139
LinearAttn & MixFFNF32C32P11.081.75826
  + Kernel FusionF32C32P11.082.06748

Read the latency column. Swapping softmax for linear attention takes 2250 ms to 1931 ms. Adding Mix-FFN — which the paper needs, because linear attention alone converges badly — takes it back up to 2425 ms, slower than the softmax baseline it replaced. Triton kernel fusion claws it back to 2139 ms. Then the autoencoder changes, and the same fused linear block runs in 748 ms. The whole block redesign is worth 1.05×; the tokenizer swap on top of it is worth 2.9×.

I wanted to be sure I was reading the columns right, so I rebuilt them. Take the 0.6B config as the repo defines it — depth 28, hidden 1152, mlp_ratio: 2.5 Mix-FFN, linear_head_dim: 32, 300 text tokens — and count multiply-accumulates from the shapes:

softmax self-attn   2 N² d               LiteLA    2 N d (d_h + 1)
MLP-FFN (ratio 4)   8 N d²               Mix-FFN   N (7.5 d² + 22.5 d)
qkv + out proj      4 N d²
cross-attn          2 N d² + 2 N Lt d + 2 Lt d²

Doubling for FLOPs and multiplying by 28 layers reproduces 6.48 / 4.30 / 4.19 / 1.08 as 6.63 / 4.48 / 4.34 / 1.12 — every row within 4.2%, which is about what the adaLN and norm terms I skipped are worth. The column is FLOPs rather than MACs despite the header, and it is the 0.6B model. The arithmetic is otherwise exactly what the configs say it should be.

What LiteLA actually computes

The kernel is a dozen lines and worth reading in full, because the trick is in a padding call.

# diffusion/model/nets/sana_blocks.py — LiteLA.attn_matmul
def attn_matmul(self, q, k, v: torch.Tensor) -> torch.Tensor:
    q = self.kernel_func(q)          # nn.ReLU
    k = self.kernel_func(k)
    v = F.pad(v, (0, 0, 0, 1), mode="constant", value=LiteLA.PAD_VAL)  # PAD_VAL = 1
    vk = torch.matmul(v, k)
    out = torch.matmul(vk, q)
    out = out.float()
    out = out[:, :, :-1] / (out[:, :, -1:] + self.eps)
    return out

Softmax attention computes softmax(QK)V\mathrm{softmax}(QK^\top)V, and the QKQK^\top in the middle is an N×NN \times N matrix. Replace the exponential with a feature map ϕ\phi and it becomes (ϕ(Q)ϕ(K))V\big(\phi(Q)\phi(K)^\top\big)V, which associativity lets you rebracket as ϕ(Q)(ϕ(K)V)\phi(Q)\big(\phi(K)^\top V\big) — and now the inner product is dh×dhd_h \times d_h, independent of NN. Sana uses ϕ=ReLU\phi = \mathrm{ReLU}.

The padding is how the denominator rides along. Attention has to normalise by jϕ(Kj)\sum_j \phi(K_j), which is a different contraction from the numerator. Appending a row of ones to VV makes the last row of the vkvk state exactly that sum, so one matmul produces numerator and denominator together and the final line divides them. It costs one extra row of a 32×32 matrix.

That state is the whole memory of the layer, and it is the same size at every resolution:

attention weights over 16 keys · score contrast γ = 1.0softmax peak 25% · ReLU kernel peak 23%, fixed
score contrast γ1.0×
Two rows of sixteen attention weights over the same key compatibilities. Softmax at contrast 1.0 puts 25 percent of its weight on the strongest key and effectively attends to 8.2 keys. The ReLU kernel puts 23 percent on the same key and attends to 7.2 keys, and neither number moves when the contrast changes.softmax(γ·s)peak 25%8.2 keys eff.moves with γ1235611258421414732ReLU / Σ ReLUpeak 23%7.2 keys eff.invariant in γ258152312341791-0.9-0.40.20.50.81.42.21.10.3-0.2-0.70.41.60.90.1-0.5raw score s
tokens per head
16,384
softmax score entries
268,435,456
LiteLA state entries
1,056, constant
rank of the mixing map
≤ 32

Drag γ. Softmax exponentiates its scores, so multiplying them by a constant is a temperature knob: at γ = 0.2 it is nearly uniform, at γ = 8 it has collapsed onto a single key. The ReLU kernel cannot move at all. ReLU-then-normalise is homogeneous of degree zero — the γ cancels between numerator and denominator — so LiteLA’s weights are fixed by the direction of the query and never by its magnitude. Both rows share one vertical scale, so the shapes are directly comparable.

Note the five faint bars. ReLU clamps every negative compatibility to exactly zero, which throws away all ordering among them: a key that mildly disagrees and one that strongly disagrees get the same weight, none.

The stat row is the other half of the bargain, and it is the half Sana is buying. Softmax needs an N×N score matrix per head; LiteLA carries a 33×32 state whose size does not depend on N at all, which is exactly why its cost is linear. The price is printed beside it: because everything routes through that state, the implied attention matrix has rank at most 32 however many tokens there are. Sana’s answer is not more attention — it is the 3×3 depthwise convolution inside Mix-FFN, which puts back the local mixing a rank-32 map smears.

Two things fall out that the paper's prose does not dwell on. The first is the rank bound: the attention map LiteLA implies is A=ReLU(Q)ReLU(K)A = \mathrm{ReLU}(Q)\mathrm{ReLU}(K)^\top, a product of an N×32N \times 32 and a 32×N32 \times N matrix, so it has rank at most 32 no matter how many tokens there are. At 4096px that is 16,384 tokens mixed through a rank-32 map.

The second is that a ReLU kernel has no temperature. ReLU-then-normalise is homogeneous of degree zero, so scaling the query changes nothing at all; softmax's exponential means the same scaling is exactly the knob that sharpens attention to a hard argmax. Slide γ\gamma in the widget and only one row moves. Sana's compensation is architectural rather than attentional — the 3×3 depthwise convolution inside Mix-FFN puts local mixing back — and the paper is honest that without it, "linear attention models suffer from much slower convergence."

Two more things I did not expect to find in the source.

The cross-attention is not linear. SanaMSBlock dispatches attn_type to LiteLA, but every cross_attn_type except vanilla lands on MultiHeadCrossAttention, which calls xformers.ops.memory_efficient_attention. Sana is a linear self-attention DiT; conditioning on the text stays softmax. That is the right call — cross-attention is O(NLtext)O(N \cdot L_\text{text}), already linear in NN — but "Linear DiT" describes one of the two attention operators in the block.

Every config sets fp32_attention: true, and the kernel force-casts to float before dividing. This is not paranoia. Softmax is self-normalising; a ReLU kernel is not, so the accumulated numerator and denominator have no bound. Sana-1.5 reports the failure mode directly: the ReLU-linear attention logits "grow uncontrollably and frequently exceed the numerical range of FP16," which is why every 1.5 and Sprint config turns on qk_norm: true and cross_norm: true. (The paper puts FP16's ceiling at 6.5e5; it is 65,504. The mechanism is right, the constant is a decimal place off.)

The autoencoder, and what 32× costs

DC-AE-F32C32 is six stages of encoder — width_list=[128,256,512,512,1024,1024], the last three EViTS5_GLU blocks — giving five 2× downsamples, and 32 latent channels. Per image element that is 3F2/C=96×3F^2/C = 96\times compression against the f8c4 VAE's 48×48\times; per token, which is what the transformer actually pays for, it is 4× fewer.

Compression that aggressive used to be a known-bad idea. The paper's Table 1 is the rebuttal, on MJHQ-30K:

AutoencoderrFID ↓PSNR ↑SSIM ↑LPIPS ↓
F8C4 (SDXL)0.3131.410.880.04
F32C64 (SD)0.8227.170.790.09
F32C32 (Sana)0.3429.290.840.05

The earlier F32 attempt was two and a half times worse on rFID than f8c4; this one is within 0.03. PSNR is still 2 dB down, which is a real gap and shows up as softness in fine texture — but the argument is that a 2 dB reconstruction penalty is a good trade for a 4× cheaper transformer, and the generation FID numbers support it. There is a companion ablation worth flagging: F8C16P4, F16C32P2 and F32C32P1 all produce the same 32×32 token grid at 1024px, F8C16 reconstructs best, and F32C32P1 generates best. Where you put the compression matters more than how much reconstruction error it costs.

The channel count is a similar trade: C=16 converges fastest but reconstructs worse, C=64 reconstructs best but the downstream DiT converges much slower. C=32 is the compromise, and the scale_factor: 0.41407 in every config is the latent normalisation that goes with it.

No positional embedding, except where there is

The clean surprise in Sana 1.0 is that the DiT has no positional encoding. use_pe: false in the 512px and 1024px configs, and the architecture figure crosses the Pos Emb box out in red. The justification is that Mix-FFN's zero-padded 3×3 depthwise convolution leaks absolute position implicitly, which is a known result for convolutional encoders. It also means nothing in the network assumes a resolution.

Except the released high-resolution models turn it back on:

# configs/sana_config/2048ms/Sana_1600M_img2048_bf16.yaml   configs/sana_config/4096ms/...
use_pe: true
pe_interpolation: 1.        #  → 2.  at 4096px

and the shipped diffusers config for Sana_1600M_4Kpx_BF16_diffusers carries "interpolation_scale": 2.0 with "sample_size": 128, while the 1024px checkpoint's config has no interpolation_scale key at all. So NoPE holds for the models trained at their native resolution and is dropped for the 2K/4K fine-tunes — which is the honest version of "positional embedding is not required": not required when you are not extrapolating.

The text encoder is an LLM you are allowed to instruct

Swapping T5-XXL (4.76B) for Gemma-2-2B-IT (2.61B) is the part everyone quotes, and the loading code is blunt about what it does:

# diffusion/model/builder.py
text_encoder = (
    AutoModelForCausalLM.from_pretrained("Efficient-Large-Model/gemma-2-2b-it",
                                         torch_dtype=torch.bfloat16)
    .get_decoder()
    .to(device)
)

Take a causal LM, throw away the LM head, use the decoder stack as a feature extractor. caption_channels: 2304 in the model config is Gemma-2-2B's hidden size. Table 9 puts the shipped encoder, Gemma2-2B-IT, at 2614M params and 0.28s against T5-XXL's 4762M and 1.61s — 5.8× faster for the same 6.1 FID and 0.2 less CLIP. Worth noting that the best FID row in that table, 5.9 at 0.21s, is Gemma-2B-IT — first-generation Gemma, which is not the model any config loads.

But the size is not the point. The point is that a decoder-only model can be given instructions, and Sana gives it a long one — the chi_prompt block sitting in every single config, telling the model how to expand a terse prompt into a detailed visual description. What the pipeline does with it is the part I had not seen described anywhere:

Gemma-2-2B-IT conditioning · 507 input slots · 300-slot selection window13 vectors reach the DiT
user prompt length11 tokens
A 507-slot Gemma input made of one BOS token, a 208-piece Complex Human Instruction preamble, a 11-token user prompt and right padding. Sana keeps slot 0 and the last 299 slots, so only 13 unmasked vectors reach the diffusion transformer's cross-attention.into Gemma507 slotsCHI preamble · 208 piecesright paddingselect_index = [0] + the last 299 slots — window opens at slot 208into the DiT300 slots, masked13 unmasked · 287 padded awaythe instruction is never handed to the transformer —it only shapes the hidden states at the prompt positions,because Gemma is causal and they come after itprompt tokens in context: 11cross-attention keys/values: 13 of 300

Sana’s text encoder is a decoder-only LLM with the language-model head removed — AutoModelForCausalLM.from_pretrained(…).get_decoder() on Gemma-2-2B-IT, 2.6B parameters against T5-XXL’s 4.8B. That swap is only half the idea. The other half is that a decoder-only model can be instructed, so Sana prepends a fixed 208-piece “Complex Human Instruction” telling the model how to expand a terse prompt into a visual description — and then throws the instruction away.

Drag the prompt length. The preamble occupies the first 209 slots of a 507-slot input, but select_index keeps only slot 0 and the last 299, and the attention mask zeroes the padding. For a short prompt the diffusion transformer ends up cross-attending to about a dozen vectors, not 300. The instruction never reaches it. It works because Gemma is causal: every hidden state at a prompt position has already attended over the whole instruction, so the enhancement is baked into the representation rather than into a second generated string.

Two practical consequences fall out of the arithmetic. Prompts longer than 298 tokens get truncated — push the slider past it and watch. And the cost of CHI is real: Gemma runs over 507 positions instead of 300, for a prompt that is usually a dozen tokens long.

I tokenised the preamble with the Gemma tokenizer shipped alongside the weights: 1057 characters, 208 pieces. So max_length_all = 209 + 300 - 2 = 507, Gemma runs over 507 positions, and then

select_index = [0] + list(range(-self.config.text_encoder.model_max_length + 1, 0))
caption_embs = self.text_encoder(...)[0][:, None][:, :, select_index]
emb_masks    = caption_token.attention_mask[:, select_index]

keeps slot 0 and the last 299. Slot 208 is the final token of the instruction; everything after is the user's prompt and then padding, which the mask zeroes. For the repo's own example prompt the diffusion transformer ends up cross-attending to 13 vectors. The instruction is never handed to the DiT at all — it works entirely through Gemma's causal attention, which has already folded it into the hidden states at the prompt positions.

That is a genuinely elegant piece of design and it is also a free lunch that isn't free: you pay for a 507-token forward pass of a 2.6B LLM to condition on a twelve-token prompt, and prompts longer than 298 tokens are silently truncated by truncation=True.

Sana-1.5: grow the depth, then buy quality with samples

Sana-1.5 is three separate ideas bolted to the same backbone, and the configs show all three.

Depth growth. SanaMS_4800M_P1_D60 is the 1.6B's 20 blocks turned into 60, initialised from the smaller model rather than from scratch, with the output projections of both attentions and the final point-wise conv zero-initialised so each new block starts as an identity map. One detail is a nice piece of empiricism: appending new blocks after all the pretrained ones failed — the well-learned features dominated through the skip connections and the new blocks got stuck — so they delete the last two pretrained blocks first. Reported as reaching the same GenEval with ~60% fewer steps.

CAME-8bit. train.optimizer.type: CAMEWrapper with block-wise 8-bit first moments and 32-bit second-order statistics, quantising only tensors above 16K parameters in blocks of 2048. For the 1.6B that is 43 GB against AdamW's 57 GB — a 25% reduction, which is the number in the appendix, not the ~8× the abstract implies (that is the optimizer-state ratio, not total training memory).

Inference-time scaling. Generate NN candidates, score them with a fine-tuned NVILA-2B verifier, keep the best few. The repo's own doc puts the 4.8B v2 model's GenEval at 81 → 96 with top-4 of 2048, and notes 32 candidates already clears 90. That is a real result and it is also a 2048× compute multiplier on the thing Sana was built to make cheap. And the README's own comparison table has the 1.5 1.6B at 0.82 GenEval against the 4.8B's 0.81 — the 4.8B still wins DPG (84.7 vs 84.5) and CLIP (29.23 vs 29.12), but on the benchmark the paper leads with, three times the parameters and three and a half times the latency buys nothing.

Sana-Sprint: two steps, on an arc

Sana-Sprint distils the flow-matching teacher into a 1–4 step generator by moving to TrigFlow's parameterisation, where the noising process is a spherical interpolation on [0,π2][0, \tfrac{\pi}{2}]:

xt=cos(t)x0+sin(t)z,zN(0,σd2I),σd=0.5x_t = \cos(t)\, x_0 + \sin(t)\, z, \qquad z \sim \mathcal{N}(0, \sigma_d^2 I), \quad \sigma_d = 0.5

so t=π/2t = \pi/2 is pure noise, t=0t = 0 is the sample, and the SNR at tt is exactly cot2(t)\cot^2(t) — the data scale cancels because the noise carries it too. The training loop is continuous-time consistency distillation (sCM) plus a latent adversarial term, and the code is unusually readable:

# train_scripts/train_scm_ladd.py
v_x = torch.cos(t) * torch.sin(t) * dxt_dt / sigma_data
v_t = torch.cos(t) * torch.sin(t)
F_theta, F_theta_grad, logvar = torch.func.jvp(model_wrapper, (x_t / sigma_data, t), (v_x, v_t), has_aux=True)
 
r = min(1, global_step / config.train.tangent_warmup_steps)          # 4000
g = -torch.cos(t) * torch.cos(t) * (sigma_data * F_theta_minus - dxt_dt)
second_term = -r * (torch.cos(t) * torch.sin(t) * x_t + sigma_data * F_theta_grad)
g = g + second_term
 
g_norm = torch.linalg.vector_norm(g, dim=(1, 2, 3), keepdim=True)
g = g / (g_norm + 0.1)                                               # tangent normalisation
weight = 1 / (torch.tan(t) * sigma_data)
loss = (weight / torch.exp(logvar)) * (F_theta - F_theta_minus - g) ** 2 + logvar

The Jacobian-vector product is the consistency condition: it measures how the network's output moves as you slide along the probability-flow trajectory, and the loss asks that movement to match the teacher's velocity. Three details are load-bearing. The tangent is normalised to unit length plus a constant 0.1, which is what keeps sCM from exploding. logvar is a learned per-sample uncertainty that reweights the loss — the + logvar term is the price for down-weighting. And cross_attn_type: vanilla in every Sprint config exists for a mundane plumbing reason: the paper notes PyTorch has no FlashAttention JVP kernel, and the xformers path Sana normally uses for cross-attention is in the same position, so Sprint swaps in a hand-rolled scaled_dot_product_attention that is differentiable twice. The source comment says so outright — # Cast for sCM, right above a cast of q, k and v to fp32.

On top of that sits LADD: a hinge-loss discriminator with heads on blocks [2, 8, 14, 19] of the frozen teacher, adv_lambda: 0.5 against scm_lambda: 1. And a weighting trick — with probability 0.5 the generator is trained at exactly largest_timestep: 1.57080, pure noise, which is what makes one-step generation work at all.

Then the inference schedule, where the repo and the paper part company:

SANA-Sprint · TrigFlow timesteps on [0, π/2] · 4 stepsshipped default diverges from the paper
TrigFlow timesteps for 4-step SANA-Sprint sampling. The shipped SCMScheduler uses 1.571, 1.178, 0.785, 0.393, 0.000; the paper's searched schedule is 1.568, 1.300, 1.100, 0.600, 0.000. They differ: the scheduler falls back to a uniform linspace when the step count is not 2.t = π/2 · pure noiset = 0 · clean sampleSCMSchedulershipped default1.5711.1780.7850.3930.000Table 7searched, in paper1.5681.3001.1000.6000.000x_t at each tcos(t)·x₀sin(t)·zt=1.57cos 0.00 · sin 1.00SNR -109 dBt=1.18cos 0.38 · sin 0.92SNR -8 dBt=0.79cos 0.71 · sin 0.71SNR -0 dBt=0.39cos 0.92 · sin 0.38SNR 8 dBt=0.00cos 1.00 · sin 0.00SNR ∞
network evaluations
4
t_max shipped
1.57080
t_max in Table 7
1.56830
schedules agree
no

Sprint samples on TrigFlow’s arc rather than a [0, 1] line: x_t = cos(t)·x₀ + sin(t)·z, so t = π/2 is pure noise, t = 0 is the sample, and the signal-to-noise ratio at t is exactly cot²(t) — the data scale σ_d = 0.5 cancels, because the noise carries it too. Each step predicts x̂₀ from the current x_t, then re-noises to the next t. Nothing about the schedule needs the steps to be evenly spaced, which is why it is worth searching.

At 1 and 2 steps the shipped defaults are the paper’s. At 4 they are not. SCMScheduler.set_timesteps only honours intermediate_timesteps when the step count is exactly 2; for anything else it prints a warning, throws the value away and falls back to a uniform linspace(1.5708, 0, n+1). Table 7’s searched 4-step schedule spends three of its four steps above t = 0.6 — far more of the budget in the high-noise regime than a uniform split gives it. You can still get it, but only by passing the whole list through the timesteps argument yourself.

SCMScheduler.set_timesteps only honours intermediate_timesteps when num_inference_steps == 2. Ask for four and it prints a warning, discards the value, and falls back to linspace(1.5708, 0, 5). The paper's Table 7 gives a searched 4-step schedule of [arctan(400), 1.3, 1.1, 0.6, 0], which spends three of four steps above t=0.6t = 0.6 — far more of the budget in the high-noise regime than a uniform split. Both are reachable; only one is the default.

Where the 0.1 seconds actually goes

The Sprint headline is "0.1s per 1024px image on H100." The paper's own Figure 1 breaks it down, and the breakdown is more interesting than the number:

Two-panel SANA-Sprint figure. Left: horizontal latency bars for 1024×1024 generation, each split into a VAE segment and a Transformer segment — Flux-Schnell 4 steps at VAE 0.15s plus Transformer 1.94s, SANA 20 steps at VAE 0.12s plus 1.18s, SANA-Sprint 4 steps at 0.12s plus 0.14s, SANA-Sprint 1 step at 0.12s plus 0.03s; annotated 1.6×, 8.4×, 39.3× and an overall 64.7×, with text encoding marked as under 0.05s. Right: a bar chart of training GPU memory, Flux-Schnell 12B and SDXL-DMD2 0.9B both above 80GB and marked OOM, SANA-Sprint 1.6B at 67GB with batch size 32 and 45GB with batch size 2, SANA-Sprint 0.6B at 20GB.
One-step Sana-Sprint is 0.03s of transformer and 0.12s of VAE decode on an A100. The 64.7× in the caption is a transformer-only ratio. (SANA-Sprint, arXiv:2503.09641, Figure 1.)

At one step the transformer costs 0.03s and the autoencoder costs 0.12s — the decoder is now four times the cost of the denoiser. The 64.7× speedup annotated across the bottom is explicitly "the ratio calculated based on Transformer latency," which is a fair thing to measure and not the thing a user experiences. End to end on an A100 the paper's own table gives 0.21s for the 0.6B at one step, and the repo's docs/sana_sprint.md gives 0.24s and 0.25s at two steps for the 1.6B and 0.6B.

So where does 0.1s on H100 come from? 0.12s of VAE decode alone would blow the budget on an A100. The answer is in the Callout above: DC-AE-Lite drops decode to 0.06s and compiling it to 0.03s, and the Lite decoder landed in the repo in August 2025, five months after the Sprint paper. The claim is reachable — newer hardware plus the newer decoder — but the "0.1s" and the paper's own Figure 1 are not describing the same configuration, and nothing in the repo measures the combination. Worth knowing if you are budgeting a latency SLO.

The 100×, checked

The number in the README's first paragraph is "20× smaller and 100× faster than Flux-12B." The first half is arithmetic — 0.6B against 12B — and the second half is defensible but load-bearing about where.

Scatter plot titled Model Performance Comparison, GenEval Overall Results on the vertical axis from 0.45 to 0.75 against Throughput in samples per second on the horizontal axis from 0 to about 1.7. Bubble area encodes parameter count. FLUX-Dev sits at about 0.66 and 0.04 samples per second, FLUX-Schnell at 0.70 and 0.5, SD3-Medium at 0.62, PlaygroundV2.5, SDXL, PixArt-Sigma and LUMINA-Next cluster at low throughput. Sana-1.6B is at 0.65 GenEval and about 1.0 samples per second and Sana-0.6B at about 0.64 and 1.7, with a red arrow spanning from FLUX-Dev to Sana labelled 40× acceleration. Grey reference bubbles at the bottom show 0.6B, 4B, 8B and 12B parameter scales.
At 1024×1024 on an A100, the gap is 40×, not 100×. The 100× is a 4096×4096 number. (Sana, arXiv:2410.10629, Figure 4.)

The paper's Table 14 measures four resolutions on an A100 in FP16, batch 16 for throughput and batch 1 for latency:

ResolutionSana-0.6B throughputFLUX-dev throughputSpeedup
512×5126.67 /s0.15 /s44.5×
1024×10241.72 /s0.04 /s43.0×
2048×20480.43 /s0.008 /s53.8×
4096×40960.104 /s0.001 /s104.0×

So: 100× is real, at 4K, on throughput, against FLUX-dev on an A100. At 1024px it is 43× in this table, 40× in the figure, "39×" in the body text and 39.5× in the README's table — four numbers for the same comparison, all in the same repo. None of them is wrong enough to matter, but if you are quoting one, quote the resolution with it.

One thing in that table does not survive a second look. The batch-1 latency column has Sana-0.6B at 0.8s and Sana-1.6B at 0.6s for 512px, and 9.6s versus 5.9s at 4096px — the 2.7×-larger model reported as faster at two of the four resolutions, while being correctly slower at the other two. The throughput column is monotone. I cannot reconcile it; my guess is different tiling or offload settings between rows at 4K, but the table does not say.

Licences, precisely

NVIDIA research releases are usually non-commercial, so I checked rather than assumed.

The ledger

What is genuinely new here. Pushing latent compression to F32 with patch size 1 and showing it does not break generation — that is the contribution, and it is the one the rest of the field absorbed. The Complex Human Instruction mechanism is the other: using a causal LM's own in-context behaviour as a prompt enhancer, then slicing the instruction back out of the conditioning, gets the benefit of prompt rewriting with none of the second generation pass. And Sprint's sCM+LADD recipe producing one model that works at 1, 2 and 4 steps — rather than a model per step count — is a real convenience that DMD-style distillations mostly do not offer.

What is convergent. ReLU linear attention is EfficientViT's, applied to a new domain. TrigFlow and sCM are OpenAI's. LADD is Stability's. Depth growth and zero-initialised residual blocks are standard LLM scaling technique. The synthesis is the work, and the repo is honest about the lineage in its acknowledgements.

What I would keep an eye on. The two findings above are the same finding seen twice. Linear attention does not pay for itself until roughly 25,000 tokens, and it costs a rank-32 ceiling on the mixing map — so an image model that never exceeds 16,384 tokens is paying the expressiveness bill without collecting much of the compute refund, and the autoencoder has to carry the efficiency story. A video model is on the other side of both lines: sequences are an order of magnitude longer, so the refund is large, and the quality cost is severe enough that you cannot just eat it. The family's own trajectory says exactly this. SANA-Video 2.0 abandons pure linear attention for a 3:1 hybrid with periodic softmax anchors, and SANA-WM and SANA-Streaming are both titled "Hybrid" too. That is the conclusion the LLM world reached about linear attention, arrived at independently and from the other direction.

And the thing that would change my read. Almost every number above is A100/FP16 from the papers. The repo ships thorough quality tooling — tools/metrics/ covers FID, CLIP score, GenEval, DPG-Bench and ImageReward — and nothing at all that reproduces the throughput and latency columns; grepping the whole tree for either word turns up no benchmark script. The newest performance claims in the README — Sol-Engine's 3.95× on GB200 — point at a branch that is not in main. If these latencies are load-bearing for you, measure them yourself: the inference code is all here, the measurement is not.

If you want the compression argument applied to a tokenizer instead of an attention operator, Mage-Flow makes the same bet from the other side. If you want to see why softmax is hard to beat on a modern GPU regardless of its asymptotics, FlashAttention-3 is the counter-argument in kernel form.

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "Sana: the autoencoder does more of the work than the linear attention", ai.thesatyajit.com, August 2026.

bibtex
@misc{ghana2026sana,
  author = {Satyajit Ghana},
  title  = {Sana: the autoencoder does more of the work than the linear attention},
  url    = {https://ai.thesatyajit.com/articles/sana},
  year   = {2026}
}
share