DL0200 BERT GPU Underutilization

What can cause a BERT-based model to struggle with GPU underutilization, how do you detect CPU-side preprocessing bottlenecks, and what optimizations (pinned memory, ONNX export, TensorRT, INT8 quantization) would you recommend?

Answer

A BERT forward pass at batch 32 and sequence length 128 is a few milliseconds of dense GEMM work, so the GPU is almost never the slow part of a badly performing BERT service. Underutilization comes from three separable causes: the host-side pipeline (Python tokenization, collation, and synchronous host-to-device copies that block the training or serving loop), wasted GPU work (padding every request to max_length, batch size 1 traffic, or fragmented small kernels whose launch overhead exceeds their compute), and framework overhead (eager-mode PyTorch dispatching hundreds of tiny kernels per layer instead of fused ones). Detection is a matter of separating those causes rather than staring at the single nvidia-smi utilization number, which reports only whether any kernel was resident during a sampling window and happily shows 90% while the SMs are mostly idle. The fix ladder is ordered by cost: first hide the host work with fast tokenizers, multiple dataloader workers, pinned memory plus non_blocking copies and prefetch; then remove padding waste with length bucketing and dynamic batching; then export to ONNX for graph fusion and constant folding; then build a TensorRT engine in FP16; and only last apply INT8 post-training quantization, which is the only step in the list that can change your accuracy.

(1) Host-Bound Pipeline: a Python BertTokenizer plus per-batch collation can cost 15-25 ms while the forward pass costs 6 ms, so a serial loop pins utilization near 25% no matter how fast the GPU is.
(2) Synchronous Copies: a transfer from pageable host memory cannot be overlapped with compute, because the driver must stage it through an internal pinned buffer; pinned memory plus non_blocking=True is what makes DMA overlap legal.
(3) Padding Waste: attention cost scales as O(L^2), so padding a batch whose mean length is 27 tokens out to 128 burns roughly 79% of the tokens on [PAD], which looks like high utilization and low throughput.
(4) Kernel Launch Overhead: eager BERT-base issues on the order of a thousand kernels per forward pass; at batch 1 the launch and layout overhead, not the math, sets latency.
(5) Detection By Elimination: loop over one cached pre-tokenized tensor. If throughput jumps, the bottleneck was host-side; if it does not, profile kernels with Nsight Systems and look at gaps between them.
(6) Optimize In Cost Order: dataloader and padding fixes are free and safe, ONNX and TensorRT FP16 are numerically near-lossless, and INT8 is the only step that requires a calibration set and an accuracy regression gate.

Two stacked timelines in milliseconds. The upper pair shows a serial dataloader where the CPU tokenizes for 18 ms while the GPU row is idle, then the GPU computes for 6 ms while the CPU waits, repeating so the GPU is idle three quarters of the time. The lower pair shows three CPU worker lanes tokenizing in staggered 18 ms windows offset by 6 ms each, after an 18 ms prefetch warm-up, feeding a GPU row of back-to-back 6 ms forward passes with no gaps.

Figure 1: The same model and the same GPU, two pipelines. In the serial loop the GPU waits for tokenization and the utilization ceiling is 6/(18+6) = 25\%. With three prefetching workers writing into pinned buffers, host work is fully hidden behind compute after an 18 ms warm-up and the GPU runs back to back. No kernel was made faster here; only the schedule changed.

How To Detect A Host-Side Bottleneck: start by distrusting the utilization percentage. nvidia-smi samples whether at least one kernel was resident, not how many SMs were busy, so query DCGM fields such as SM activity and SM occupancy, or run nvidia-smi dmon and watch the memory-controller column: a host-bound job shows low SM activity and near-zero copy activity in the gaps. Then run the decisive experiment, which takes ten minutes: tokenize one batch once, keep it on the device, and loop the forward pass. That measures your true kernel-only throughput, and the gap between it and end-to-end throughput is exactly your host overhead. Confirm the cause with torch.profiler (look for aten::copy_, cudaStreamSynchronize, and dataloader wait time) or an Nsight Systems timeline, where a host-bound run appears as short kernel clusters separated by long empty stretches on the CUDA stream. Common culprits found this way: the slow Python tokenizer instead of the Rust BertTokenizerFast, num_workers=0, tokenizing inside the request handler on the same thread that owns the GPU, a stray .item() or .cpu() inside the loop forcing a sync every step, and logging metrics per step.

Mathematical Formulation:
U_{\mathrm{serial}} = \frac{t_{\mathrm{gpu}}}{t_{\mathrm{cpu}} + t_{\mathrm{gpu}}}
U_{\mathrm{ovl}} = \frac{t_{\mathrm{gpu}}}{\max(t_{\mathrm{cpu}}/W,\; t_{\mathrm{gpu}})}
C \propto B\,(L^2 d + L d^2)
w = 1 - \bar{L}/L_{\max}

