3Day 3

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.

Not measuredEXP-016

Next-token prediction gives the model one target per position. Multi-token prediction (MTP) gives it several targets, the next token, the one after, 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. 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 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 at this position2tokens 3 to 4
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.

What happened?

Two designs

Parallel heads or sequential modules

The two papers Day 3 read build MTP differently:

The note chose Gloeckle's parallel form. A full Transformer block per depth would add almost a model's worth of parameters at 3.3 million, and the comparison would measure capacity instead of the objective. It then made one more deviation and recorded it: 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: Gloeckle's parallel form with a single linear adapter per depth instead of a Transformer layer. At 3.3M parameters a full block per depth would compare capacity, not the objective.

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

Why the heads cannot all be tied

The Day 3 plan first proposed tying every head to the embedding matrix, as the main head is, so MTP would add no parameters. The note caught the error before any code. Heads that share the unembedding and read the same hidden state compute identical logits. 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 371
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 does not 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 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 the hypothesis in the negative. MTP does not improve bits per byte at 3.3M parameters. What the run would actually measure is the cost, and the depth-2 agreement rate, which is how often the depth-2 head's top guess is the true token two steps ahead, measured by mtp_agreement in octlm/day3.py. That rate is the number that says whether a free draft head for speculative decoding is worth building.

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

Not run. Moved to Day 5 as EXP-070

The plan changed on 2026-09-23, before Stage 0. The code stayed. Why the plan changed explains the decision. EXP-070 runs depth 2 against the same control on the 20M model, after EXP-069 measures the seed spread at that scale, and the hypothesis stays negative.

Skipped

What we did not build, and why