Multi-token prediction

Train extra heads to predict the token two and three steps ahead from the same hidden state. The code is built and tested, the plan predicted no gain at 3.3M parameters, and the run moved to Day 5.

Plan switched

Next-token prediction gives the model one target per position. Multi-token prediction (MTP) gives it several, the next token, the one after that, and so on, each from its own head on the same trunk. The extra targets are extra training signal, and at inference time an extra head can draft tokens ahead for speculative decoding. Day 3 built it, wrote down a negative hypothesis, and stopped before the run.

Definition

One trunk, several future targets

The trunk is the usual stack of blocks, and it produces one final hidden vector hth_t per position. Head kk maps hth_t to a distribution over the token kk steps ahead, xt+kx_{t+k}. The training loss averages the heads:

LMTP=1n∑k=1nLk,Lk=−1T−k+1∑tln⁡pk(xt+k∣x≤t)\mathcal{L}_{\text{MTP}} = \frac{1}{n}\sum_{k=1}^{n} \mathcal{L}_k, \qquad \mathcal{L}_k = -\frac{1}{T - k + 1}\sum_{t} \ln p_k(x_{t+k} \mid x_{\le t})

Depth n=1n = 1 is just ordinary next-token training. Each extra depth loses k−1k - 1 positions at the end of the block, because their targets fall past the last token.

Playground
Head0123456789
inputdef total(values):\n return sum(values
t+1 total(values):\n return sum(values)
t+2(values):\n return sum(values)–
Targets available here2later targets can fall past the block
Positions lost at the end1depth k has no target for the last k − 1
Extra parameters65,5361 adapter of 256 × 256

Every head reads the same final hidden state. Depth 1 goes straight into the tied output head. Depth k first passes through its own linear adapter, so it can learn a different map to the token two or three steps ahead.

A short line of code as tokens. Each row below the input is one head's target at every position. The width setting is the Day 3 model's 256.

So what happened ?

Two designs

Parallel heads or sequential modules

The two papers Day 3 read build MTP differently:

The note picked Gloeckle's parallel form. A full Transformer block per depth would add almost a whole model's worth of parameters at 3.3 million, and then the comparison would measure capacity instead of the objective. It then made one more deviation and wrote it down, each depth gets one linear adapter instead of a Transformer layer, the cheapest thing that keeps the heads distinct.

Diagram
Design
shared trunk: embedding + blocks → hₜidentitytied unembeddingpredicts xₜ₊₁linear adaptertied unembeddingpredicts xₜ₊₂linear adaptertied unembeddingpredicts xₜ₊₃

octlm uses parallel future heads with one linear adapter per extra depth. Day 3 chose this cheaper map at 3.3M parameters so a full extra block would not turn the comparison into a capacity test. Day 5 reuses the map at width 512.

Three future tokens from one trunk, four ways. The last one is the design the note rejected.
A correction

Why the heads can't all be tied

The Day 3 plan first proposed tying every head to the embedding matrix, like the main head, so MTP would add no parameters. The note caught the mistake before any code got written. Heads that share the unembedding and read the same hidden state compute identical logits. So depth 2 would predict token t+1t + 1 again, not t+2t + 2. Each depth needs its own transformation before the shared unembedding, and octlm's model says so where it builds them:

octlm/model.pyline 443
def __init__(self, config: DecoderConfig) -> None:
    super().__init__()
    config.validate()
    self.config = config
    self.token_embedding = nn.Embedding(config.vocab_size, config.d_model)
    self.position_embedding = (
        nn.Embedding(config.context_length, config.d_model)
        if config.position == "learned"
        else None
    )
    self.dropout = nn.Dropout(config.dropout)
    self.blocks = nn.ModuleList(TransformerBlock(config) for _ in range(config.n_layers))
    self.final_norm = make_norm(config)
    self.lm_head = nn.Linear(config.d_model, config.vocab_size, bias=False)
    self.lm_head.weight = self.token_embedding.weight
    # One adapter per extra depth. Heads sharing both the trunk state and the tied
    # unembedding would compute identical logits, so each depth needs its own map.
    self.mtp_adapters = nn.ModuleList(
        nn.Linear(config.d_model, config.d_model, bias=False)
        for _ in range(config.mtp_depth - 1)
    )
    self.apply(self._initialize)

The loss shifts the targets by kk for depth kk and drops the positions that fall off the end:

octlm/train.pyline 108
def mtp_loss(model: Decoder, inputs: Tensor, targets: Tensor, pad_id: int) -> Tensor:
    """Depth k predicts token t+k, so depth k reads targets shifted by k and loses k positions."""
    depth = model.config.mtp_depth
    if depth == 1:
        logits = model(inputs)
        return F.cross_entropy(logits.flatten(0, 1), targets.flatten(), ignore_index=pad_id)
    stack = model(inputs, all_depths=True)
    losses = []
    for k, logits in enumerate(stack):
        span = targets.shape[1] - k
        shifted = targets[:, k:]
        losses.append(
            F.cross_entropy(logits[:, :span].flatten(0, 1), shifted.flatten(), ignore_index=pad_id)
        )
    return torch.stack(losses).mean()

With mtp_depth = 1 this is exactly the ordinary cross entropy, and a test checks that the logits match the pre-Day-3 model. That identity is the guarantee that adding the feature changed nothing for every model that doesn't use it.

The hypothesis

Written negative, before the run

Gloeckle et al. report a scale coupling. MTP's gains grow with model size, and small models see muted or even harmful effects on some benchmarks. At 7B on 200B tokens, their 2-future model matched the baseline and the 4-future model regressed. The gains that hold are on code and at 3B parameters and above.

So the note wrote a negative hypothesis, MTP would not improve bits per byte at 3.3M parameters. The Day 3 run never tested it. The planned run would also have measured cost and depth-2 agreement, the share of held-out positions where the depth-2 head's top guess is the true token two steps ahead. mtp_agreement in octlm/day3.py computes that rate. It's one input to deciding whether a draft head deserves a speculative-decoding experiment.

Stop condition, depths 1, 2 and 3, three seeds each. Keep only if depth 2 beats the noise floor on code, or if its agreement rate justifies a draft head. Otherwise revert and record the scale finding as the reason.

Status

Day 3 run withdrawn, Day 5 comparison built

The plan changed on 2026-09-23, before Stage 0. The code stayed. Why the plan changed explains the decision. EXP-070 now has a working local command for depth 2 on the 26M model and will compare it with the matched modern control against EXP-069's measured 0.0050 seed spread. Its GPU comparison and draft-agreement result are still pending.

Skipped

What we didn't build, and why