Complete Technical Guide

From Raw Data to a
Fine-Tuned 32B Model.

Two guides in one logical sequence — first build the mental model of what fine-tuning actually does, then learn how QLoRA makes it feasible on a single GPU.

Part 1
Fine-tuning fundamentals
+
Part 2
QLoRA deep-dive
0
chapters end-to-end
Start from first principles
Part 1 · Step 01

The Big Picture

A pre-trained LLM already knows language. Fine-tuning teaches it your specific task — by showing it examples of the behavior you want.

An LLM like Qwen 2.5 Coder has been pre-trained on billions of tokens of text and code. It understands syntax, semantics, and patterns. But it doesn't know your coding style, your domain, or how you want it to respond.

Fine-tuning bridges this gap. You provide training pairs — examples of inputs and desired outputs — and the model adjusts its internal weights to produce similar outputs for similar inputs.

But how exactly does a sequence of JSON messages turn into weight updates inside a neural network? That's what this guide explains, step by step.

i

We use LoRA (Low-Rank Adaptation) during fine-tuning, which means only a small set of adapter weights are updated while the original billions of parameters stay frozen. Part 2 of this guide explains exactly how that works.

Part 1 · Step 02

The ChatML Prompt Template

Every model family expects a specific format for structuring conversations. Qwen 2.5 uses ChatML — and your training data must follow it exactly.

The ChatML format wraps every message with special tokens: <|im_start|> marks the beginning and <|im_end|> marks the end. Each message is tagged with a role: system, user, or assistant.

ChatML Format — Qwen 2.5
<|im_start|>system
You are a helpful coding assistant.<|im_end|>
<|im_start|>user
Write a Python function to add two numbers.<|im_end|>
<|im_start|>assistant
def add(a, b):
    return a + b<|im_end|>
!

You don't format this manually. When using HuggingFace TRL, the tokenizer's apply_chat_template() handles all formatting automatically. You just provide structured JSON.

Part 1 · Step 03

Dataset Preparation

Structure your training examples as a list of messages. TRL's SFTTrainer handles the rest.

The Required Format

Each training example is a dictionary with a "messages" key containing a list of role-content pairs:

JSON — Training Example
{
  "messages": [
    {"role": "system",    "content": "You are a helpful coding assistant."},
    {"role": "user",      "content": "Add two numbers in Python"},
    {"role": "assistant", "content": "def add(a, b):
    return a + b"}
  ]
}
!

Common gotcha: The column must be named "messages" — not "conversations". TRL specifically looks for this key to trigger automatic chat template application.

Padding Strategies

Training pairs have different lengths. When batched, shorter sequences need pad tokens. Three strategies exist:

StrategyHow It WorksBest For
Naive PaddingPads each batch to longest sequence. Simple but wastes compute on pad tokens.Long examples
PackingConcatenates examples into one sequence via packing=True in SFTConfig.Short examples (2–5× speedup)
UnslothOptimized fused kernels for padding, attention, and loss masking.Maximum efficiency
Part 1 · Step 04

Tokenization

The tokenizer converts human-readable text into a sequence of integer IDs that the model can process.

After apply_chat_template() + tokenizer.encode(), our example becomes a sequence of integers. Watch each token appear:

T
Token Sequence After Encoding
TOKENS — watch them appear in sequence
<im_s> system \n Be helpful . <im_e> <im_s> user \n Hi <im_e> <im_s> asst \n def add <im_e> PAD
POSITIONS
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
System
User
Assistant
Special
Padding

Each token maps to a unique integer ID in Qwen's vocabulary of 151,936 tokens. For example: <im_s> → 151644, def → 755, add → 912.

Part 1 · Step 05

The Shift-by-One

An LLM is a next-token predictor. The same sequence is used as both input and target, shifted by one position.

At every position, the model sees all tokens up to and including that position, and must predict the next token. Watch the target row shift into place:

The Shift-by-One Mechanism
POS
INPUT
↓   predicts   ↓
TARGET

At position 14, the model sees everything up to \n (newline after "assistant") and must predict def. At position 15, it sees up to def and must predict add.

i

Key insight: The model sees system and user tokens through attention — they provide context. But we only compute loss at assistant positions. This is where loss masking comes in next.

Part 1 · Step 06

Loss Masking

We don't want the model to learn to predict system or user tokens. Only assistant responses matter.

Labels for non-assistant positions are set to -100 — PyTorch's ignore index. Watch which tokens get masked vs trained:

M
Loss Mask Applied to Our Example
TOKENS
<im_s>
system
\n
Be
helpful
.
<im_e>
<im_s>
user
\n
Hi
<im_e>
<im_s>
asst
\n→def
def→add
add→<im_e>
PAD
LABELS (−100 = ignored by loss)
−100
−100
−100
−100
−100
−100
−100
−100
−100
−100
−100
−100
−100
−100
755
912
151645
−100
Masked (−100, ignored)
Trained (loss computed here)
Padding

Multi-Turn Masking

For multi-turn conversations, every assistant turn is trained — every user/system turn is masked. SFTConfig's assistant_only_loss=True does this automatically.

sys
user₁
asst₁
user₂
asst₂
user₃
asst₃
Part 1 · Step 07

Cross-Entropy Loss

At each unmasked position, the model outputs a probability distribution over the full vocabulary. Loss measures how wrong it is.

For our three trained positions, the model must predict: def (pos 14), add (pos 15), and <im_e> (pos 16). Here's what the untrained model actually predicts:

POSITION 14 — Predict "def"
Model sees: ...<im_s>assistant\n → should output def
"return"
0.20
"def" ✓
0.12
"class"
0.08
others
0.60
Loss = −log(0.12) =2.12← model was uncertain
POSITION 15 — Predict "add"
Model sees: ...assistant\n def → should output add
"main"
0.11
"add" ✓
0.06
"print"
0.05
others
0.78
Loss = −log(0.06) =2.81← model was wrong
POSITION 16 — Predict "<im_e>"
Model sees: ...assistant\n def add → should output <im_e>
"("
0.35
"\n"
0.10
"<im_e>" ✓
0.03
others
0.52
Loss = −log(0.03) =3.51← expected "(" not end

After Many Training Steps

PositionCorrect TokenBefore TrainingAfter Training
14defP = 0.12 → Loss = 2.12P = 0.88 → Loss = 0.13
15addP = 0.06 → Loss = 2.81P = 0.79 → Loss = 0.24
16<im_e>P = 0.03 → Loss = 3.51P = 0.82 → Loss = 0.20

Total loss: 2.81 → 0.19. The model has learned to confidently produce the correct assistant response.

Part 1 · Step 08

The Complete Pipeline

The entire journey from a JSON training pair to updated model weights — in eight steps.

01

Training Pair (JSON)

Your dataset with "messages" column containing system, user, and assistant turns.

02

apply_chat_template()

The Qwen tokenizer wraps messages in ChatML format with <|im_start|> and <|im_end|> tokens.

03

Tokenization → Token IDs

The formatted string converts to a sequence of integers: [151644, 8948, 198, ...]

04

Shift by One Position

Input = [tok₀, tok₁, …, tokₙ₋₁] and Target = [tok₁, tok₂, …, tokₙ]

05

Loss Masking

System, user, and pad token labels set to -100. Only assistant positions retain true token IDs.

06

Forward Pass → Logits

At every position, the model outputs a probability distribution over all 151,936 vocabulary tokens.

07

Cross-Entropy Loss

At each unmasked position: loss = −log(P(correct_token)). Total = average of unmasked losses.

08

Backprop → LoRA Weight Update

Gradients flow to LoRA adapter weights (A, B matrices) only. Base weights stay frozen.

Repeat

This cycle runs for every batch, every step, every epoch.

i

Part 1 complete. You now know what fine-tuning is, how data flows through the model, and how the model learns. Part 2 tackles the harder question: how do we do this efficiently on a 32-billion parameter model that doesn't even fit in GPU memory?

End of Part 1 · Beginning of Part 2

Now let's scale it up.
Fine-tuning a 32B model
on one GPU.

Part 1 showed you what fine-tuning does. Part 2 explains QLoRA — the technique that makes fine-tuning a 32-billion parameter model feasible on a single A100, without sacrificing quality.

NF4 4-bit weights LoRA adapters BitsAndBytes PEFT → Part 2 starts here
Part 2 · Chapter 01 — The Memory Problem

128 GB.
That's the wall.

Storing a 32B model in FP32 takes 128 GB just for weights. Add optimizer state and you need 384 GB — three A100s before you train a single step.

FP32 weights
128 GB
128 GB
+ AdamW optim
384 GB
384 GB
BF16 weights
64 GB
64 GB
LoRA only
64 GB base + adapter
~64 GB
QLoRA ✓
~25 GB total
~25 GB

The core problem: GPUs have fixed memory. You cannot fine-tune what you cannot load.

Quantization shrinks the weights. LoRA shrinks what needs updating. Together they make a 32B fine-tune fit on one A100-80GB with room to spare.

Three ideas.
One solution.
1
🗜

NF4

Store each weight in 4 bits — not 32. A specially designed codebook makes this near-lossless for normally-distributed weights.

Saves vs BF16  ·  4.127 bits/param
2
🔧

LoRA

Don't update 32B weights. Add two tiny matrices whose product approximates the needed change. Train only those.

Only 0.41% of params  ·  270 MB adapter
3
📄

Paged Optim

Optimizer state pages to CPU on memory spikes, pages back at the optimizer step. No OOM from gradient checkpointing bursts.

Costs ~0 throughput  ·  absorbs spikes
Part 2 · Chapter 02 — Normal Float 4

16 levels.
Placed where
weights actually live.

Standard 4-bit quantization wastes levels on values that rarely appear. NF4 uses information theory to concentrate precision exactly where pretrained weights cluster.

The bell-curve insight

Pretrained LLM weights follow a normal (Gaussian) distribution. Most values are tiny — near zero. A handful reach ±1.

Uniform 4-bit quantization spaces its 16 levels evenly across the full range, wasting precision on the sparse tails. NF4 places levels at the quantiles of the bell curve — equally probable regions — so every level is used equally often.

exact 0 -1.0 peak density +1.0 dense here sparse sparse NF4 codebook — 16 levels at N(0,1) quantiles

Try it — quantize a weight

Drag the slider to set a weight value and see which NF4 level it snaps to.

// Interactive weight quantizer
0.37
Input
0.37
Nearest NF4
0.338
Index (4-bit)
Error
0.032

Double Quantization

Each block of 64 weights needs one scale factor (absmax). That's 0.5 extra bits per weight. QLoRA quantizes those scales too:

NF4 weights: 4.000 bits/param FP8 scales: 0.125 bits/param ← (was 0.5, now compressed) FP32 meta: 0.002 bits/param ──────────────────────────────── Total: 4.127 bits/param (vs 16 for BF16)

That extra compression saves ~1.5 GB on a 32B model at essentially zero accuracy cost.

How numbers are stored — bit explorer

Every format uses its bits differently. Click between formats to see exactly how 5.75 is encoded.

FormatBitsExponentMantissaMax valueQLoRA role
FP32328233.4 × 10³⁸Optimizer master copy only
BF16 COMPUTE168 = FP3273.4 × 10³⁸LoRA adapters + all matmuls
FP161651065,504Avoid in QLoRA RISKY
NF4 STORE4codebookcodebook[-1, 1]Frozen base weights
Part 2 · Chapter 03 — Low-Rank Adaptation

Don't update
32 billion weights.
Update 134 million.

LoRA's observation: the update a model needs is low-rank. You can approximate it with two thin matrices — and only train those.

W₀ 5120 × 5120 FROZEN · NF4 + B 5120×16 INIT: 0 A 16 × 5120 Frozen base (NF4) Trainable (BF16) Trainable (BF16) B × A = ΔW (approx. low-rank update) h = W₀·x + (α/r) · B · A · x gradients flow here

Live parameter count

Adjust r to see the real numbers for Qwen 2.5 Coder 32B (H=5120, 64 layers, 7 target modules).

// LoRA rank slider — Qwen 2.5 Coder 32B
Trainable params
134 M
A + B matrices, all layers
% of 32.76B
0.41%
vs base model total
Adapter size
270 MB
BF16 checkpoint

Target modules

For code generation on Qwen 2.5, target all 7 linear projections. Skipping MLP layers loses significant quality.

// Attention
q_projk_projv_projo_proj
// MLP (SwiGLU)
gate_projup_projdown_proj
// Skip unless adding vocab tokens
embed_tokenslm_head
💡

rsLoRA (use_rslora=True): scales by α/√r instead of α/r. Prevents gradients shrinking at high rank. Use at r ≥ 32. DoRA (use_dora=True): separates magnitude and direction updates, matches full fine-tuning quality at low ranks.

Part 2 · Chapter 04 — QLoRA

NF4 base.
BF16 adapter.
They collaborate.

The frozen NF4 base and the trainable BF16 adapter never interact directly — but the adapter learns to compensate for NF4's rounding errors as part of its task adaptation.

Per-layer forward pass — animated

Every forward pass through a Linear4bit layer runs two paths in parallel and adds their outputs.

x BF16 input FROZEN WEIGHT PATH W^NF4 → lookup → ×c₂^FP8 → ×c₁^FP32 → W^BF16 · x = y_base y_base BF16 TRAINABLE LORA PATH A^BF16 (r×k) → A·x → B^BF16 (d×r) → (α/r) · B·(A·x) = y_lora y_lora BF16 + y BF16 out W^BF16 is transient — dequantized per layer, used for matmul, immediately freed. Never persists across layers.

Three categories of memory — and what each one means

Every byte in GPU memory during QLoRA training belongs to one of three categories. Understanding which is which tells you exactly what you can change, what you cannot, and where OOMs come from.

Component Size What it is Why it matters
🔴 Frozen  —  requires_grad=False · loaded once, never written · persists in HBM all run
W^NF4 16.4 GB 32.76B params × 4 bits ÷ 8. Each weight is a 4-bit index into the NF4 codebook, packed 2 per byte. The dominant cost. prepare_model_for_kbit_training calls model.requires_grad_(False) — PyTorch allocates zero gradient buffers for it, saving ~16.4 GB vs full fine-tuning.
DQ scales ~0.5 GB c₂^FP8: one absmax per 64-weight block. c₁^FP32: one meta-scale per 256 blocks of c₂. Needed to reconstruct W^BF16 in two dequant steps per forward pass. Without DQ this costs 0.5 bits/param; with DQ it drops to 0.127 bits/param.
embed + lm_head ~3.1 GB 152,064 × 5,120 × 2 bytes (BF16). Not quantized — bitsandbytes only touches inner Linear layers. Frozen by default. Add to modules_to_save only when adding new vocabulary tokens — PEFT then saves a full BF16 copy alongside the adapter.
🟢 Trainable  —  requires_grad=True · updated every step · has gradient buffer + optimizer state
LoRA A + B ~270 MB 134M params × 2 bytes BF16 at r=16, 7 modules, 64 layers. A: Kaiming-uniform init. B: zeros. B=0 → ΔW=0 at step 0, model starts identical to pretrained. Only these weights move. At inference: merge W_merged = W₀ + (α/r)·B·A for zero latency overhead.
Gradients ~270 MB PyTorch auto-allocates a gradient buffer per trainable param, same shape and dtype (BF16). PyTorch computes ∂L/∂B and ∂L/∂A but never ∂L/∂W₀ because requires_grad=False. That skip alone saves ~16.4 GB of gradient memory.
AdamW-8bit ~270 MB 1st moment m + 2nd moment v per trainable param, INT8-quantized, in CUDA Unified Memory. With paged_adamw_8bit, pages to CPU RAM under pressure and back at optimizer.step(). Absorbs the transient OOM spikes that gradient checkpointing recomputation causes.
🔵 Transient  —  materialized and freed within each layer · never accumulates across layers simultaneously
W^BF16 dequant ~640 MB peak NF4 → BF16 decoded in a CUDA kernel, used for one matmul, freed. Only one layer's W^BF16 exists at a time. Largest single layer is down_proj: 27,648×5,120×2 = 283 MB. PyTorch's caching allocator reuses the memory slot for the next layer — peak cost is one layer, not all 64.
Activations ~1–3 GB With grad-ckpt: saved only at segment boundaries, recomputed on backward. Without: ~40 GB at seq=2048. Recomputation costs ~30% slower backward, saves ~8× activation memory. Always use gradient checkpointing with QLoRA — the tradeoff is always worth it.
FA2 workspace ~2–4 GB Flash Attention 2 tiles Q·Kᵀ; the lse buffer costs batch × heads × seq_len × 4 bytes. Peak transient hits during backward of the last layer in a grad-ckpt segment — simultaneous recomputed activations + W^BF16 + gradient. This is the spike paged_adamw_8bit is designed to absorb.
NF4 base
16.4 GB
4 bits × 32.76B params
LoRA adapter
270 MB
r=16, 7 target modules
Total training
~25 GB
one A100-80GB fits
Checkpoint saved
270 MB
adapter only — not base

Why quality holds despite 4-bit: NF4 rounding introduces a small, consistent error on each weight. The BF16 LoRA adapter can steer the layer output to compensate — it learns the task residual and the quantization residual simultaneously. The adapter doesn't know the difference; it just sees "the direction that reduces loss", which includes compensation for NF4 error.

Part 2 · Chapter 05 — Configuration

The parameters
that actually matter.

Three libraries, seven decisions. Everything else can stay at its default.

BnB
BitsAndBytesConfig
bitsandbytes ≥ 0.44
ParameterDefaultSet toWhy
load_in_4bitFalseTrueActivates 4-bit path, replaces Linear with Linear4bit
bnb_4bit_quant_type"fp4""nf4"Optimal for normally-distributed weights — always use NF4
bnb_4bit_compute_dtypefloat32torch.bfloat16Default FP32 is silently 2× slower and wrong
bnb_4bit_use_double_quantFalseTrueFree 1.5 GB — quantizes the scales too
bnb_4bit_quant_storageuint8torch.bfloat16 (FSDP only)Required for FSDP multi-GPU; safe on single GPU too
PEFT
LoraConfig
PEFT ≥ 0.13 · peft.LoraConfig

LoraConfig defines the adapter architecture — rank, target layers, and scaling. It does not touch the base model. Call get_peft_model(model, lora_cfg) after prepare_model_for_kbit_training to inject the adapter.

ParameterDefaultFor Qwen 2.5 32B
// Adapter architecture
r816–32. Rank = dimension of the bottleneck. More rank = more capacity but more memory. At r=16 on Qwen 2.5 32B you get 134M trainable params (0.41%). At r=64 use rsLoRA.
lora_alpha82×r (e.g. 32 when r=16). The effective scale on ΔW is α/r = 2. Higher α = bigger adapter signal relative to base. rsLoRA changes this to α/√r internally when use_rslora=True.
lora_dropout0.00.05 for most tasks. Regularizes adapter weights during training. Set to 0.0 when using DoRA or PiSSA — they have their own regularization.
bias"none""none" — always. "all" or "lora_only" break adapter merging and multi-adapter serving (S-LoRA, vLLM). Only change if you have a specific reason.
// Which layers to adapt
target_modulesNone["q_proj","k_proj","v_proj","o_proj","gate_proj","up_proj","down_proj"] for Qwen 2.5. Or use "all-linear" — PEFT auto-discovers all Linear layers. Do NOT skip gate/up/down_proj for code tasks.
layers_to_transformNone (all)List of layer indices to adapt, e.g. list(range(32,64)) to only adapt the top half. Useful for very constrained memory — top layers matter most for task adaptation.
layers_patternNoneString pattern to match layer name, e.g. "layers". Used with layers_to_transform to scope which sub-module the index applies to.
modules_to_saveNone["embed_tokens","lm_head"] only when adding new vocabulary tokens. These are saved as full BF16 trainable copies alongside the LoRA adapter — not quantized.
// Task and init
task_typeNone"CAUSAL_LM" — always set explicitly. Controls how PEFT wires up the forward pass and chat-template masking.
init_lora_weightsTrueTrue (Kaiming-uniform A, zero B) — adapter output is exactly zero at step 0, so fine-tuning starts from the pretrained state. "loftq" for ≤3-bit. "pissa" for faster convergence. False only for debugging.
// Advanced scaling
use_rsloraFalseTrue when r ≥ 32. Standard LoRA scales by α/r — gradient magnitude shrinks as 1/√r when rank grows. rsLoRA scales by α/√r instead, keeping gradient magnitude constant regardless of r. Essential for high-rank experiments.
use_doraFalseTrue at r = 4–8 for best quality. Decomposes W into magnitude × direction and adapts them independently. Full fine-tuning shows negative correlation between magnitude and direction updates; vanilla LoRA can't do this. DoRA closes most of the quality gap at low ranks. Small compute overhead.
rank_pattern{}Dict mapping module name → rank, e.g. {"q_proj":32,"v_proj":16}. Useful when you want higher rank on attention output but lower on MLP to save memory.
alpha_pattern{}Same as rank_pattern but for alpha. Use alongside rank_pattern to keep α/r constant per module.
fan_in_fan_outFalseTrue only for Conv1D-style weights (GPT-2). Keep False for all Qwen/Llama/Mistral models — they use standard Linear layers.
// Verify after get_peft_model
model.print_trainable_parameters() # → trainable params: 134,217,728 || all params: 32,897,925,120 || trainable%: 0.4079 # Verify LoRA is injected on the right layers for n, m in model.named_modules(): if hasattr(m, 'lora_A'): print(n, list(m.lora_A.keys()))
TRL
SFTConfig
TRL ≥ 0.12 — silently overrides many TrainingArguments defaults

SFTConfig inherits from TrainingArguments but overrides several defaults. TRL sets: lr=2e-5 (vs HF 5e-5), optim=fused (vs plain adamw), gradient_checkpointing=True (vs False), logging_steps=10 (vs 500), report_to="none" (vs "all"), use_cache=False (vs True). Always verify with print(trainer.args).

ParameterTRL defaultFor Qwen 2.5 32B
// Learning rate & scheduler
learning_rate2e-51e-4 to 2e-4. LoRA adapters are randomly initialized and need a higher LR than full fine-tuning to converge. The frozen base limits the damage from a large LR — only the small adapter updates. TRL default 2e-5 is too conservative; start at 2e-4 and tune down if loss is unstable.
lr_scheduler_type"linear""cosine" — cosine decay keeps LR higher longer then anneals smoothly. Linear decay drops too fast in the middle of training where you still want to learn.
warmup_ratio0.00.03 — 3% of total steps. Prevents gradient explosion in the first few batches when the adapter weights are still far from equilibrium. Avoids the need for grad_norm clipping alone.
num_train_epochs3.01–2 for large domain datasets, 3–5 for very small ones. More epochs = more memorization, which is fine for SFT (you want the pattern reproduced) but watch for eval loss diverging.
// Gradient stability
max_grad_norm1.00.3 — QLoRA paper recommendation. Adapter gradients can spike early in training; 0.3 clips them more aggressively. If you see sudden loss spikes, lower to 0.1.
adam_beta20.9990.95 — default 0.999 makes AdamW slow to respond to gradient magnitude changes. 0.95 gives faster adaptation, which matters for the small adapter with rapidly changing gradients. This is the single most under-appreciated hyperparameter in LLM fine-tuning.
weight_decay0.00.01–0.1 optional. Regularizes adapter weights. Usually not needed since adapters are already small and dropout provides regularization.
// Optimizer & memory
optim"adamw_torch_fused""paged_adamw_8bit" — stores optimizer state (m, v) in CUDA Unified Memory. Under gradient-checkpointing memory spikes, pages m/v to CPU RAM automatically and pages them back at optimizer.step(). Near-zero throughput cost unless you're right at the memory edge. paged_adamw_32bit if you need full-precision optimizer state.
gradient_checkpointingTrue (TRL)Keep True. Saves activations only at layer boundaries, recomputes within each layer on backward. Trades ~30% compute for ~8× activation memory reduction. Pass gradient_checkpointing_kwargs={"use_reentrant":False} to avoid the reentrant warning with PEFT.
gradient_accumulation_steps116–32. With batch_size=1, effective_batch = grad_accum × n_gpus. 16 steps → effective batch 16 on single GPU. Large effective batches stabilize training when per-device batch must be 1 due to memory.
per_device_train_batch_size81 on A100-80GB at seq=2048. 2 on H100-80GB. Scale up with max_length — seq=512 allows batch=4 on A100.
// Precision
bf16NoneTrue on A100/H100. Matches bnb_4bit_compute_dtype=bfloat16. Never set fp16=True with 4-bit quantization — causes NaN at step 1 on Ampere+ due to activation overflow.
tf32NoneTrue — free ~10% throughput on Ampere/Hopper. TF32 rounds FP32 matmul inputs to 10-bit mantissa then accumulates in FP32. Invisible to user code, no precision impact for training.
// Dataset (TRL-specific keys)
max_length10242048–4096 for code. Renamed from max_seq_length in TRL ≥ 0.17 — use max_length in newer versions. Longer context = more GPU memory for activations; use grad-ckpt to compensate.
packingFalseTrue when most examples are shorter than max_length. ConstantLengthDataset packs multiple examples into one sequence, separated by EOS. 2–5× throughput improvement. Do NOT use packing + assistant_only_loss without also setting dataset_kwargs={"skip_prepare_dataset":False} — they can interact incorrectly.
assistant_only_lossFalseTrue for chat-format data. Uses the chat template's generation markers to mask prompt tokens (labels=-100). Loss and gradients only flow from assistant response tokens. Without this, the model trains to reproduce the user's question too, wasting capacity.
dataset_text_field"text"Column name in your dataset that contains the formatted text string. SFTTrainer reads this field and tokenizes it. Must match what your format function outputs.
neftune_noise_alphaNone5 or 10 for small domain datasets. Adds random Gaussian noise to embedding vectors during training. Equivalent to input data augmentation — significantly improves out-of-distribution generalization. Costs zero inference overhead (disabled at eval).
dataset_num_procNone8–16. Number of CPU workers for dataset tokenization. Parallelizes the map() call. Match to your CPU core count.
// Checkpointing & logging
save_strategy"steps"Keep "steps". Use "epoch" only if epochs are very short.
save_steps500200–500. Save every N steps. Each checkpoint is ~270 MB (adapter only). More frequent = less work lost on preemption.
save_total_limitNone2–3. Keeps only the N most recent checkpoints. Without this, all checkpoints accumulate on disk.
logging_steps10 (TRL)10 is fine. Log loss, lr, grad_norm, tokens/sec to wandb/tensorboard. Watch for sudden loss spikes (→ lower max_grad_norm) or flat loss (→ raise lr or add MLP targets).
report_to"none" (TRL)"wandb" for experiment tracking. Set WANDB_PROJECT env var. TRL already logs train/loss, train/learning_rate, train/grad_norm.
seed42Set explicitly for reproducibility. Affects dropout, data shuffling, and model init.
// Verify effective batch size and memory
# effective_batch_size = per_device_batch × grad_accum × n_gpus # e.g. 1 × 16 × 1 = 16 (single A100) # After training starts, confirm memory usage: import torch print(torch.cuda.max_memory_allocated() / 1e9, "GB peak") # Should be ≤ 75 GB on A100-80GB (leave headroom)
fn
prepare_model_for_kbit_training
Call before get_peft_model — easy to forget
1

Freezes base model

Sets requires_grad=False on all base params — gradients never flow to W₀.

2

Upcasts LayerNorms to FP32

BF16 norms can be numerically unstable. FP32 norms are stable at negligible memory cost.

3

Adds embedding gradient hook

Grad-ckpt needs a gradient at layer 1. Frozen embeddings don't have one — this hook creates it.

4

Enables gradient checkpointing

Pass gradient_checkpointing_kwargs={"use_reentrant": False} to avoid DDP/PEFT warning.

Common errors

Loss → NaN at step 1 — fp16=True with bnb 4-bit on A100/H100
bf16=True, fp16=False
OOM mid-training — grad-ckpt spike hits memory ceiling
paged_adamw_8bit
Loss flat from step 0 — α/r too small or MLP layers not targeted
raise α, add gate/up/down
"element 0 does not require grad" — skipped prepare_model_for_kbit_training
call before get_peft_model
Infinite generation at inference — pad_token = eos_token
separate pad token
5× slower than expected — no Flash Attention 2
attn_implementation="flash_attention_2"
FSDP type error — quant_storage=uint8 with multi-GPU FSDP
quant_storage=torch.bfloat16
Part 2 · Chapter 06 — Full Recipe

Everything wired
together. Copy, run.

Qwen 2.5 Coder 32B — H=5120, 64 layers, 40 attn heads (GQA 8 KV), I=27648, context 128K via YaRN.

train_qlora.py
import torch from datasets import load_dataset from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training from trl import SFTConfig, SFTTrainer MODEL_ID = "Qwen/Qwen2.5-Coder-32B-Instruct" # ── Chapter 02: 4-bit NF4 storage ──────────────────────────────────────── bnb_cfg = BitsAndBytesConfig( load_in_4bit = True, bnb_4bit_quant_type = "nf4", # levels at N(0,1) quantiles bnb_4bit_use_double_quant = True, # quantize block scales too (+1.5 GB saved) bnb_4bit_compute_dtype = torch.bfloat16, # dequant to BF16, not FP32 bnb_4bit_quant_storage = torch.bfloat16, # needed for FSDP; safe single-GPU too ) # ── Tokenizer ───────────────────────────────────────────────────────────── tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, use_fast=True) if tokenizer.pad_token is None: tokenizer.add_special_tokens({"pad_token": "<|pad|>"}) # separate from eos! tokenizer.padding_side = "right" # right during training; left for generation # ── Load 4-bit model ────────────────────────────────────────────────────── model = AutoModelForCausalLM.from_pretrained( MODEL_ID, quantization_config = bnb_cfg, device_map = "auto", torch_dtype = torch.bfloat16, attn_implementation = "flash_attention_2", # 2–3× faster ) model.config.use_cache = False model.config.pretraining_tp = 1 # ── Chapter 05: prepare for k-bit training ──────────────────────────────── model = prepare_model_for_kbit_training( model, use_gradient_checkpointing = True, gradient_checkpointing_kwargs = {"use_reentrant": False}, ) # ── Chapter 03: LoRA adapter ────────────────────────────────────────────── lora_cfg = LoraConfig( r = 16, lora_alpha = 32, # 2×r → scale factor = 2 lora_dropout = 0.05, bias = "none", task_type = "CAUSAL_LM", target_modules = ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"], ) model = get_peft_model(model, lora_cfg) model.print_trainable_parameters() # → ~134M / 32.9B / 0.41% # ── Dataset ─────────────────────────────────────────────────────────────── dataset = load_dataset("your/dataset", split="train") def format(ex): msgs = [{"role": "user", "content": ex["prompt"]}, {"role": "assistant", "content": ex["completion"]}] return {"text": tokenizer.apply_chat_template(msgs, tokenize=False)} dataset = dataset.map(format, remove_columns=dataset.column_names) # ── Chapter 05: SFTConfig ───────────────────────────────────────────────── sft_cfg = SFTConfig( output_dir = "./out", num_train_epochs = 2, per_device_train_batch_size = 1, gradient_accumulation_steps = 16, learning_rate = 2e-4, # higher than full FT lr_scheduler_type = "cosine", warmup_ratio = 0.03, max_grad_norm = 0.3, adam_beta2 = 0.95, optim = "paged_adamw_8bit", bf16 = True, tf32 = True, gradient_checkpointing = True, gradient_checkpointing_kwargs = {"use_reentrant": False}, max_length = 2048, packing = True, assistant_only_loss = True, dataset_text_field = "text", dataset_num_proc = 8, save_total_limit = 2, logging_steps = 10, report_to = "wandb", ) trainer = SFTTrainer(model=model, tokenizer=tokenizer, train_dataset=dataset, args=sft_cfg) trainer.train() trainer.save_model() # saves adapter only (~270 MB)
Summary — Parts 1 & 2

Three ideas.
One 25 GB result.

NF4
4-bit codebook at N(0,1) quantiles

Weights follow a bell curve. NF4 places 16 levels at equal-probability intervals — precision exactly where needed. Double quantization of scales brings total to 4.127 bits/param.

LoRA
Low-rank update in BF16

Task adaptation is low-rank. B×A (r=16) captures it with 134M params instead of 32B. B=0 at init — model starts identical to pretrained. Merge at inference for zero overhead.

QLoRA
The adapter compensates for NF4

NF4 rounding error ≈ small residual. BF16 LoRA adapter learns that residual alongside the task — not despite quantization but with it. This is why QLoRA matches 16-bit fine-tuning quality.