Fine-Tuning & Trainingpytorchhuggingfacetransformersacceleratedeepspeed

Why your fine-tuning run is so much slower than it should be, and how to speed it up

Error
training throughput far below expected tokens/sec or steps/sec for the GPU and model size

Also appears as

  • GPU utilization stuck below 50% during training (checked via nvidia-smi)
  • each training step takes several times longer than benchmark numbers suggest it should

Short answer

Slow fine-tuning is usually a GPU sitting idle waiting on the CPU dataloader, a batch size too small to keep the GPU's compute units busy, missing FlashAttention or another fused kernel so attention runs in a much slower fallback path, or CPU/NVMe offloading (for models too large to fit fully in VRAM) that is inherently bound by PCIe bandwidth rather than GPU compute. Check GPU utilization with nvidia-smi first to know which of these you're actually facing.

Affects: LoRA and full fine-tuning on single or multi-GPU setups, HuggingFace transformers, Accelerate, and DeepSpeed

Fastest path to faster training

  1. 1Run nvidia-smi in a loop during training and check GPU utilization; if it's frequently below 70-80%, the bottleneck is likely the dataloader or CPU-side preprocessing, not the GPU itself.
  2. 2Increase per-device batch size and/or gradient accumulation steps until GPU memory is well utilized (watch nvidia-smi memory.used), since small batches underutilize modern GPU compute.
  3. 3Enable FlashAttention 2 (attn_implementation='flash_attention_2') if your model and GPU support it, since the default eager attention implementation is meaningfully slower, especially at longer sequence lengths.
  4. 4Increase dataloader_num_workers and enable pin_memory=True so data loading and tokenization happen in parallel with GPU compute instead of blocking it.
  5. 5If using CPU or NVMe offloading (DeepSpeed ZeRO-Offload) because the model doesn't fit in VRAM, understand that offloading is inherently PCIe-bandwidth-bound; the real fix is more or bigger GPUs, not further offload tuning.

How to confirm this is your problem

  • nvidia-smi shows GPU utilization frequently dropping to 0-30% between steps rather than staying consistently high
  • Training throughput (tokens/sec or steps/sec) is far below published benchmark numbers for similar hardware and model size
  • Increasing batch size doesn't meaningfully speed up wall-clock training time per epoch
  • CPU usage is pegged at 100% on one or more cores while GPU sits comparatively idle
  • Training with CPU or disk offloading is dramatically slower than training without it (expected, but often more severe than anticipated)

Root causes and fixes

Most common

Batch size too small to utilize the GPU's compute units

Modern GPUs have far more parallel compute capacity than a very small batch can occupy; with a tiny batch, most of the GPU's tensor cores sit idle during each forward and backward pass, and per-step overhead (kernel launches, synchronization) becomes a larger fraction of total time relative to actual compute, making each step slower than it needs to be for the work performed.

Fix: Increase per_device_train_batch_size as far as VRAM allows, and use gradient_accumulation_steps to reach your desired effective batch size without exceeding memory, which recovers most of the throughput lost to a too-small batch.

Common

Sequence packing not used, wasting compute on padding

Without packing, a batch of variable-length examples is padded to the length of its longest member, meaning shorter examples waste compute processing padding tokens that produce no useful gradient. On datasets with high length variance, this can mean a large fraction of every batch's compute is spent on tokens that contribute nothing to learning.

