~/satyajit

Nemotron-Labs Dory1.0-4B: one 20-layer block, run up to four times

mdjsonmcp

2026-10-19 · 19 min · looped-transformers · small-models · nvidia · kv-cache

A 1:41 narrated explainer, drawn in code. Every number and picture in it is this article's own; the sources are below.

› transcript

Hi, I'm Marline! Today: how NVIDIA's small Dory model thinks harder by running the same layers more than once. Dory keeps one shared block of twenty layers and runs it several times for every token. Extra depth costs compute, not parameters. A token first goes through eight input layers, each one attention plus a feed forward network. Then the twenty layer recurrent block runs. By default it runs twice, with the same weights both times. Before every pass, the input block's output is added back, so the loop keeps its starting point. Eight output layers turn the final state into the next token. Here is NVIDIA's own drawing. The input block, I, makes the starting state. Each loop adds that input back in and runs the shared block R again, from loop one to loop k. By default, every pass keeps its own keys and values, so the cache grows with each loop. In last loop mode, each pass overwrites the one before. The cache stays at the one loop size, whatever the loop count. The parameters never change. The work per token does: each extra pass adds about one point eight billion multiply adds. Most of the reported gain comes from the second pass. A fourth adds a little on agent tasks, and nothing on recall. Looping buys more thinking per token, not more facts. Reasoning scores rise, and the knowledge score stays flat. One shared block, run up to four times. Fixed parameters, growing compute, and a cache mode that keeps memory flat. Every source is in the full article. I'm Marline. Bye!

Why read this

Notabletop 60%

Reads Dory's loop and KV modes out of the serving code, recounts its 3.98B parameters, prices each pass, and runs it on CPU at 1 to 4 loops.

  • Original analysis
  • Runs on a consumer GPU
  • A lasting reference

LLM architectureOpenMDW-1.1Practitioner model

How this was scored
Is it new?
2 of 3: A real new idea, method or capability
Can I trust it?
2 of 3: Measures key facts from files, code or configs
Can I run it?
2 of 3: Open code or weights with real limits
Will I understand it?
2 of 3: Mechanism from first principles with figures
Can I act on it?
2 of 3: A concrete recipe, numbers or comparison
Will it last?
2 of 3: A reference for a year or more
Does it affect many?
1 of 3: A specialist community
Only here?
2 of 3: A teardown or measurement few others did

Score 67 of 100, ranked 176 of 577 rated articles. Each question is answered 0–3 by hand, and a 3 is rare. How articles are scored

Oleksii Kuchaiev at NVIDIA posted the release with one line of framing: "Nemotron-Labs models give us a glimpse of what the future might be. And it may be looped!" The model is NVIDIA-Nemotron-Labs-Dory1.0-4B, released on October 16, 2026, in four formats (BF16, MXFP8, NVFP4 and a Q5_K_M GGUF). There is no paper. The card is the documentation, and the only executable description of the architecture is the vLLM pull request that serves it, vllm-project/vllm#60149. The Hugging Face repo ships no modelling code and transformers 5.20.1 has no dory model type, so AutoModelForCausalLM cannot open it today.

So I read the vLLM code, read the safetensors header, and wrote a 260-line PyTorch forward pass from the reference to run it on a CPU. This piece is what "looped" means in that code, what it costs, and which of the card's claims hold.

nvidia/NVIDIA-Nemotron-Labs-Dory1.0-4B-BF16@b366b87 · snapshot 2026-10-19
announced
4B
measured
3,975,229,440
parameters
3.98B
repo size
7.97 GB
architecture
DoryForCausalLM
task
text-generation
library
transformers
license
other
safetensors
1 shard
largest file
7.95 GB
files
13
downloads
1.2K
likes
120
languages
en
parameters by dtype
BF163.98B
nvidiapytorchnemotron-labsdory

Looped normalized GPT: 8 input, 20 recurrent and 8 output attention+MLP pairs, recurrent block run 2 times by default. 3,975,229,440 BF16 parameters (measured from the header). No modelling code in the repo; served by vLLM PR #60149. OpenMDW-1.1.

repo last modified 2026-10-16

What "looped" means in Dory

A standard transformer is a list of distinct blocks, each applied once. A looped (or recurrent-depth) transformer stores fewer blocks and applies some of them more than once. The site's looped transformer explainer covers the family. Dory is the prelude, core and coda variant:

Diagram: Emb feeds an orange input block I, whose output h-in becomes the initial state s0. Three dashed loop boxes, Loop 1, Loop 2 and Loop k, each contain a plus node that adds h-in to the previous state and a blue recurrent block R. The final state sk goes to a red output block O and then the head H.
Dory's layer stack: input block, a shared recurrent block applied k times with the input re-injected before each loop, then the output block (Dory1.0-4B model card, architecture figure).

The card's caption says "the Input and Output blocks have 8 layers each, while the Recurrent block has 20 layers." The config says something that looks different: num_hidden_layers: 72, n_input_layers: 16, n_recurrent_layers: 40, n_output_layers: 16. Both are right. In Dory's code a "layer" is one sublayer, either attention or an MLP, never both. The layer_types list in config.json alternates them: 36 attention entries and 36 mlp entries (measured). The card counts attention+MLP pairs. So the stack is 8 + 20 + 8 = 36 pairs, and with kk loops the token passes through 16+20k16 + 20k pairs: 36 at one loop, 56 at the default two, 96 at four (reasoned).

The loop itself is a dozen lines of DoryBackbone.forward in the PR:

# vllm/model_executor/models/dory.py (PR #60149), condensed
for layer in self.layers[:n_in]:
    h = layer(positions, h)
input_h = h
state = input_h
for i in range(self.num_loops):
    h = justnorm(state + input_h)          # re-inject the input block's output
    for layer in self.layers[n_in : n_in + n_rec]:
        h = layer(positions, h, loop_idx=i)
    state = justnorm(h)
h = state
for layer in self.layers[n_in + n_rec :]:
    h = layer(positions, h)

Three things follow from it.

The input is re-injected before every pass. Each loop starts from s~k=norm⁡(sk−1+hin)\tilde s_k = \operatorname{norm}(s_{k-1} + h^{\text{in}}), with s0=hins_0 = h^{\text{in}}. This is the "input injection" that keeps a recurrence anchored to the token it is processing. IFM's ablations found it one of the axes that matters; Dory uses the simple additive form and starts the state from the input rather than from noise.

The loop count is a constant. self.num_loops = config.n_recurrent_loops, read once when the model is built. Every token, in prefill and decode, runs exactly that many passes. There is no halting head, no per-token exit and no router. You set it at load time: --hf-overrides '{"n_recurrent_loops": 2}' in vLLM, --override-kv dory.num_loops=int:2 in llama.cpp, which the card says accepts 1, 2, 3 or 4. The checked-in config.json says 2, which is NVIDIA's recommended default. This is elastic in the deployment sense, one checkpoint for several budgets, not adaptive in the Universal-Transformer sense.

Everything lives on the unit sphere. Dory is a normalized GPT (nGPT, arXiv 2410.01131). There is no LayerNorm or RMSNorm. justnorm divides by the L2 norm, and the residual update is not h + f(h). It is justnorm(a + α·(norm(b) − a)): move a learned, per-dimension fraction α of the way from the current point toward the sublayer's output, then project back onto the sphere. Queries and keys are normalized after RoPE, the attention output goes through a sigmoid gate and is normalized again, and the logits are multiplied by a learned per-token logit_scale. For a looped model this matters more than usual. A hidden state whose norm cannot grow is one failure mode, variance blow-up across passes, that LOOM had to engineer around and Dory gets by construction. I have no ablation showing that is why NVIDIA chose nGPT; it is a reasonable reading, not a reported one.

What the card does not say is how the model was trained with respect to loops: a fixed kk throughout, a random kk per step as in Huginn, or a schedule. It reports results at 1, 2 and 4 loops from one checkpoint, so whatever the recipe was, the weights tolerate all three. The training loop count is unverified.

Parameters stay fixed, compute does not

The name says 4B. I summed every tensor in the BF16 safetensors header (an HTTP range request, no download): 3,975,229,440 parameters across 507 tensors, all BF16 (measured). The name is honest. Split by role:

GroupParametersShare
Embeddings335,544,3208.4%
Input block (8 pairs)734,224,38418.5%
Recurrent block (20 pairs)1,835,560,96046.2%
Output block (8 pairs)734,224,38418.5%
LM head + logit_scale335,675,3928.4%

All measured from the header. The embedding and LM head are untied (131,072 × 2,560 each), so 3.30B of the 3.98B are non-embedding.

The recurrent block is 46.2% of the weights, so every extra pass re-runs almost half the model. Counting the weights each token multiplies against (everything but the embedding lookup):

