CorX Labs

Technical breakdown

3 billion tokens on one GPU.

Everyone asks the same thing when they hear CorX1.5 was pretrained on ~3 billion tokens with a single GPU: how did all of that fit? The answer is that it never had to. Here is what actually has to fit, and what it costs.


The wrong mental model

3 billion training tokens never need to fit in GPU memory. Tokens stream through the GPU a batch at a time and are thrown away. What has to fit is the model weights, their gradients, the optimiser state, and the activations for a single micro-batch. For a 158M-parameter model that is a couple of gigabytes — a number that does not change whether you train on 3 million tokens or 3 billion.

Dataset size is a disk and wall-clock problem. Model size is the memory problem. Confusing the two is the single most common reason people think training from scratch is out of reach.

Once you separate those two questions, "3 billion tokens on one GPU" stops sounding impossible and starts sounding like a scheduling problem. The rest of this post is the arithmetic behind both halves.

What actually lives in VRAM

CorX1.5 has 157.8 million parameters. Everything below follows from that one number. In fp32, each parameter costs 4 bytes — and the optimiser needs its own copies.

Per-parameter memory cost for a 157.8M parameter model
WhatBytes / paramTotal at 157.8M
Weights (fp32)40.63 GB
Gradients (fp32)40.63 GB
Adam first moment40.63 GB
Adam second moment40.63 GB
Subtotal162.52 GB
Adam moments, 8-bit instead20.32 GB (saves 0.95 GB)
Subtotal with 8-bit Adam101.58 GB

The thing worth staring at is the middle of that table: the optimiser costs twice what the model does. Adam keeps a running mean and variance for every single parameter, so a 0.63 GB model drags 1.26 GB of bookkeeping behind it. Swapping in an 8-bit Adam implementation quantises those two moment buffers to one byte each and hands back nearly a gigabyte for roughly no quality cost. On a small card, that one change is often the difference between a batch size of 4 and a batch size of 12.

On top of that sits activation memory — the intermediate tensors kept alive between the forward and backward pass. Unlike the numbers above, this one scales with batch size and sequence length, which is exactly why it is the lever you pull when you run out of room.

The rule of thumb: model state is fixed and you pay it once. Activation memory is variable and you pay it per sequence in flight. If you are out of memory, you almost always shrink the second one — not the model.

The 6 GB file that never touches the GPU

Here is where the 3 billion actually lives. Before training starts, the entire corpus — 25 open datasets spanning code, maths, reasoning, conversation and general text — gets tokenised once, offline, and written into a single flat binary file of raw token IDs. No JSON, no per-example records, no tokenising during training.

CorX1.5's vocabulary is 32,768 tokens. That number matters more than it looks:

Dataset size on disk by token dtype
Token dtypeMax vocab3B tokens on disk
uint1665,5366 GB
int322,147,483,64712 GB

A 32,768-entry vocabulary fits inside a uint16 with room to spare, so the whole pretraining corpus is 6 GB on disk — not 12. That halving is free, and it halves the disk bandwidth the training loop needs to keep the GPU fed.

The file is then read with a memory map. The operating system pages in only the slices actually touched and evicts the rest, so RAM usage stays bounded no matter how big the file grows, and the process never loads 6 GB into anything:

import numpy as np, torch

# 3e9 tokens, uint16, sitting on disk. Nothing is loaded here.
data = np.memmap("train.bin", dtype=np.uint16, mode="r")

def get_batch(batch_size, block_size):
    # Sample random windows straight out of the mapped file.
    ix = torch.randint(len(data) - block_size - 1, (batch_size,))
    x = torch.stack([torch.from_numpy(data[i     : i+block_size  ].astype(np.int64)) for i in ix])
    y = torch.stack([torch.from_numpy(data[i + 1 : i+block_size+1].astype(np.int64)) for i in ix])
    return x.pin_memory().to("cuda", non_blocking=True), \
           y.pin_memory().to("cuda", non_blocking=True)

Sampling random offsets also means there is no shuffle step. Shuffling 3 billion tokens as records would be its own engineering project; sampling random windows out of a mapped array gets you the same decorrelation for free, and it makes the dataset trivially resumable — position is just an integer.

This is the actual answer to the question. The 3 billion tokens are a 6 GB file on a disk. The GPU sees a few thousand of them at a time, for a few milliseconds, and then forgets them. Nothing about the dataset size ever appears in a VRAM figure.

Packing: every token has to do work

Because the corpus is one continuous stream of token IDs, documents are concatenated end to end with a separator and then sliced at exactly 1,024 tokens. Every position in every batch carries a real token.

The naive alternative — one document per row, padded out to the block size — quietly throws away a large share of every batch. If your documents average 300 tokens and you pad them to 1,024, roughly 70% of the compute in every step is spent on padding tokens that contribute nothing. On a cluster that is money. On one GPU it is the difference between finishing and not finishing.

The related trick is on the other side of the loss function. CorX1.5 uses assistant-only loss masking: during instruction tuning the model reads the user's tokens as context but only ever gets a gradient for the tokens it is supposed to produce. Same forward pass, same cost, and none of the capacity is spent learning to predict the user's half of the conversation — which is not a skill the model needs.

Gradient accumulation: "batch size" is two different numbers

Language models train best with large batches — hundreds of thousands of tokens per optimiser step. A single consumer GPU cannot hold that. These two facts look like a contradiction and are not, because batch size means two separate things:

  • The micro-batch — how many sequences are in flight at once. This is a memory constraint.
  • The effective batch — how many sequences the optimiser sees before it takes a step. This is a learning-dynamics constraint.

Gradient accumulation decouples them. Run the forward and backward pass on a small micro-batch, keep the gradients instead of stepping, repeat, and only call optimizer.step() once you have accumulated enough. The gradients sum exactly as if you had run one big batch — the memory just never went up.

accum_steps = 64          # micro-batches per optimiser step
optimizer.zero_grad(set_to_none=True)

for micro in range(accum_steps):
    x, y = get_batch(batch_size=8, block_size=1024)
    with torch.autocast("cuda", dtype=torch.bfloat16):
        loss = model(x, y) / accum_steps      # scale so the sum is a mean
    loss.backward()                            # gradients accumulate in place

torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
scheduler.step()

Worked through with those numbers:

Tokens per step and total steps
QuantityWorkingResult
Tokens per forward pass8 sequences × 1,0248,192
Tokens per optimiser step8,192 × 64524,288
Steps to reach 3B tokens3,000,000,000 ÷ 524,288≈ 5,722

Roughly 5,700 optimiser steps for the whole pretraining run. That is a surprisingly small, very human number — and it is why the learning-rate schedule matters so much at this scale. There is no room for a slow warm-up over tens of thousands of steps; every step is 0.02% of the entire budget.

Micro-batch is the knob you turn for memory. Accumulation steps is the knob you turn to keep the effective batch constant while you do it. Halve one, double the other, and the training run is mathematically unchanged.

Precision and attention: the cheap wins

Three settings do a disproportionate amount of the work.

bfloat16 autocast. Running the forward and backward pass in bf16 roughly halves activation memory and lets the GPU use its tensor cores. bf16 keeps fp32's exponent range, so unlike fp16 it does not need loss scaling and does not silently produce infinities halfway through a long run. If the card supports it, there is no reason not to.

Fused attention. A naive attention implementation materialises the full scores matrix — shape batch × heads × 1024 × 1024. At a micro-batch of 8 with, say, 16 heads, that single tensor is about 268 MB in bf16, per layer. A fused kernel (F.scaled_dot_product_attention, or FlashAttention) never writes it to memory at all, computing the softmax in tiles instead. This is usually the largest single activation saving available.

Gradient checkpointing. The escape hatch. Discard intermediate activations during the forward pass and recompute them during the backward pass — roughly 30% slower per step in exchange for a large drop in activation memory. At 10 layers and a 1,024-token window CorX1.5 is small enough that this is a last resort rather than a default, but it is what you reach for when nothing else will fit.

One more, almost free: torch.set_float32_matmul_precision("high") to enable TF32 on the remaining fp32 matmuls.

Why the context window is 1,024 tokens

This was a budget decision, not a technical limit. Attention cost grows with the square of sequence length while everything else grows linearly, so doubling the context window to 2,048 more than doubles the memory and compute per sequence.

Given a fixed compute budget, that money can be spent two ways: fewer, longer sequences, or more, shorter ones. For a model this small, more tokens wins. A 158M model has nowhere near enough capacity to exploit long-range structure across 4,000 tokens; it has plenty of room to get better at ordinary sentences. Spending the budget on breadth rather than reach was the right trade — and it is honestly documented as a limitation on the model page rather than dressed up as a feature.

The real constraint is wall-clock

Once everything fits, the only remaining question is time — and it is pure division. Throughput depends on the card, the kernels and how well the data loader keeps up, so here is the shape of it rather than one number:

Time to process 3 billion tokens at various throughputs
ThroughputTime for 3B tokensIn practice
10,000 tok/s≈ 83 hoursabout 3.5 days
25,000 tok/s≈ 33 hoursa day and a half
50,000 tok/s≈ 17 hoursovernight
100,000 tok/s≈ 8 hoursa working day

Which tells you where optimisation effort belongs. Nobody's training run is bottlenecked on a clever architecture choice — it is bottlenecked on tokens per second. A change that makes each step 20% faster gives back most of a day. A change that makes the model 2% better on validation loss but 40% slower costs you more than it is worth at this scale.

And the most embarrassing bottleneck is always the same one: a GPU sitting idle waiting for data. If nvidia-smi shows utilisation bouncing rather than pinned, the fix is in the data loader, not the model.

Surviving a multi-day run on one machine

This is the part that separates a bedroom setup from a lab, and it has nothing to do with machine learning. A run measured in days on a single machine will be interrupted. Power, a driver update, a full disk, an out-of-memory error at step 4,000.

So the checkpoint has to contain everything needed to continue as if nothing happened — not just the weights:

  • Model state_dict
  • Optimiser state_dict — those Adam moments are hard-won; losing them costs real progress
  • Learning-rate scheduler state and the step counter
  • RNG state, so the data sampling continues rather than restarts
  • The config used, so a checkpoint is never ambiguous about what produced it

Write them on a fixed step interval, keep the last few plus the best-by-validation, and never overwrite in place — write to a temporary file and rename, so a crash mid-write cannot destroy the only good checkpoint you have. I have learned every item on that list the expensive way.

Knowing when to stop

A held-out validation split is carved out before training starts and never touched by the training sampler. Validation loss is evaluated on a fixed schedule, and training stops when it stops improving — early stopping.

The reason this matters more for a small model than a large one: with 158M parameters and billions of tokens, training loss will keep drifting down long after the model has stopped getting genuinely better. Without a held-out split you cannot tell the difference between learning and memorising, and you will happily burn a day of compute making the model worse.

The training loss tells you the optimiser is working. Only the validation loss tells you the model is.

What I'd do differently

  • Instrument tokens per second from step one. I spent time tuning things that did not matter before I was measuring the thing that did.
  • Validate the tokenised file before training, not after. A bug in a preprocessing script is invisible until it has cost you a day.
  • Write the resume path first. Building checkpoint-and-resume before the first long run, instead of after the first crash, would have saved more time than any optimisation on this list.
  • More data cleaning, less architecture fiddling. At this scale the corpus moved validation loss more than any hyperparameter I touched.

None of this is exotic. Every technique here is published, and most of it is a few lines of PyTorch. The reason it is worth writing down is that when you are starting out, the hard part is not the trick — it is not knowing which question you are actually asking. "How do I fit 3 billion tokens on one GPU" has no answer. "How do I fit 158 million parameters plus one batch on one GPU, and how long does streaming 3 billion tokens past it take" has two, and both of them are arithmetic.

FAQ

Do 3 billion tokens have to fit in GPU memory?

No. Tokens stream through the GPU a batch at a time. What has to fit is the weights, gradients, optimiser state and the activations for one micro-batch. The full corpus lives on disk as a flat binary and is read through a memory map, so dataset size is a disk and throughput question, not a VRAM one.

How much VRAM does a 158M model need to train?

About 2.5 GB of model and optimiser state in fp32 with Adam, or roughly 1.6 GB with an 8-bit optimiser — plus activation memory, which depends on your micro-batch and sequence length and is the part you actually tune.

How big is 3 billion tokens on disk?

6 GB, if your vocabulary is under 65,536 so each token fits in a uint16. CorX1.5's 32,768-token vocabulary fits comfortably. Storing the same tokens as int32 would make it 12 GB.

Could I do this with a smaller GPU?

Almost certainly, at this model size — drop the micro-batch, raise the accumulation steps to keep the effective batch the same, and turn on 8-bit Adam and gradient checkpointing. The run gets slower, not impossible. Memory sets what you can train; throughput sets how long you wait.

The model

This is what came out of it.

CorX1.5 — 158M parameters, trained from random weights, open-sourced under Apache 2.0.