Fix: Enable sequence packing (TRL's SFTTrainer supports packing=True) which concatenates multiple short examples into fixed-length sequences separated by EOS tokens, dramatically reducing wasted compute on padding for datasets with variable-length examples.

Common

Missing FlashAttention or another fused attention kernel

The default eager attention implementation computes the full attention matrix with several separate, unfused GPU operations, each with its own memory read/write overhead. FlashAttention and similar fused kernels compute the same mathematical result with a single, memory-efficient fused kernel, which is significantly faster and also uses less memory, especially as sequence length grows.

Fix: Load the model with attn_implementation='flash_attention_2' (or 'sdpa' as a solid built-in alternative if FlashAttention installation isn't feasible) instead of leaving the default eager implementation active.

Commands
model = AutoModelForCausalLM.from_pretrained(model_id, attn_implementation='flash_attention_2')
Common

Dataloader-bound: CPU preprocessing and tokenization can't keep up with the GPU

If tokenization, data augmentation, or disk I/O for reading training examples happens synchronously on the main process with too few worker processes, the GPU finishes its compute for a batch and then sits idle waiting for the next batch to be prepared, which shows up as low GPU utilization even though the model and hardware are otherwise capable of much higher throughput.

Fix: Increase dataloader_num_workers (start around 4-8 and tune based on CPU core count), enable pin_memory=True, and pre-tokenize the dataset once and cache it to disk rather than re-tokenizing on the fly every epoch.

Occasional

PCIe-bound CPU or NVMe offloading for models that don't fit in VRAM

When using DeepSpeed ZeRO-Offload or similar techniques to move optimizer states or parameters to CPU RAM or NVMe storage because the model is too large for available VRAM, every offloaded tensor must move across the PCIe bus each time it's needed, and PCIe bandwidth is orders of magnitude slower than GPU-to-GPU memory bandwidth, making offloaded training inherently much slower regardless of how well it's tuned.

Fix: Recognize this as a hardware capacity limitation rather than a tuning problem: the durable fix is more VRAM (larger or additional GPUs) or a smaller/quantized model that fits without offloading, not further offload configuration adjustments.

Rare

Mixed precision or TF32 not enabled

Training in full fp32 precision without enabling TF32 (on Ampere and newer GPUs) or bf16/fp16 mixed precision leaves substantial GPU compute throughput on the table, since these formats allow the GPU's tensor cores to run at significantly higher throughput than full fp32 matrix operations.

Fix: Enable bf16 or fp16 mixed precision training explicitly, and ensure torch.backends.cuda.matmul.allow_tf32 = True is set if any fp32 operations remain in your pipeline.

Diagnostic commands

Monitor GPU utilization live during training

nvidia-smi --query-gpu=utilization.gpu,utilization.memory --format=csv -l 2

Utilization consistently below 70-80% points to a bottleneck outside the GPU itself (dataloader, small batch, or offloading), while consistently high utilization with still-slow wall-clock time suggests the workload itself (attention implementation, precision) needs optimizing.

Check which attention implementation is active

python -c "print(model.config._attn_implementation)"

Should read flash_attention_2 or sdpa for good performance. A value of eager means you're leaving meaningful speed and memory efficiency on the table, especially at longer sequence lengths.

Profile dataloader wait time versus compute time

python -c "from torch.profiler import profile; # profile a few training steps and inspect DataLoader time"

A large fraction of wall-clock time attributed to data loading rather than model compute confirms a dataloader bottleneck, pointing you toward more workers, caching, or pre-tokenization rather than GPU-side tuning.

Check whether offloading is active and how much is offloaded

python -c "print(ds_config.get('zero_optimization', {}).get('offload_optimizer'))"

If offload_optimizer or offload_param is configured, expect meaningfully lower throughput than a fully in-VRAM run; this confirms PCIe-bound offloading as an expected cause rather than a bug to chase further.

Stopping it from happening again

  • Benchmark a short training run (50-100 steps) with nvidia-smi monitoring before committing to a full multi-hour job, to catch utilization problems early.
  • Pre-tokenize and cache datasets to disk once rather than re-tokenizing on the fly every epoch.
  • Default new training scripts to flash_attention_2 or sdpa rather than eager attention.
  • Size GPU capacity to fit the model and desired batch size without offloading whenever the budget allows, since offloading should be a deliberate fallback, not a default.
  • Track tokens/sec as a standard metric across runs so throughput regressions are caught immediately rather than discovered after a slow run completes.

When this becomes an architecture problem

If GPU utilization is already high and you've enabled FlashAttention, packing, and mixed precision, but throughput still falls short of what the hardware should deliver, that points to a deeper configuration or parallelism strategy issue (tensor parallelism setup, interconnect bottleneck, or DeepSpeed ZeRO stage choice) worth a focused review. If you're regularly forced into CPU/NVMe offloading because your current GPU fleet can't fit the models you need to fine-tune, that's a capacity planning and hardware decision, not a software tuning one.

Frequently asked questions

What GPU utilization should I expect during fine-tuning?

A well-tuned training run typically keeps GPU utilization at 80-95% for most of each step, with brief dips at batch boundaries. Utilization frequently dropping to 0-30% indicates a dataloader, batch size, or offloading bottleneck rather than the GPU itself being the limiting factor.

Does FlashAttention actually make a big difference for fine-tuning speed?

Yes, particularly as sequence length grows. FlashAttention avoids materializing the full attention matrix and uses a single fused, memory-efficient kernel, commonly providing significant speedups and memory savings over the default eager attention implementation, especially for context lengths beyond a few thousand tokens.

Why is CPU offloading so much slower than training fully on GPU?

Offloaded parameters or optimizer states must move across the PCIe bus between CPU RAM and GPU VRAM every time they're needed, and PCIe bandwidth is dramatically lower than GPU memory bandwidth. This makes offloaded training inherently much slower regardless of tuning; it trades speed for the ability to fit a model that otherwise wouldn't fit in available VRAM at all.

Does increasing batch size always speed up training?

Only up to the point where the GPU's compute units are fully utilized; beyond that, further batch size increases mainly consume more memory without meaningfully improving throughput, and can eventually cause out-of-memory errors. Monitor GPU utilization to know when you've reached diminishing returns.

Related problems

Gradient checkpointing errors during fine-tuning

Gradient checkpointing errors during fine-tuning almost always come from three sources: the use_reentrant parameter left unset (it now must be explicit and False is usually correct for transformer models), an attention implementation that isn't fully compatible with checkpointing's re-computation approach, or leaving use_cache=True enabled while checkpointing is on, which conflicts because checkpointing recomputes the forward pass and a live KV cache assumes it won't be recomputed. Set use_reentrant=False and use_cache=False together.

CUDA out of memory even though nvidia-smi shows free VRAM

This almost always means memory fragmentation: the allocator has enough total free memory but no single contiguous block large enough for the requested allocation, because the address space is broken into many small free-and-used segments from prior allocations of different sizes. The fix is enabling PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True, reducing allocation size variability, or restarting the process to reset the address space.

How to reduce VRAM usage for LLM inference

VRAM usage during inference comes from three independent budgets: model weights (params x bytes/param), KV cache (scales with batch x context length), and activation/workspace memory. Reducing usage means attacking whichever budget dominates: quantize weights to cut the largest fixed cost, cap max context length and concurrency to cut the largest variable cost, and use an efficient attention/serving backend to minimize workspace overhead.

Training loss not decreasing during fine-tuning

Training loss that stays flat is most often caused by a LoRA adapter that targets the wrong modules (missing q_proj/k_proj/v_proj/o_proj), a learning rate that is too low for LoRA or too high and bouncing, or labels that were never masked so the model is trying to learn the prompt tokens as if they were random noise. Check target_modules first, then the label mask, then the learning rate.

Guide

Fine-Tuning Failure Modes: What Actually Goes Wrong

Fine-tuning failure modes that actually derail enterprise projects: catastrophic forgetting, eval overfitting, data leakage, and how to catch each one.

Guide

Multi-Node LLM Training Infrastructure: Networking and Storage

Multi-node LLM training infrastructure explained: InfiniBand vs RoCE tradeoffs, storage throughput needs, and cluster topology for enterprise fine-tuning.

Guide

On-Prem GPU Cluster Design: Node Sizing, Networking, and Storage

Design an on-prem GPU cluster: node sizing for H100/H200/B200, InfiniBand vs RoCE networking, storage throughput, and rack power for enterprise AI workloads.

Guide

LoRA vs QLoRA: Choosing the Right Fine-Tuning Method

LoRA vs QLoRA for enterprise fine-tuning: rank and alpha choices, real VRAM math by model size, and when each method actually wins.

Still stuck, or tired of fighting your own infrastructure?

Netray deploys and operates on-prem AI for regulated manufacturers and defense suppliers. We have debugged this stack in production, on air-gapped networks, at scale.