QLoRA runbook: from dataset to adapter served in multi-LoRA (operational procedure)
Contents
This is the operational companion to QLoRA and multi-LoRA at the limit on small models. That post takes apart the why, NF4, double quantisation, paged optimizers, the adapter maths; this is the how, with commands you copy and paste. If you have not read the fundamentals one, read it first: here we assume you know what an adapter is, why the base lives in 4-bit and why the gradient only touches the adapter.
TL;DR
A reproducible procedure in five phases: (1) pin the environment with fixed versions; (2) prepare the dataset in chat format; (3) train the QLoRA adapter with TRL + PEFT on an RTX 4090 (24 GB, Ada Lovelace) using gradient checkpointing, gradient accumulation and paged_adamw_8bit; (4) validate and version the adapter as an artefact of megabytes; (5) serve it in vLLM with --enable-lora, loading it hot without restarting the server and resolving it from object storage. All on-premise, on consumer hardware, without a single data item leaving the perimeter. What follows are the exact commands and the memory budget that separates “it fits” from “OOM”.
The end-to-end flow
Phase 0 — Environment and versions
QLoRA is sensitive to the versions of bitsandbytes, transformers, peft and trl: misaligned combinations give dequant errors or adapters that do not load in vLLM. Pin the environment and do not touch it mid-campaign. Reference versions as of June 2026 (check the specific ones in your index; the exact pin matters less than the coherence between them):
python -m venv .venv && source .venv/bin/activate
pip install --upgrade pip
# Training (producer)
pip install "torch>=2.4" \
"transformers>=4.50" \
"peft>=0.14" \
"trl>=0.15" \
"bitsandbytes>=0.45" \
"accelerate>=1.2" \
"datasets>=3.2"
# Serving (consumer) — in its own environment/image
pip install "vllm>=0.8"
What each piece does and why it is pinned:
| Package | Role in the flow | Why the version matters |
|---|---|---|
torch | tensor runtime and CUDA kernels | the CUDA ABI has to match the driver and bitsandbytes; a major jump breaks the 4-bit kernels. |
transformers | loads the base, the tokenizer and the chat_template | it has to know the architecture of the SLM you use; a new model needs a version that supports it. |
peft | implements LoRA/QLoRA: injects the A,B matrices and writes the adapter_config.json | that adapter_config.json is what vLLM reads when serving; old versions write fields the serving side does not understand. |
trl | the SFTTrainer: the supervised training loop | it integrates peft natively; its API (SFTConfig) changes between versions, hence the pin. |
bitsandbytes | the NF4 quantisation and paged_adamw_8bit | the most sensitive piece: a badly compiled binary gives corrupt dequant or hangs on the first step. |
accelerate | orchestrates device, mixed precision and device_map | the silent backend of almost everything; misaligning it with transformers gives cryptic errors. |
datasets | loads the JSONL (and allows streaming if the corpus is large) | not very sensitive; any recent 3.x will do. |
vllm | the multi-LoRA serving | separate environment or image: do not mix its stack with the training bitsandbytes. |
The golden rule: coherence between the four above (transformers, peft, trl, bitsandbytes) matters more than the exact number of each. Pin them when starting a campaign and do not move them until it closes.
Check that the GPU and CUDA are healthy before starting; a badly compiled bitsandbytes shows up late:
python -c "import torch, bitsandbytes; print(torch.cuda.get_device_name(0), torch.cuda.is_available())"
nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv
For 100 % sovereignty: download the base once from your internal Hugging Face mirror (or a MinIO with the weights) and export HF_HOME to a local volume. Nothing in this flow needs to leave the perimeter.
Phase 1 — Preparing the dataset
The canonical format for a conversational task is JSONL, one conversation per line, with the model’s chat template. Do not invent your own format: use the base tokenizer’s chat_template, because any mismatch between how you train and how you serve degrades quality silently.
{"messages":[{"role":"system","content":"You are a network support assistant."},{"role":"user","content":"The north wing AP does not come up after the outage."},{"role":"assistant","content":"First confirm the PoE on the port..."}]}
{"messages":[{"role":"user","content":"Generate the VLAN change for customer 42."},{"role":"assistant","content":"interface GigabitEthernet0/3\n switchport access vlan 42..."}]}
What each field is and why:
| Field | What it is | Operational note |
|---|---|---|
messages | the complete conversation, a list of turns | one conversation per JSONL line; it is what apply_chat_template turns into tokens. |
role | who is speaking: system, user, assistant | the adapter learns to produce the assistant turns; the user/system ones are context, not target. |
content | the text of the turn | the system one sets the persona/task; keep it identical to the one you will use in production or the adapter drifts out of alignment. |
Operational rules that save grief: watch the ratio of examples (a well-curated narrow-task dataset of 2,000-20,000 examples performs better than 200,000 noisy ones), deduplicate, and set aside 5-10 % as a validation split that does NOT go into training. Building the corpus from production signal is covered by Retrain: closing the loop.
Phase 2 — The training script
A minimal, complete script with TRL + PEFT. It trains an r=8 adapter on an 8B SLM quantised to NF4. Each block has its rationale commented.
# train_qlora.py
import torch
from datasets import load_dataset
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig
from trl import SFTConfig, SFTTrainer
BASE = "Qwen/Qwen3-8B" # or whichever SLM you serve; ALWAYS use the same one in train and serve
OUT = "adapters/soporte-redes-v1"
# 1) Base frozen and quantised to 4-bit NF4 with double quantisation
bnb = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # NormalFloat, quantile-optimal for Gaussian weights
bnb_4bit_use_double_quant=True, # quantises the scale constants -> ~0.37 bits/param less
bnb_4bit_compute_dtype=torch.bfloat16 # the matmuls run in BF16 after dequant on the fly
)
tok = AutoTokenizer.from_pretrained(BASE)
model = AutoModelForCausalLM.from_pretrained(
BASE, quantization_config=bnb, torch_dtype=torch.bfloat16, device_map={"": 0}
)
# 2) The adapter: low rank, attention projections only (aggressive). Raise target_modules if the eval asks for it.
peft_cfg = LoraConfig(
r=8, lora_alpha=16, lora_dropout=0.05, bias="none", task_type="CAUSAL_LM",
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
)
ds = load_dataset("json", data_files={"train": "data/train.jsonl",
"eval": "data/eval.jsonl"})
# 3) Training config designed to fit in 24 GB
cfg = SFTConfig(
output_dir=OUT,
per_device_train_batch_size=1, # small real batch
gradient_accumulation_steps=16, # EFFECTIVE batch = 1*16 = 16, without paying its VRAM all at once
gradient_checkpointing=True, # recomputes activations in backward: trades compute for memory
optim="paged_adamw_8bit", # paged optimizer: the airbag against VRAM peaks
learning_rate=2e-4, lr_scheduler_type="cosine", warmup_ratio=0.03,
num_train_epochs=3, bf16=True,
max_length=2048, # bounds the sequence: activations scale with it
logging_steps=10, eval_strategy="steps", eval_steps=100, save_steps=200,
report_to="none",
)
trainer = SFTTrainer(model=model, args=cfg, peft_config=peft_cfg,
train_dataset=ds["train"], eval_dataset=ds["eval"],
processing_class=tok)
trainer.train()
trainer.save_model(OUT) # saves ONLY the adapter (MB), not the base
BitsAndBytesConfig — how the base is quantised
| Option | What it does | Why this value / when to change it |
|---|---|---|
load_in_4bit=True | loads the base weights in 4-bit | it is the foundation of QLoRA: without it the 8B does not even fit to train. |
bnb_4bit_quant_type="nf4" | uses the NF4 format (quantile-optimal for Gaussian weights) | "fp4" exists, but NF4 performs better on transformer weights; leave NF4. |
bnb_4bit_use_double_quant=True | quantises the scale constants themselves | saves ~0.37 bits/param (hundreds of MB on an 8B); the margin separating “it fits” from “OOM”. Leave it at True. |
bnb_4bit_compute_dtype=torch.bfloat16 | matmul precision after undoing the quantisation on the fly | BF16 on Ada/Hopper (4090, H100); use float16 only on GPUs without BF16. |
LoraConfig — the shape of the adapter
| Option | What it does | Why this value / when to change it |
|---|---|---|
r=8 | adapter rank: its correction capacity | 4-8 for a narrow task (aggressive); raise it to 16-64 only if the eval shows underfitting. |
lora_alpha=16 | scale factor of the delta (effective α/r) | common convention α=2r; it modulates how much the adapter “weighs” on the base. |
lora_dropout=0.05 | regularisation on the adapter | 0.05-0.1 with small datasets (avoids overfit); 0 if the corpus is large. |
bias="none" | does not train the bias terms | "none" is the standard; "all"/"lora_only" rarely help and cost params. |
task_type="CAUSAL_LM" | objective/head type | fixed for a generative LLM. |
target_modules=[q,k,v,o] | which matrices get an adapter | attention only = cheap and aggressive; add gate_proj/up_proj/down_proj (MLP) if the task demands rewriting more behaviour and the eval asks for it. |
SFTConfig — the memory budget and the loop
| Option | What it does | Why this value / when to change it |
|---|---|---|
per_device_train_batch_size=1 | microbatch per GPU | 1 on 24 GB; the real batch is built by gradient_accumulation_steps. |
gradient_accumulation_steps=16 | accumulates 16 microbatches before updating | effective batch = 1×16 = 16 without paying its VRAM all at once; raise it if you shorten the sequence and want a bigger effective batch. |
gradient_checkpointing=True | recomputes activations in the backward pass instead of storing them | essential on a 4090: ~20-30 % slower in exchange for much less VRAM. |
optim="paged_adamw_8bit" | Adam optimizer in 8-bit + states pageable to RAM | less state VRAM and the airbag that avoids the OOM at the peaks. |
learning_rate=2e-4 | learning rate of the adapter | 1e-4-3e-4 is the typical QLoRA range; adapters tolerate a higher LR than a full fine-tune. |
lr_scheduler_type="cosine" | LR decay curve | cosine or linear; cosine usually gives a smooth drop at the end. |
warmup_ratio=0.03 | warms the LR over the first 3 % of steps | avoids the instability of the first steps. |
num_train_epochs=3 | complete passes over the dataset | 1-3; watch the eval loss so as not to overfit. |
bf16=True | compute and adapter precision | BF16 on Ada/Hopper; fp16=True if your GPU has no BF16. |
max_length=2048 | maximum sequence length | the #1 VRAM lever for activations: shorten it first if there is an OOM. |
eval_strategy/eval_steps/save_steps | validation and checkpoint cadence | tune them to the dataset size; evaluating often costs time. |
The four pieces that make it fit on a 4090 are: per_device_train_batch_size=1 + gradient_accumulation_steps (a large effective batch without its memory cost all at once), gradient_checkpointing=True (recomputing activations instead of storing them) and optim="paged_adamw_8bit" (paging states to RAM at the peaks). Remove any of the three with long sequences and you will see the OOM.
A declarative alternative with Axolotl if you prefer YAML over Python (same result):
base_model: Qwen/Qwen3-8B
load_in_4bit: true
adapter: qlora
lora_r: 8
lora_alpha: 16
lora_target_modules: [q_proj, k_proj, v_proj, o_proj]
sequence_len: 2048
micro_batch_size: 1
gradient_accumulation_steps: 16
gradient_checkpointing: true
optimizer: paged_adamw_8bit
learning_rate: 0.0002
num_epochs: 3
bf16: true
datasets:
- path: data/train.jsonl
type: chat_template
Phase 3 — Launch and monitor
# Simple launch on one GPU
python train_qlora.py
# In another terminal: watch VRAM. If it gets close to the ceiling, lower max_length or raise grad accumulation.
watch -n 2 nvidia-smi --query-gpu=memory.used,memory.total,utilization.gpu --format=csv
Approximate VRAM budget when training the 8B on the 4090, and what to touch when it gets tight:
| Component | Approx. VRAM | Lever if there is an OOM |
|---|---|---|
| 8B NF4 base (frozen) | ~4.0 GB | — (fixed) |
| Adapter + grad + Adam states | ~0.3-0.7 GB | lower r |
| Activations (batch × sequence) | ~6-14 GB | lower max_length, batch_size; raise grad_accum |
| Dequant buffers / workspace | ~1-2 GB | — |
Table of quick OOM remedies, in order of cost: (1) lower max_length; (2) confirm gradient_checkpointing=True; (3) raise gradient_accumulation_steps and lower per_device_train_batch_size to 1; (4) use paged_adamw_8bit (already in the script); (5) as a last resort lower r. If after all that it still does not fit, the sequence or the model is too large for 24 GB: either you bound it, or you move up the hardware.
Phase 4 — Validating the adapter
Never promote an adapter on the strength of the training loss. Measure against the validation split you set aside and against a handful of real prompts.
# quick_eval.py
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
from peft import PeftModel
bnb = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True, bnb_4bit_compute_dtype=torch.bfloat16)
tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-8B")
base = AutoModelForCausalLM.from_pretrained("Qwen/Qwen3-8B", quantization_config=bnb, device_map={"": 0})
model = PeftModel.from_pretrained(base, "adapters/soporte-redes-v1") # base + adapter
msgs = [{"role": "user", "content": "The north wing AP does not come up after the outage."}]
ids = tok.apply_chat_template(msgs, add_generation_prompt=True, return_tensors="pt").to(0)
print(tok.decode(model.generate(ids, max_new_tokens=256)[0], skip_special_tokens=True))
For a serious verdict, run the adapter through your eval suite (the layer described by LLM evals) and compare against the base without an adapter and against the previous version of the adapter. Promote only if it wins on the task metric without regressing on safety/format.
Phase 5 — Versioning the adapter as an artefact
The adapter is a pair of files of a few MB (adapter_model.safetensors + adapter_config.json). Treat it as a versioned, signed, traceable artefact, not as a loose file.
# Reproducible checksum + upload to internal object storage (MinIO/S3)
sha256sum adapters/soporte-redes-v1/adapter_model.safetensors > adapters/soporte-redes-v1/SHA256
aws --endpoint-url https://minio.interno s3 cp \
adapters/soporte-redes-v1/ s3://adapters/soporte-redes/v1/ --recursive
A convention that works: s3://adapters/<task-or-customer>/<version>/. Immutable per version, with its SHA256. Deleting a customer is deleting a prefix of a few MB, not retraining anything. Versioning 500 adapters costs what versioning 500 heavy configuration files costs.
Phase 6 — Serving in multi-LoRA with vLLM
The consumer loads one shared base and applies the adapter delta per request. Startup with static adapters declared:
VLLM_ALLOW_RUNTIME_LORA_UPDATING=True \
vllm serve Qwen/Qwen3-8B \
--enable-lora \
--max-loras 8 \ # max nº of DIFFERENT adapters per batch (not the total loadable)
--max-lora-rank 8 \ # = the maximum rank of your adapters; do not inflate it (wastes memory)
--max-cpu-loras 64 \ # adapters cached in RAM for fast swap to VRAM
--lora-modules soporte-redes=/srv/adapters/soporte-redes/v1
Each flag, what it controls and how to size it:
| Flag / variable | What it controls | How to size it |
|---|---|---|
--enable-lora | turns on adapter support | mandatory; without it, vLLM ignores any model that is an adapter. |
--max-loras 8 | nº of different adapters in a single batch | more adapters per batch makes the SGMV kernels more expensive; 8-32 is reasonable. It is not the total loadable. |
--max-lora-rank 8 | maximum rank the server reserves | set it equal to the real rank of your adapters (8 here); inflating it wastes VRAM and performance. |
--max-cpu-loras 64 | adapters cached in RAM ready to page to VRAM | ≥ nº of active adapters; it is the “bench” from which fast swapping happens. |
--lora-modules name=path | declares static adapters at startup | useful for the fixed ones; leave it out if everything goes through dynamic loading/Resolver. |
VLLM_ALLOW_RUNTIME_LORA_UPDATING=True | enables the hot load/unload endpoints | essential for /v1/load_lora_adapter; without it, the server is static. |
--max-loras limits the different adapters per batch, not how many you can have loaded; the bulk lives on CPU (--max-cpu-loras) and is paged to VRAM on demand. Set --max-lora-rank to the real rank (8 here): inflating it wastes memory and performance. Requests pick the adapter through the model field:
curl http://localhost:8000/v1/chat/completions -H "Content-Type: application/json" -d '{
"model": "soporte-redes",
"messages": [{"role":"user","content":"The north wing AP does not come up after the outage."}]
}'
# model:"Qwen/Qwen3-8B" (no adapter) uses the bare base on the same server
Hot loading of a new adapter without restarting (thanks to VLLM_ALLOW_RUNTIME_LORA_UPDATING=True):
curl -X POST http://localhost:8000/v1/load_lora_adapter -H "Content-Type: application/json" -d '{
"lora_name": "cliente-42",
"lora_path": "/srv/adapters/cliente-42/v3"
}'
# and to free VRAM/CPU when a customer goes idle:
curl -X POST http://localhost:8000/v1/unload_lora_adapter -H "Content-Type: application/json" -d '{"lora_name":"cliente-42"}'
For multi-tenant at scale, avoid declaring hundreds of adapters by hand: the LoRAResolver resolves and loads the adapter from local storage or S3 the first time an unknown model arrives, so the server stays lean and the adapters are pulled lazily from your MinIO. The internals of how thousands of concurrent adapters are batched (SGMV kernels, unified paging, the heterogeneous gather/scatter) are in Multi-LoRA serving; this runbook only switches them on. To squeeze the base’s decode throughput on a 4090, combine this with Optimising decode in vLLM.
Serving multi-adapter vs merging per task
Two deployment architectures, and the procedure changes:
Serving multi-LoRA (the above). A shared base + N hot adapters. It is the default sovereign pattern: minimum footprint, per-customer isolation, hot-swap. Use standard QLoRA and merge nothing.
Merging per task. If you want a single quantised-and-adapted artefact per task (no adapter at runtime), do not merge a standard QLoRA adapter into the 4-bit base: the merge reintroduces precision that NF4 does not represent and on requantising you lose part of what was learnt. For that case, train with QA-LoRA (quantization-aware), which merges cleanly onto a quantised base. It is an architecture decision, not a quality one; the conceptual detail is in the fundamentals post.
Checklist of operational gotchas
- Coherent chat template between training and serving. The most common and most silent mismatch: you train with one
chat_templateand serve with another. Use the base’s on both sides. - Exactly the same base (revision included) in
trainandserve. An adapter trained onQwen3-8Bis not valid on another revision of the model. --max-lora-rank≥ the rank of ALL the adapters served together, but no more: inflating it wastes VRAM.- KV budget vs
--max-loras. The bottleneck in serving is not the adapters (MB), it is the KV cache and concurrency; see Inverted roofline for the SLM regime. rtoo low = underfitting if the task demands rewriting a lot of behaviour. Raiseronly if the eval asks for it.- Do not promote on training loss. Validate against the reserved split + real prompts + a safety regression.
- Version and make immutable every adapter with its
SHA256; never overwrite a served version.
Applied to on-premise infrastructure
On an RTX 4090 (24 GB) the same machine is producer and consumer: you train a customer’s adapter in hours and serve it on the same server on top of the shared base. It is the canonical case for multi-tenant demos and platform prototypes.
On a generic 4×H100 SXM cluster (320 GB, NVLink, native FP8) QLoRA stops being necessary in order to fit, but it serves to parallelise production (several adapter jobs at once) and to keep the quantised format consistent between training and serious serving of hundreds of concurrent adapters. The base can run in native FP8; the mechanics of the runbook do not change, only the scale.
See also
- QLoRA and multi-LoRA at the limit on small models — the fundamentals post: the why of NF4, double quantisation, paged optimizers and the adapter maths. This runbook is its executable face.
- Multi-LoRA serving — the consumer internals we only switch on here: SGMV, unified paging, heterogeneous batching of thousands of adapters.
- Optimising decode in vLLM — how to squeeze the decode throughput of the base you serve the adapters on top of, on a 4090.
- Retrain: closing the feedback → dataset → adapter loop — where the Phase 1 dataset comes from.
- LLM evals: the layer after tracing — how to validate the Phase 4 adapter with judgement, not with the training loss.
- Inverted roofline on small models — the performance regime that explains why the serving bottleneck is the KV cache, not the adapters.
- Aggressive quantisation: from 4-bit to ternary — what happens to the quantised base below NF4 underneath the adapter.
References
- Dettmers, T., Pagnoni, A., Holtzman, A., Zettlemoyer, L. QLoRA: Efficient Finetuning of Quantized LLMs. NeurIPS 2023. https://arxiv.org/abs/2305.14314
- Hugging Face TRL — PEFT integration (SFTTrainer + QLoRA): https://huggingface.co/docs/trl/peft_integration
- Hugging Face PEFT: https://github.com/huggingface/peft
- bitsandbytes: https://github.com/bitsandbytes-foundation/bitsandbytes
- vLLM — LoRA Adapters (serving, dynamic loading, LoRAResolver): https://docs.vllm.ai/en/stable/features/lora/
- Axolotl: https://github.com/axolotl-ai-cloud/axolotl
- Xu, Y. et al. QA-LoRA: Quantization-Aware Low-Rank Adaptation. ICLR 2024. https://arxiv.org/abs/2309.14717