Caistro Labs

Poros

Bit-exact large-model fine-tuning on consumer GPUs

July 10, 2026

tl;dr: Poros fine-tunes a 72B model end-to-end in 18.1 GB of VRAM on one RTX 5090, and a 32B in 11.61 GB. The adapter it produces is bit-identical to the same training run held fully in VRAM, on every model we validate: torch.equal, not allclose. It does it by streaming the frozen base through the GPU one block at a time instead of keeping it resident.

paper · GitHub · docs

Why the frozen base sits in VRAM

Most standard single-GPU QLoRA paths (PEFT, Unsloth, hand-rolled loops) keep the frozen base resident on the GPU for the whole run. At 32B, that is roughly 18 GB of NF4 weights sitting in VRAM every step, receiving no gradients and never changing.

The base is read-only. It exists in the computation only because the forward pass needs to read its weights, block by block, as the input passes through. Between one block's forward and the next, those weights are idle. And during the optimizer step, they are completely irrelevant.

Block k is read twice per step

phasesblock kresident forwardbackwardoptimizer 2 reads 0 reads one step

Training-step timeline: block k weights stay resident for the entire step but are read at only two moments, early in the forward pass and mid backward pass, and are never read during the optimizer phase.

Resident the whole step, read at two moments, unused during the optimizer step entirely.

Backpropagation doesn't require full-base residency. QLoRA inherited that default from full fine-tuning, where the base weights actually change and need to stay on-device. Frozen-base training kept it after the reason for it was gone.

Moving model state down the memory hierarchy is not new: ZeRO-Offload pushes optimizer state and parameters to the CPU, FlexGen schedules placement across GPU, CPU, and disk for inference, and FSDP+QLoRA shards a quantized base across several GPUs. The case Poros targets is single-GPU training, where the backward pass itself has to be bounded.

Poros replaces persistent residency with streamed residency. Each transformer block's weights are needed exactly twice per micro-batch: once in the forward pass, once in the backward pass. Poros loads a block for the forward, holds activations at block boundaries, then reloads and recomputes each block during the backward in reverse order. Between those two moments, the weights are not on the GPU.

The result: 32B training drops from a 44.88 GB peak to 11.61 GB, and 72B from 81.57 GB to 18.87 GB. And the adapter that comes out is not approximately the same as a resident run's. It is the same, bit for bit. Loss, adapter weights, gradient norms, and optimizer state match a fully-resident run exactly across 200 steps, checked with torch.equal, not allclose: the difference is 0.00e+00. At 72B we verify loss and adapter weights directly, at every one of 200 steps; gradient norms and optimizer state are gated directly at 7B and 32B.

Why getting an identical trajectory requires more than just streaming weights

Streaming the frozen base reduces memory, but matching the resident trajectory bit-for-bit is a harder problem.

The catch is dropout. When Poros reruns a block's forward during the backward pass to recover its activations, that rerun resamples the dropout masks, different masks than the original forward drew. The gradients from that point on belong to a graph that never actually ran forward, so the adapter drifts from a resident run. And nothing warns you: the loss curve looks completely normal while the adapter quietly diverges.

Recompute vs the RNG stream

P global RNG naive rerun drift fork_rng 0.00e+00

Recompute versus the RNG stream: at point P a naive rerun branches off the global RNG stream and drifts further from it with every draw, while fork_rng replays land exactly on the original stream — the recomputed masks are the originals.

Recompute advances the global RNG. fork_rng holds it at P, so the recomputed masks are the originals.

Poros closes this with RNG state capture. Before each block's forward pass, the engine snapshots CPU and CUDA RNG state. During the backward, each block is reloaded once and its forward is recomputed inside torch.random.fork_rng with that exact snapshot restored. The recomputed forward reproduces the original exactly, down to the bit.

PyTorch's activation checkpointing handles the same subtlety the same way when it recomputes segments, by preserving per-segment RNG state. Poros applies that discipline at block granularity, in a runtime where the weights themselves are not resident, and verifies the result bit-for-bit end to end.

The rest of the training step is straightforward once that fix is in place. The forward streams frozen blocks through a small GPU window with asynchronous prefetch on CUDA streams; activations at block boundaries are retained. The backward walks blocks in reverse, reloads each one, recomputes with restored RNG, and propagates gradients only to the adapters and block inputs. Frozen weights are never differentiated. Then one optimizer step, identical to the step a resident run would have taken.

Snapshot forward, restore backward

forwardbackward reload +recompute B1B2B3B4B5 snapshot B5B4B3B2B1 fork_rng identical masks

Snapshot and restore schedule: the forward pass snapshots RNG before each of blocks B1 to B5; the backward pass reloads each block in reverse order and recomputes inside fork_rng with the matching snapshot restored, producing identical masks.

Forward snapshots CPU+CUDA RNG before each block; backward reloads each block and recomputes inside fork_rng with that snapshot restored.

The learning problem itself is unchanged: the same attention kernels, optimizer, LoRA configuration, data order, and loss function as a resident run.

Block-major gradient accumulation combined with dropout is not supported: it would reorder RNG consumption across micro-batches and break bitwise parity silently, so Poros raises an error instead of producing a non-exact result.

Parity holds across the whole audit matrix

We checked parity with targeted gates across these conditions rather than a single run: 200-step NF4 gates, gradient accumulation at 2 and 4 micro-batches, real Alpaca data, 1000-step extended audits, and dedicated dropout RNG gates. Every gate comes back 0.00e+00. The run artifacts are committed in docs/validation/.

The 1000-step audits hold exact parity at every checkpoint, ruling out slow drift as distinct from a step-200 match. The mode-sweep gate re-runs parity with deterministic algorithms off, default fast SDPA, TF32 enabled, bf16 autocast, and torch.compile active; the faster kernels don't disturb it. That sweep gates forward parity per mode; the 200-step gates above are the full four-surface audits.

One loss curve, zero difference

2.51.50.6 loss residentPoros difference resident − Poros 0.00e+00 200 steps

Loss parity over 200 steps of real 32B training: resident and Poros draw one identical curve; the per-step difference below is exactly zero at every step.

Real 32B data. Resident and Poros draw one curve; the per-step difference below is 0.00e+00 at every step.

Bitwise parity is verified per model family on real hardware:

  • Qwen2.5 (7B–72B, incl. DeepSeek-R1-Distill-Qwen): RTX 5090 and RTX 3090 at 7B; A100, B300, PRO 6000, and RTX 5090 at 32B; datacenter hardware at 72B, with the end-to-end run on an RTX 5090
  • Qwen3 (32B): B300, RTX 5090
  • Qwen3.5 / Qwen3.6 dense: gated at 4B on an RTX 4090; the 0.8B–27B dense range shares that one validated implementation
  • Gemma3 (27B): RTX 5090
  • Gemma4 (31B): RTX 5090

Each result is exact within its own hardware and software stack.

The memory savings grow with scale

At 32B, with a rank-16 LoRA adapter, NF4, and sequence length 512, peak training memory drops from 44.88 GB to 11.61 GB, which fits a 16 GB budget with 4.39 GB to spare. That budget fit is arithmetic, not a separate run; the smallest card we measured on directly is 24 GB. The same run on a consumer RTX 5090 peaks a touch lower, at 11.02 GB.

Block size trades memory for step time: at 32B, a block size of 1 peaks at 10.48 GB, 2 at 10.73, 4 at 11.61, 8 at 14.01, and 16 at 18.67; larger blocks buy back step time at the cost of a higher peak.

Sequence length moves the peak sub-linearly, because block weights rather than activations dominate at single-block residency: at 7B, going from sequence length 128 to 1024 raises the peak from 6.13 GB to 8.13 GB. The headline numbers are at 512, and parity is additionally gated at sequence length 4096 at 32B.

At 72B, peak training memory drops from 81.57 GB to 18.87 GB. Qwen2.5-72B loads and trains end to end on a single RTX 5090: the recorded run measured an 18.13 GB training peak, with a training loss that matches the same configuration on an RTX PRO 6000 to about 3e-5. The training peak fits a 24 GB budget; a measured 24 GB end-to-end run is future work. (The bitwise-parity gate at 72B ran on datacenter hardware; the 5090 run establishes the memory envelope.)

The same-card comparison at 32B on an RTX 3090 (24 GB):

32B against the 24 GB card

01530GB 24 GB card PEFT QLoRA Accelerate Unsloth Poros OOM OOM 22.29 11.61 12.39 GB headroom

32B against the 24 GB card: PEFT QLoRA and Accelerate CPU-offload OOM; Unsloth fits at 22.29 GB; Poros trains at 11.61 GB, leaving 12.39 GB of headroom under the 24 GB line.

PEFT QLoRA and Accelerate CPU-offload OOM the 24 GB card. Unsloth fits at 22.29 GB with its own gradient-checkpointing mode. Poros trains at 11.61 GB.

The Accelerate result follows from how device-map offload works: it changes where the weights live, but the backward pass is still standard autograd, and its working set grows with model size until it exceeds the card. Poros bounds the backward as well, rematerializing and releasing each block; that bounded backward is what makes the 11.61 GB possible. That is 48% less VRAM than Unsloth here and 46.9–54.9% less on the equivalent A100 run. The two solve different problems: Unsloth optimizes the resident path, Poros changes what has to be resident. Where a model already fits comfortably resident, the resident path remains the right choice.

Peak VRAM by scale

0 40 80 GB 2.2× resident Poros 3.9× 4.3× 7B 32B 72B 15.24 6.90 44.88 11.61 81.57 18.87

Peak training VRAM, resident versus Poros, at three model scales: 7B falls from 15.24 to 6.90 gigabytes, 32B from 44.88 to 11.61 gigabytes, and 72B from 81.57 to 18.87 gigabytes, so the saving grows with scale from 2.2 times to 4.3 times.

Peak training VRAM, resident vs Poros. The saving grows with scale.

The loader streams too

The training-time savings assume the model can be loaded onto the card at all. By default, Poros loads the model the way it trains it: streamed, so the full base never materializes on the GPU. The standard load does materialize it, staging the whole quantized base before the block manager can evict anything; at 72B that peak is 38.80 GB, enough to run a 24 GB card out of memory before training ever starts.

The block-streamed NF4 loader stages one tensor at a time, quantizes it, and evicts before loading the next. Load-time GPU peak:

  • 0.27 GB at 7B (vs 5.30 GB standard)
  • 0.81 GB at 32B (vs 18.16 GB standard)
  • 1.63 GB at 72B (vs 38.80 GB standard)

The output is byte-identical to a standard load, checked against sha256 manifests at 0.5B, 7B, 32B, and 72B across the four families in the paper's matrix. The 72B byte gate passed on A100 across 3,765 tensors, 0 mismatched. Training parity re-gates at 0.00e+00 under the streamed loader at 7B and 32B.

This is the default. nf4_load_strategy="auto" selects streamed load with standard fallback.

Host RAM follows the same pattern: the standard path stages the whole checkpoint through host memory while it quantizes, and streaming removes that transient.

Host RAM at load time

0 100 GB 64 GB host 1.2× standard streamed 1.8× 2.8× 7B 32B 72B 15.1 12.1 62.0 34.7 136.5 48.2

Host RAM at model load, standard versus streamed, at three model scales: 7B falls from 15.1 to 12.1 gigabytes, 32B from 62.0 to 34.7 gigabytes, and 72B from 136.5 to 48.2 gigabytes, putting a 72B fine-tune inside a 64 gigabyte RAM host.

Host RAM, streamed vs standard load. A 72B fine-tune fits a 64 GB-RAM host.

The cost: step time

Poros is slower than resident training. On cards where both can run, expect roughly 1.6× to 2.7× per step depending on the hardware; the ratio looks worse on a B300 only because the resident baseline there is already so fast that any fixed streaming cost inflates it. The more common case is that the resident baseline does not run at all: 32B on an RTX 3090 OOMs resident, and Poros trains it at 3.21 s/step. In exchange, Poros trains at scales where PEFT and Accelerate cannot run, with a trajectory that matches a resident run exactly.

Most of the overhead is transfer, and it is predictable. Each micro-batch streams the base twice: once in the forward, once in the rematerialized backward. On an RTX PRO 6000 at gradient accumulation 1, that transfer is about 716 ms of a 1,101 ms step at 32B. The full per-card timings are in the paper.

Host memory is the other cost: provision for the streamed-loader peaks (34.7 GB at 32B, 48.2 GB at 72B). Steady state settles lower once the load transient passes.

Step time on an RTX 3090

0 1.5 3.0 s 7B32B PEFT QLoRA Accelerate Unsloth Poros PEFT QLoRA Accelerate Unsloth Poros 0.51 0.73 0.53 0.87 OOM OOM 2.07 3.21 +1.14 s

Step time on a single RTX 3090: at 7B, PEFT QLoRA takes 0.51 seconds, Accelerate 0.73, Unsloth 0.53 and Poros 0.87; at 32B, PEFT QLoRA and Accelerate out-of-memory, Unsloth takes 2.07 seconds and Poros 3.21 seconds, 1.14 seconds longer but the only stack that fits 32B under 12 gigabytes.

Step time on a single RTX 3090. Poros runs the longest step but is the only stack that fits 32B under 12 GB. PEFT and Accelerate OOM, Unsloth needs roughly double the VRAM.

Future work

We're working on two things next.

Full-precision streaming. The same blockwise residency works without quantization. The repo includes an experimental BF16 streaming runtime that passes the same bitwise training gates at 32B: an 11.40 GB peak against roughly 65 GB of resident bf16 weights, with bitwise parity at both 0.5B and 32B. It is currently unoptimized and slow; making it fast is the goal for the next release.

Multi-GPU and additional architectures. Poros targets a single GPU and the validated dense families today. Both extensions are held to the same requirement before anything is marked validated: byte-identical load and bitwise training parity, with the run artifacts committed.

Frozen-base fine-tuning never needed the whole model in VRAM. Poros gives it up, pays for it in step time, and proves with torch.equal that nothing else changed. It runs on the card you already own: pip install poros-train[ml], star it on GitHub, and the paper has the rest.

Get running

pip install "poros-train[ml]"

One line on top of your existing PEFT script:

import poros
model = poros.prepare(model)   # your loop, your config, untouched
Poros prepared model
        model  Qwen/Qwen2.5-7B
   model_type  qwen2
 architecture  validated
      adapter  lora (validated)
    precision  NF4
        stack  not checked (run poros doctor)
   block_size  4
    guarantee  bitwise parity (NF4, deterministic)

Poros is Apache-2.0, Linux/CUDA, single GPU, dense models only. Requirements, supported families, and the full quickstart are in the repository; architecture requests go through issues.

paper · GitHub · docs

@techreport{tigres2026poros,
  title       = {Poros: Bit-Exact Large-Model Fine-Tuning on Consumer GPUs},
  author      = {Tigres, Jeffrey},
  institution = {Caistro Labs},
  year        = {2026},
  url         = {https://caistrolabs.com/poros-paper.pdf}
}