Where:

  • U is the fraction of wall-clock time the GPU spends executing kernels, capped at 1, with U_{\mathrm{serial}} for a blocking loop and U_{\mathrm{ovl}} for an overlapped prefetch pipeline.
  • t_{\mathrm{cpu}} is per-batch host time (tokenize, collate, copy) and t_{\mathrm{gpu}} is per-batch device time for the forward, or forward plus backward when training.
  • W is the number of dataloader workers or preprocessing processes; overlap only holds if their output lands in pinned memory and the copy is issued as non_blocking.
  • C is the dominant FLOP count per batch with B the batch size, L the padded sequence length, and d the hidden width; the L^2 d term is attention and the L d^2 term is the projections and feed-forward.
  • w is the fraction of computed tokens that are padding, with \bar{L} the mean true length and L_{\max} the padded length; w understates the true waste whenever the L^2 term dominates.

Worked Numbers For One Batch:
U_{\mathrm{serial}} = \frac{6}{18 + 6} = 0.25
U_{\mathrm{ovl}} = \frac{6}{\max(18/3,\; 6)} = 1.0
w = 1 - \frac{27}{128} = 0.79

Three workers are enough to hide 18 ms of host work behind a 6 ms forward pass, and the same arithmetic tells you when adding workers stops helping: once t_{\mathrm{cpu}}/W drops below t_{\mathrm{gpu}}, extra workers only add memory pressure and page-cache churn. The padding figure is the other half of the story. Sorting a serving queue by length into buckets, or using dynamic padding to the longest member of each batch, typically recovers most of that 79% at zero accuracy cost, and it is strictly better than buying a larger GPU to compute attention over [PAD] tokens faster.

Bar chart of throughput in sequences per second for five BERT-base inference configurations at batch 32 and sequence length 128 on a single mid-range GPU: PyTorch eager FP32 with a serial tokenizer at 210, PyTorch eager FP32 with three workers and pinned memory at 320, ONNX Runtime FP32 with a fused graph at 480, TensorRT FP16 at 1150, and TensorRT INT8 with post-training calibration at 1900.

Figure 2: An illustrative optimization ladder for BERT-base at batch 32, sequence 128, on one mid-range GPU. Each rung removes a different bottleneck: host stalls first, then kernel launch and layout overhead through graph fusion, then arithmetic precision. Only the last rung can move your evaluation metric, which is why it ships behind an accuracy gate.

PropertyPyTorch eagerONNX RuntimeTensorRT (FP16 / INT8)
Graph optimizationNone by default; op-by-op dispatchConstant folding, LayerNorm and GELU fusion, attention fusionFull autotuned kernel selection plus fused multi-head attention
Variable sequence lengthFree; any shape runsDynamic axes supported, some re-optimization per new shapeNeeds declared optimization profiles; shapes outside them fail or fall back
Build and deploy costZero; ship the checkpointOne export step, portable artifactMinutes of engine build, plus a calibration pass for INT8
PortabilityRuns anywhere PyTorch runsCPU, GPU, and other execution providersEngine is tied to GPU architecture and TensorRT version
Accuracy riskBaselineNumerically equivalent in FP32FP16 usually within noise; INT8 needs a regression gate
Dominant failure modeLaunch overhead and host stalls dominate at small batchUnsupported op forces a subgraph back to a slow pathEngine rebuild on driver or hardware change; activation outliers break INT8

Code Implementation:

# 1) hide host work: fast tokenizer, workers, pinned memory, prefetch
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("bert-base-uncased", use_fast=True)

def collate(batch):
    # dynamic padding to the longest item, not to max_length
    return tok([b["text"] for b in batch], padding=True,
               truncation=True, max_length=128, return_tensors="pt")

loader = torch.utils.data.DataLoader(
    ds, batch_size=32, collate_fn=collate,
    num_workers=4, pin_memory=True, persistent_workers=True,
    prefetch_factor=4)

for batch in loader:
    batch = {k: v.to("cuda", non_blocking=True) for k, v in batch.items()}
    with torch.autocast("cuda", dtype=torch.float16):
        out = model(**batch)      # no .item() / .cpu() inside the loop

# 2) measure kernel-only throughput to prove where the time goes
cached = {k: v.cuda() for k, v in collate(ds[:32]).items()}
torch.cuda.synchronize(); t0 = time.perf_counter()
for _ in range(200):
    with torch.no_grad(), torch.autocast("cuda", dtype=torch.float16):
        model(**cached)
torch.cuda.synchronize()
print("device-only seq/s:", 200 * 32 / (time.perf_counter() - t0))

# 3) export and build: ONNX -> TensorRT with dynamic shape profiles
# torch.onnx.export(model, tuple(cached.values()), "bert.onnx",
#                   dynamic_axes={"input_ids": {0: "B", 1: "L"}}, opset_version=17)
# trtexec --onnx=bert.onnx --fp16 --int8 --calib=calib.cache \
#         --minShapes=input_ids:1x16 --optShapes=input_ids:32x128 \
#         --maxShapes=input_ids:64x256 --saveEngine=bert.plan

Login to view more content


Log in to track your progress

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *