GPU Optimization
Tối ưu GPU — Profiler kéo sập bottleneck
Một GPU A100 có thể đạt 312 TFLOPS khi chạy FP16 Tensor Core. Trong thực tế, một bài train LLM thường đạt bao nhiêu phần trăm con số đó?
Hãy hình dung một nhà máy cơ khí có hàng nghìn máy CNC chạy song song — đó là GPU. Phía trong phân xưởng là kho nguyên liệu nhỏ nhưng cực nhanh (SRAM, vài MB) và bên ngoài là kho tổng khổng lồ nhưng xa hơn (HBM, vài chục GB). Mỗi khi máy CNC cần thêm nguyên liệu, xe nâng phải chạy ra kho tổng, lấy pallet, và mang về. Nếu máy CNC cứ phải chờ xe nâng, sản lượng tuột dốc dù bạn có thêm bao nhiêu máy đi nữa.
Đó chính là vấn đề memory bandwidth: tốc độ tính toán của Tensor Core nhanh hơn tốc độ HBM khoảng 50-100 lần. Phần lớn công việc của kỹ sư tối ưu GPU là sắp xếp lại timeline sao cho xe nâng và máy CNC không phải đợi nhau. Profiler là camera quan sát toàn bộ phân xưởng — không có nó, bạn chỉ đang đoán.
Các tối ưu nổi tiếng đều có bóng dáng trong ẩn dụ này. Mixed precision giống như dùng pallet nhẹ hơn — xe nâng chạy nhanh hơn. Gradient checkpointing là "vứt bớt bán thành phẩm, tự gia công lại khi cần" — đánh đổi thời gian máy lấy không gian kho. Kernel fusion (ví dụ FlashAttention) là "làm xong cả 3 công đoạn trước khi trả pallet về kho tổng". Data parallelism là mở thêm nhà máy song song, nhưng đêm nào các nhà máy cũng phải họp để chia sẻ bản vẽ (all-reduce gradient).
Trong phần tiếp theo, bạn sẽ cầm lái profiler. Mỗi toggle bạn bật là một tinh chỉnh trên phân xưởng; timeline thay đổi ngay lập tức, và bottleneck badge cho biết đâu là chỗ nghẽn tiếp theo. Đừng chỉ nhìn throughput tăng — quan trọng hơn là hiểu tại sao.
Hình minh họa
Nsight-style timeline
Mỗi thanh là một event GPU trong 1 training step. Bật các tối ưu bên dưới và quan sát.
Tỷ lệ compute/bandwidth = 312 / 1.555 ≈ 200 FLOPs/byte. Workload có arithmetic intensity thấp hơn 200 sẽ bị memory-bound ngay cả khi dùng Tensor Core.
Công thức roofline: hiệu năng là minimum của hai giới hạn. Nếu AI quá thấp, dù Tensor Core có nhanh cũng vô ích — dữ liệu không về kịp. Ngược lại nếu AI cao, workload compute-bound, và Tensor Core phát huy hết.
Profiler cho thấy: compute 18%, HBM 52%, idle 30%. Bạn nên ưu tiên gỡ chỗ nào TRƯỚC TIÊN?
Bạn train Transformer 13B FP32 trên 1×A100 80GB thì OOM. Thứ tự bật tối ưu nào TỐI ƯU NHẤT về memory?
Giải thích
Một GPU hiện đại có hai lớp hệ thống mà bạn phải đồng thời tối ưu: compute fabric (CUDA cores, Tensor Cores, RT cores) và memory hierarchy (HBM → L2 → SMEM → register). Phần lớn performance problem trong ML workload nằm ở lớp thứ hai. Lý do đơn giản: Tensor Core có thể tạo ra 312 TFLOPS, nhưng HBM chỉ cấp được 1555 GB/s. Nếu bạn cần 4 byte (một FP32) cho mỗi FLOP, tức là cần 312 × 10¹² × 4 = 1248 TB/s băng thông — gấp 800 lần những gì HBM có thể cấp. Do đó Tensor Core phải tái sử dụng dữ liệu trong SRAM; nếu không, nó sẽ idle.
Để đo được tất cả những điều trên, bạn dùng profiler. Hai công cụ chính của NVIDIA là:
- Nsight Systems (nsys): xem timeline ở cấp độ process/stream/kernel. Phù hợp để tìm idle gap, data loader bottle‑ neck, kernel launch overhead, sync không cần thiết.
- Nsight Compute (ncu): deep dive vào một kernel cụ thể. Cho bạn occupancy, warp stall reasons, L1/L2 hit rate, Tensor Core utilization.
- PyTorch Profiler: wrapper tiện lợi, export sang Chrome Tracing hoặc TensorBoard. Đủ tốt cho 80% use case training.
- Nvidia-smi / DCGM:thống kê mức độ sử dụng GPU, nhưng chỉ số "GPU util" gây hiểu lầm — nó chỉ nói có kernel chạy hay không, không phản ánh occupancy thật.
Mixed precisionlà đòn bẩy đầu tiên nên kéo. Khái niệm: lưu weight ở FP32 (để cộng gradient không mất độ chính xác), nhưng chạy forward/backward ở FP16 hoặc BF16. Tensor Core xử lý FP16 nhanh gấp 8× FP32 CUDA, và memory giảm một nửa. BF16 (Brain Floating Point) có cùng kích thước FP16 nhưng dynamic range rộng như FP32 → ít gặp overflow hơn, không cần loss scaling. Đây là mặc định cho các model > 1B param hiện nay.
import torch
from torch.cuda.amp import autocast, GradScaler
model = MyTransformer().cuda()
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4)
scaler = GradScaler() # xử lý FP16 overflow tự động
for batch in dataloader:
optimizer.zero_grad()
# Autocast: tự động chuyển sang FP16 cho matmul / conv,
# giữ FP32 cho layernorm / softmax để ổn định.
with autocast(dtype=torch.float16):
output = model(batch["input"])
loss = criterion(output, batch["label"])
# Loss scaling: nhân loss với số lớn để gradient không underflow ở FP16.
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
# Với BF16, không cần GradScaler vì dynamic range đủ rộng:
# with autocast(dtype=torch.bfloat16):
# ...
# loss.backward()
# optimizer.step()
Gradient checkpointing(còn gọi là activation checkpointing) là đòn bẩy thứ hai. Bình thường, forward pass lưu mọi activation để backward pass có thể dùng khi tính gradient. Với model vài chục tầng và sequence dài, activation chiếm memory nhiều hơn cả weight. Checkpointing chỉ lưu activation ở một số "checkpoint layer" và tính lại phần giữa khi cần. Giá phải trả: forward pass được chạy hai lần (một lần chính thức, một lần recompute) → ~ 30% compute overhead đổi lấy 50-70% giảm memory.
Tensor Coreslà đơn vị phần cứng chuyên biệt cho matmul 4×4 FP16 (hoặc TF32, BF16, FP8 tùy generation). Chúng tăng throughput matmul 8-16× so với CUDA core tiêu chuẩn. Để Tensor Core "bắt" được kernel, shape tensor phải là bội của 8 (cho FP16) hoặc 16 (cho FP8). Đây là lý do nhiều codebase dùng hidden size 4096, 6144, 8192 — đều chia hết cho 8. Dùng 4095 sẽ vô tình đẩy kernel về CUDA core.
import torch
# Shape phải là multiple của 8 cho FP16 Tensor Core.
# 4096 = 8 * 512 → OK
# 4095 → PyTorch sẽ pad hoặc rơi về CUDA core
a = torch.randn(128, 4096, dtype=torch.float16, device="cuda")
b = torch.randn(4096, 4096, dtype=torch.float16, device="cuda")
# Bật TF32 cho matmul FP32 — tăng tốc ~2x, sai số ~1e-3
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
# Với Ampere/Hopper, dùng BF16 thay FP16 nếu gặp NaN:
a_bf = a.to(torch.bfloat16)
b_bf = b.to(torch.bfloat16)
out = a_bf @ b_bf # tự động chạy trên Tensor Core
# Profile để chắc chắn:
# with torch.profiler.profile(activities=[ProfilerActivity.CUDA]) as p:
# out = a_bf @ b_bf
# print(p.key_averages().table(sort_by="cuda_time_total"))
# Tìm kernel 'tensorop' hoặc 'hmma' → Tensor Core đang chạy.
Kernel fusion là bước tối ưu tiếp theo khi mixed precision và checkpointing đã bật. Ý tưởng: thay vì gọi nhiều kernel nhỏ (mỗi kernel đọc/ghi HBM riêng), fuse chúng thành một kernel lớn giữ dữ liệu trong SRAM. Ví dụ nổi tiếng nhất là FlashAttention: attention naive viết ma trận N×N ra HBM rồi đọc lại cho softmax (tốn 4N² bytes memory). FlashAttention chia attention thành tile, tính softmax streaming trong SRAM, chỉ ghi output cuối cùng ra HBM. Kết quả: 2-4× nhanh hơn và dùng ít memory hơn ~20×.
Distributed training mở rộng sang nhiều GPU khi một GPU không đủ. Có ba chiến lược chính:
- Data parallelism (DP/DDP): mỗi GPU có full model, xử lý batch khác nhau, sync gradient qua all-reduce. Đơn giản, scale tốt tới vài chục GPU nếu model đủ nhỏ.
- Tensor parallelism (TP): chia trọng số của mỗi layer ra nhiều GPU. Giao tiếp dày đặc (mỗi layer có 2 all-reduce), yêu cầu NVLink băng thông cao.
- Pipeline parallelism (PP): chia layer thành stage trên các GPU khác nhau, pipeline micro-batch qua các stage để lấp đầy bubble.
- ZeRO / FSDP: shard optimizer states, gradient, và weight qua các GPU. Giữ được mô hình lớn mà vẫn cho cảm giác như data parallel.
Tối ưu GPU là một vòng lặp: profile → tìm bottleneck → áp dụng đúng tool → profile lại. Bạn không thể đoán đúng chỉ bằng đọc paper; mỗi workload có pattern riêng. Công cụ có sẵn (Nsight, PyTorch Profiler, DCGM) đã đủ cho 95% trường hợp. Phần khó là đọc output và biết tìm dấu hiệu gì.
Các chủ đề bạn có thể muốn đọc tiếp: tối ưu inference với KV-cache, model serving ở scale, và tối ưu chi phí GPU cloud.
- Profile trước, tối ưu sau — Nsight Systems cho timeline, Nsight Compute cho kernel deep dive, PyTorch Profiler cho use case thường ngày.
- Phân loại bottleneck bằng time share: compute-bound, memory-bound, idle-bound — mỗi loại có playbook tối ưu khác nhau.
- FP16/BF16 + Tensor Cores là đòn bẩy đầu tiên: tăng throughput 2-8×, giảm memory 50%, gần như không rủi ro với BF16.
- Gradient checkpointing đổi 30% compute lấy 50-70% memory — chỉ bật khi bạn thật sự cần memory.
- Kernel fusion (FlashAttention, torch.compile) giảm HBM roundtrip — đặc biệt quan trọng cho attention và softmax.
- Multi-GPU là phương án cuối cùng: DP → ZeRO/FSDP → TP → PP, theo thứ tự độ phức tạp tăng dần. Luôn tối ưu single-GPU trước.
Kiểm tra hiểu biết
Profiler cho thấy kernel matmul chiếm 70% thời gian, HBM transfer chỉ 15%, idle 5%. Workload này là gì?