LoopsEffective depth (pairs)Matmul weights touched / tokenFLOPs / tokenvs 1 loop
1363.64B7.28 GFLOP1.00x
2565.48B10.95 GFLOP1.50x
3767.31B14.62 GFLOP2.01x
4969.15B18.29 GFLOP2.51x

All reasoned from the measured parameter groups, at 2 FLOPs per weight, ignoring attention scores (small at short context). At batch size one, decode is bound by memory bandwidth, and the number of bytes read per token scales the same way: 7.28 GB of BF16 weight reads per token at one loop and 18.29 GB at four, unless the 3.67 GB recurrent block fits in a cache, which on a GPU it does not. At the default of two loops, Dory does the per-token work of a 5.5B dense model while storing 3.98B.

The KV cache: two modes, one approximation

Every attention layer in the recurrent block sees a different hidden state on each pass, so in principle each pass has its own keys and values. The PR makes that the default, recurrent_kv_cache_mode: "per_loop": for a recurrent attention layer it builds one Attention module per loop, each with its own cache, registered under a virtual layer index loop_idx * num_hidden_layers + layer_idx so the projections stay shared while the caches do not.

The arithmetic from the config (reasoned): each attention layer stores 2 (K and V) × 4 KV heads × 256 dims × 2 bytes = 4,096 bytes per token. Of the 36 attention layers, 9 are full attention (2 input, 5 recurrent, 2 output) and 27 use a 1,024-token sliding window, so at long context only the full layers grow. Per token of context that is 36 KiB at one loop, 56 KiB at two and 96 KiB at four. At the advertised 256K context, one sequence's cache is 9.1 GiB at one loop, 14.2 GiB at two and 24.3 GiB at four.

"last_loop" is the card's answer: one cache per recurrent attention layer, which every loop overwrites. The cache is then the one-loop size for any kk: 9.1 GiB at 256K whether you loop once or four times. The code comment says exactly what this costs:

In last-loop mode every loop writes this step's KV to the same cache, overwriting the previous loop's. Loop i thus attends to loop-i KV for this step's tokens and last-loop KV for earlier tokens.

In other words, on pass 1 of a new token, the earlier tokens are seen as they looked after their final pass, not their first. That is not the computation the model does in per-loop mode, and so it is not exact. It is an approximation, and the card's evidence for it is one sentence: "the accuracy with last-loop caching stays close to the per-loop mode with nearly unchanged output lengths." No table. My implementation has both modes, but the machine ran out of memory and time before I reached the last-loop runs, so I have no number of my own here either.

Running it on a CPU

Satyajit asked me to run what I could. With no transformers implementation, I wrote my own forward pass in plain PyTorch from the vLLM reference: nGPT justnorm, the slerp-style residual, RoPE with θ=104\theta = 10^4 on sliding layers and 10710^7 on full ones, q/k normalization after RoPE, the sigmoid output gate, logit_scale, the loop, and both KV modes. The prompt is rendered with the repo's own chat_template.jinja with thinking off (<think></think>), decoding is greedy, and the loop count and cache mode are runtime arguments. The hardware is a shared 4-core cloud VM with no GPU, two threads, nice -n 19, with five other jobs on the machine. Speeds below are lower bounds.

First attempt, BF16 in RAM. I streamed the BF16 file straight into memory (7.71 GB resident after loading, 7.96 GB after fusing the q/k/v/gate and gate/up projections, measured). It ran: at one loop it answered "391" to "What is 17 * 23?" with log-probability −0.0041 on the right answer, at 0.79 tokens/s. Four prompts in, the container's memory cgroup killed it. A 4B model in BF16 needs about 8 GB, and a shared box with a hard memory limit does not have 8 GB to spare.

Second attempt, int8 weight-only. I quantized each 2-D weight to int8 with one scale per output row as it streamed in, kept norms and scales in BF16, and used PyTorch's _weight_int8pack_mm kernel. That is W8A16, the same precision class as NVIDIA's own MXFP8 export, at half the memory: 4.90 GB peak resident (measured). On a test matrix the int8 matmul differs from BF16 by 0.87% relative error, and on the four prompts the BF16 run finished, the int8 model's log-probabilities on the reference answers land within 0.13 of BF16's (−0.0032 vs −0.0041, −5.36 vs −5.47, −2.24 vs −2.37, −11.80 vs −11.74; measured). The quantization is not what moves the numbers below.

Five prompts, each ending "Reply with only the number.", thinking off, greedy:

PromptReference1 loop: answer (log p)2 loops: answer (log p)3 loops: answer (log p)
What is 17 * 23?391391 (−0.003)391 (−0.31)391 (−0.015)
120 km in 1.5 hours, average km/h?80120 (−5.36)120 (−6.31)120 (−5.91)
If 3x + 7 = 31, what is x?82 (−2.24)24 (−2.78)not run
Primes between 10 and 30?611 (−11.80)11 (−11.12)not run
Sally's 3 brothers each have 2 sisters12 (−1.38)2 (−1.40)not run

All measured, int8, per-loop cache. "log p" is the log-probability the model puts on the reference answer when forced through it; 0 means certain. The model gets 1 of 5 at one loop and 1 of 5 at two. The second loop changes one wrong answer into a different wrong answer: on the algebra prompt it answers 24, which is 31−731 - 7, one step further along than 2. That is the whole quality signal I could get, and I would not read much into it. Dory is a reasoning model whose card results are all with reasoning on; asking it for a bare number with <think></think> already closed is the regime where an extra pass has the least room to help. Its card numbers come from long reasoning traces, and I could not generate those on this machine.

The same memory limit ended this run too: the cgroup killed it at 4.90 GB, two prompts into three loops, while other jobs on the box spiked. Third attempt: I wrote the int8 weights to a 3.98 GB file and memory-mapped it, so the kernel could evict pages instead of killing the process, and ran a round-robin speed test (64-token prompt, 8 decode steps, each loop count in turn so contention hits all of them alike). The process survived. The numbers did not. With a load average of 10 to 15 on 4 cores and the weight pages being evicted between steps, two rounds gave:

LoopsDecode tok/s, round 1Decode tok/s, round 2Fastest single-prompt decode in any run
10.520.212.70
20.160.211.43
30.080.130.17
40.060.100.10 (bench only)

All measured, CPU only, two threads. Round 2 has one loop and two loops at the same speed, which no model does; that is the machine, not Dory. The defensible readings are the ones that do not depend on wall-clock time. The cache my implementation builds is exactly what the per-loop arithmetic says: 5,160,960 bytes for 35 tokens at one loop, 8,028,160 at two and 10,895,360 at three, which is 35 tokens × 36, 56 and 76 attention layers × 4,096 bytes (measured). And the cost of a pass is the FLOP table above: 1.50x, 2.01x and 2.51x the one-loop work. On an idle machine I would expect decode speed to fall roughly in that proportion, since decode is reading weights; I did not get an idle machine to show it. I stopped there and deleted the weights.

The card's numbers

All of the following are NVIDIA's, run under NVIDIA's own harness (NeMo Gym / NeMo Evaluator SDK), for every model in the table (reported). Only Qwen3.6-35B-A3B's SWE-bench Verified score is a quoted third-party number.

BenchmarkDory 1 loopDory 2 loopsDory 4 loopsQwen3.5-4BQwen3.5-9B
SciCode27.334.9435.1125.8228.21
SWE-bench Verified50.359.561.854.761
Terminal-Bench 2.112.518.122.21524
τ³-bench (Banking)11.612.8914.695.037.22
GDPval-AA-V2 (Elo)815946967424546
GPQA Diamond69.1972.4172.1677.7180.93
HLE (text, no tools)8.2510.8910.4311.3115.43
IFBench74.2977.947953.8359
AA-LCR63717364.768.4
AA-Omniscience10.0310.0810.0315.0218.05

Four readings.

The second loop is most of the gain. From one to two loops, SWE-bench Verified goes 50.3 → 59.5 (+9.2) and SciCode 27.3 → 34.94; from two to four it goes 59.5 → 61.8 (+2.3) and 34.94 → 35.11 (reasoned from the reported rows). On GPQA Diamond and HLE, four loops is slightly worse than two (72.16 vs 72.41, 10.43 vs 10.89). Only the agentic rows, Terminal-Bench (18.1 → 22.2) and τ³ (12.89 → 14.69), keep climbing. That matches the card's own advice: two loops by default, four for agentic work.

Knowledge does not loop. AA-Omniscience, a recall test, reads 10.03, 10.08 and 10.03 at one, two and four loops. Flat to the second decimal. More passes over the same weights do not add facts that are not in the weights. This is the result that virtual logic depth argued from small GPT-2s, "looping buys reasoning, not knowledge", here at 4B in a shipped model's own table. It is also where Dory loses: 10.08 against Qwen3.5-4B's 15.02, and below it on GPQA Diamond (72.41 vs 77.71) and HLE (10.89 vs 11.31), all knowledge-heavy.

Against Qwen3.5-4B at two loops, Dory wins 7 of 10 rows, including SWE-bench Verified (59.5 vs 54.7), IFBench (77.94 vs 53.83) and GDPval (946 vs 424). The fair framing is that Qwen3.5-4B does 4B parameters of work per token and Dory at two loops does about 5.5B (reasoned, above). Against Qwen3.5-9B, which does more work still, Dory at two loops is close on SWE-bench (59.5 vs 61) and behind on GPQA and HLE. These are comparisons of stored size, not of compute, which is the confound SMELT set out to remove.

The aggregate chart puts Dory's internal Artificial Analysis v4.3.2 estimate at 13.8, 17.7 and 18.4 for one, two and four loops, against Qwen3.5-4B at 11.0 and Qwen3.5-9B at 12.53. The card's footnote is explicit that these are "internal estimates" that "may deviate from official Artificial Analysis (AA) scores."

Line chart of AA v4.3.2 estimate against loop count k. Dory1-4B rises from 13.8 at k=1 to 17.7 at k=2 and 18.4 at k=4. Dashed reference lines: Qwen3.6-35B-A3B 20.15, Muse-Glimmer-30B 19.94, Gemma4-26B-A4B 16.35, Qwen3.5-9B 12.53, Qwen3.5-4B 11.0.
NVIDIA's internal AA v4.3.2 estimate for Dory at 1, 2 and 4 loops against reference models; the card notes these may deviate from official AA scores (Dory1.0-4B model card, benchmark figure).

Do the extra passes pay for themselves?

A loop makes every token more expensive. The card's counter-claim is that it makes answers shorter: "With looping, the model needs fewer generated tokens to solve a task."

Grouped bar chart of output tokens per attempt relative to k=1 on four benchmarks. Terminal-Bench 2.1: 1.00x, 0.72x, 0.50x. GDPval-AA-V2: 1.00x, 0.66x, 0.40x. SWE-bench Verified: 1.00x, 0.79x, 0.74x. tau-cubed Banking: 1.00x, 0.80x, 0.77x, for k=1, 2 and 4.
Output tokens per attempt at 2 and 4 loops relative to 1 loop, on four agentic benchmarks (Dory1.0-4B model card, token-efficiency figure).

Multiply the per-token cost by those factors and you get compute per task relative to one loop (reasoned, assuming cost is linear in generated tokens and ignoring the prompt, which in agentic tasks is not small):

Terminal-BenchGDPvalSWE-bench Verifiedτ³ Banking
2 loops (1.50x per token)1.08x0.99x1.19x1.20x
4 loops (2.51x per token)1.26x1.01x1.86x1.94x

At two loops the total compute for a task is roughly flat, between 0.99x and 1.20x, while SWE-bench Verified gains 9.2 points. That is the honest version of "elastic inference compute": the second pass is close to free per task on these benchmarks, because the model talks less. Four loops is not free: on SWE-bench and τ³ it nearly doubles compute per task for 2.3 and 1.8 more points. Latency follows the same arithmetic only when decode is the bottleneck; prefill has no shorter answer to offset the extra passes, so long prompts pay the full 1.50x or 2.51x.

Where it breaks, and what is unverified

Licence

OpenMDW-1.1 (reported from the card; I read the licence text). It grants permission "to deal in the Model Materials without restriction", including under patent and trade-secret rights, asks you to keep the licence and notices when you redistribute, and terminates for anyone who sues claiming the materials infringe. It places no restriction on outputs. That is about as permissive as model licences get, close to MIT in spirit, and it is the same licence the site found on Nemotron 3.5 Super VL.

Where this sits

Dory is the second looped small model to ship with real weights in a few months, after Nanbeige4.2-3B, which also loops twice. What Dory adds is the elastic part: one checkpoint with published scores at 1, 2 and 4 loops, and a cache mode that decouples memory from loop count. What it does not add is a way for the model to decide its own depth per token. Every token pays for the same number of passes, the hard ones and the trivial ones. The site's other Nemotron-Labs release, TwoTower, made a different structural bet on the same theme, spending a fixed set of weights more than once.

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "Nemotron-Labs Dory1.0-4B: one 20-layer block, run up to four times", ai.thesatyajit.com, October 2026.

bibtex
@misc{ghana2026nemotrondory,
  author = {Satyajit Ghana},
  title  = {Nemotron-Labs Dory1.0-4B: one 20-layer block, run up to four times},
  url    = {https://ai.thesatyajit.com/articles/nemotron-dory},
  year   = {2026}
}
share