Ca trực GPU lúc nửa đêm
Đừng tối ưu model bằng linh cảm. Đây là playbook đọc trace PyTorch để biết bottleneck nằm ở CPU, GPU, sync hay compile.
Bụi Wire“GPU đang rảnh mà latency vẫn cao, chắc model dở?”
Đó là câu Nam, tech lead của một team AI nội bộ ở Sài Gòn, nhắn vào group lúc 11 giờ đêm. Team đang triển khai một service inference PyTorch: nhận request, chạy vài phép tensor, trả kết quả cho hệ thống recommendation. Dashboard nhìn khá vô lý: GPU utilization không căng, CPU cũng chưa cháy, nhưng p95 latency cứ nhấp nhô như bệnh nhân sốt nhẹ kéo dài.
Sáng hôm sau, cả team có hai lựa chọn: đổi model, bật torch.compile, hoặc tăng GPU. Cả ba đều nghe có vẻ hợp lý. Nhưng hợp lý kiểu “đau bụng thì uống đại thuốc giảm đau” — đỡ hôm nay, mai chưa chắc biết bệnh gì.
Luận điểm của mình: trước khi tối ưu PyTorch, bạn cần chẩn đoán đường đi của một operation, không phải chỉ nhìn một con số latency cuối cùng. torch.profiler không phải món đồ trang trí cho notebook; nó là hồ sơ khám bệnh của pipeline.

Minh họa tóm tắt ý chính của bài viết.
Mục tiêu: biết chỗ nào cần mổ, chỗ nào chỉ cần nghỉ
Với builder, câu hỏi không phải “làm sao cho nhanh hơn?” mà là:
- CPU đang mất thời gian chuẩn bị lệnh hay GPU đang tính lâu?
- Có đoạn nào buộc CPU chờ GPU không?
- Kernel có bị launch quá nhiều lần không?
torch.compilecó thật sự giảm overhead, hay chỉ chuyển chi phí sang chỗ khác?
Một vài thuật ngữ cần neo nhanh:
torch.profiler: công cụ ghi lại timeline chạy code PyTorch, cho bạn thấy CPU và GPU làm gì theo thời gian.- Trace: bản ghi timeline, giống phim quay chậm của từng operation.
- CPU lane / GPU lane: hai “làn” trong trace cho biết việc diễn ra ở CPU hay GPU.
- CUDA runtime call: lời gọi từ CPU sang CUDA để điều phối công việc trên GPU.
cudaDeviceSynchronize: điểm đồng bộ, nơi CPU phải chờ GPU xong việc.torch.compile: cơ chế compile graph để tối ưu execution, thường nhắm tới giảm overhead và fuse operation, nhưng không miễn phí.
Điểm đổi cách nghĩ nằm ở đây: bottleneck không phải lúc nào cũng nằm trong phép toán nặng nhất. Đôi khi thứ làm bạn chậm là khoảng chờ, offset giữa CPU và GPU, hoặc một sync vô tình chen vào giữa request path.
Câu chuyện của Nam: bật compile trước, hiểu trace sau
Team Nam có một đoạn xử lý tối giản kiểu matrix multiplication rồi cộng bias. Trong môi trường demo, code chạy ổn. Khi đưa vào service thật, latency bắt đầu nhiễu.
Nam thử torch.compile vì nghĩ: compile thì nhanh hơn. Nhưng kết quả không rõ ràng. Có request nhanh hơn, có request không. CPU overhead còn có lúc tăng.
Đây là va chạm quen thuộc: nhiều team xem torch.compile như thuốc kê sẵn, trong khi nó giống một phác đồ điều trị — phải biết triệu chứng trước. Nếu vấn đề của bạn là GPU kernel quá nặng, compile có thể giúp theo một kiểu. Nếu vấn đề là shape thay đổi liên tục, warmup chưa ổn, hoặc CPU phải làm nhiều việc điều phối, câu chuyện khác hẳn.
Trong bài hướng dẫn về torch.profiler, có một chi tiết rất đáng nhớ: trace của phép toán nhỏ như 64x64 có thể lộ ra những khoảng offset giữa CPU và GPU, ví dụ khoảng lệch cỡ 2,5 ms trong một trace cụ thể. Cũng có đoạn cudaDeviceSynchronize lớn, khoảng 1,78 ms trong ví dụ đó. Những con số này không phải để bạn bê nguyên về hệ thống của mình, mà để nhớ một điều: timeline có thể kể ra thứ bảng tổng hợp latency che mất.
Ví dụ cụ thể: giả sử service của bạn gọi 6 operation nhỏ liên tiếp. Mỗi operation nhìn riêng không đáng kể. Nhưng nếu mỗi lần đều có overhead launch kernel, rồi xen vài điểm sync, tổng request path sẽ giống một phòng khám có quá nhiều khâu ký giấy: bác sĩ rảnh, bệnh nhân vẫn ngồi chờ.
Checklist trước khi đụng vào code production
Trước khi tối ưu, team Nam chốt một checklist ngắn. Bạn có thể dùng lại trong một buổi chiều:
1. Chọn workload đại diện
Đừng profile một tensor đồ chơi nếu production dùng batch size khác, shape khác, dtype khác. Nếu không lấy được dữ liệu thật, hãy tạo input giả nhưng giữ gần với shape và device thật.
2. Tách warmup khỏi vùng đo
GPU execution có chi phí khởi động, cache, compile, memory allocation. Nếu bạn đo cả warmup rồi kết luận production chậm, bạn đang lấy nhịp tim sau khi leo cầu thang để chẩn đoán lúc nghỉ.
3. Ghi cả CPU và CUDA activity
Nếu chỉ nhìn GPU, bạn sẽ bỏ lỡ overhead ở Python, dispatcher, hoặc CUDA launch. Nếu chỉ nhìn CPU, bạn không thấy kernel thật sự chạy ra sao.
4. Xuất trace để đọc timeline
Bảng tổng hợp tốt cho xếp hạng operation. Trace mới cho thấy chuỗi sự kiện: cái gì gọi cái gì, CPU chờ ở đâu, GPU có bị trống đoạn nào không.
5. So sánh trước và sau từng thay đổi
Bật torch.compile, đổi batch size, fuse operation, hay bỏ sync — mỗi lần chỉ đổi một thứ. Nếu đổi ba thứ cùng lúc, bạn sẽ thắng hoặc thua mà không biết vì sao.
Playbook 30 phút: lấy trace đầu tiên cho một operation
Dưới đây là skeleton tối giản để bạn bắt đầu. Nó không thay thế benchmark production, nhưng đủ để team đọc trace cùng nhau.
import torch
from torch.profiler import profile, ProfilerActivity, record_function
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
x = torch.randn(4096, 4096, device=DEVICE)
w = torch.randn(4096, 4096, device=DEVICE)
b = torch.randn(4096, 4096, device=DEVICE)
# Warmup: đừng đo đoạn này
for _ in range(5):
y = x @ w + b
if DEVICE == "cuda":
torch.cuda.synchronize()
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
record_shapes=True,
profile_memory=True,
with_stack=True,
) as prof:
with record_function("matmul_add_path"):
y = x @ w + b
if DEVICE == "cuda":
torch.cuda.synchronize()
print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=20))
prof.export_chrome_trace("trace.json")
Sau đó mở trace.json bằng Chrome trace viewer hoặc công cụ đọc trace bạn đang dùng.
Khi đọc, đừng lao vào dòng dài nhất ngay. Hãy đi theo thứ tự:
- Nhìn CPU lane trước: CPU có mất nhiều thời gian trước khi GPU bắt đầu không?
- Nhìn GPU lane sau: kernel có chạy liên tục hay có khoảng trống?
- Tìm sync point: có
cudaDeviceSynchronize,.item(), copy tensor về CPU, hoặc logging nào ép chờ không? - Đếm kernel launch: nhiều operation nhỏ có thể làm overhead điều phối phình ra.
- Chạy lại với shape khác: operation nhỏ và lớn có triệu chứng khác nhau. Trace 64x64 và 4096x4096 thường kể hai câu chuyện khác.
Nếu muốn thử torch.compile, đừng chỉ đo “có compile” và “không compile”. Hãy đọc trace xem:
- CUDA launches có giảm không?
- Kernel có được fuse không, tức nhiều operation nhập thành ít kernel hơn?
- CPU overhead tăng hay giảm?
- Có chi phí compile lần đầu cần loại khỏi request thật không?
Dịch sang tiếng người: compile không phải nút “nhanh hơn”; nó là một quyết định tradeoff giữa chi phí chuẩn bị, tính ổn định của graph, và lợi ích runtime.
Những bẫy làm team đọc sai trace
Bẫy 1: Tin bảng tổng hợp hơn timeline
prof.key_averages() rất tiện, nhưng nó gom dữ liệu theo operation. Nếu vấn đề là thứ tự sự kiện, khoảng chờ, hoặc CPU-GPU offset, bảng có thể không đủ. Timeline mới cho bạn thấy “ai chờ ai”.
Bẫy 2: Quên đồng bộ khi đo thời gian
GPU chạy bất đồng bộ, nghĩa là CPU có thể bắn lệnh rồi đi tiếp. Nếu bạn đo bằng timer Python mà không synchronize đúng chỗ, số đo có thể lệch. Nhưng synchronize quá nhiều trong production lại có thể làm chậm thật. Đo cho đúng không có nghĩa là nhét sync lung tung vào code chạy thật.
Bẫy 3: Profile workload quá sạch
Notebook tối giản không có data loading, network, serialization, batching queue, logging. Production thì có. Nếu trace local đẹp nhưng service vẫn chậm, hãy profile sát request path hơn: từ lúc nhận tensor đến lúc trả output, không chỉ một phép toán ở giữa.
Bẫy 4: Tối ưu operation nhỏ như tối ưu operation lớn
Với tensor nhỏ, overhead launch và dispatch có thể nổi bật. Với tensor lớn, thời gian tính toán kernel có thể chiếm ưu thế. Cùng một đoạn x @ w + b, nhưng bệnh án khác nhau khi shape khác nhau.
Nếu là mình, mình sẽ kê đơn thế này
Cho một team đang triển khai AI service bằng PyTorch, mình sẽ không bắt đầu bằng “bật compile đi”. Mình sẽ yêu cầu một artifact trước: một trace đại diện, có warmup tách riêng, có CPU và CUDA lane, có ghi chú workload.
Sau đó mới ra quyết định:
| Triệu chứng trong trace | Hướng xử lý nên thử |
| --- | --- |
| GPU có nhiều khoảng trống | Giảm CPU overhead, batching tốt hơn, tránh sync không cần thiết |
| Quá nhiều kernel nhỏ | Thử fuse operation, torch.compile, hoặc viết lại path tính toán |
| Kernel lớn chiếm phần chính | Xem dtype, memory layout, thuật toán, batch size |
| CPU overhead tăng sau compile | Kiểm tra graph break, shape động, chi phí compile/warmup |
| Sync xuất hiện giữa request path | Tìm .item(), copy về CPU, logging tensor, hoặc metric thu thập sai chỗ |
Nam cuối cùng không đổi GPU. Team của bạn ấy tìm thấy một đoạn metric gọi .item() trong hot path, cộng thêm vài operation nhỏ có thể gom lại. torch.compile vẫn được thử, nhưng không còn là niềm tin mù; nó trở thành một nhánh thử nghiệm có tiêu chí rõ.
Sau bài này, điều mình muốn bạn nghĩ khác là: optimization không bắt đầu từ tool mới, mà bắt đầu từ khả năng đọc triệu chứng runtime. GPU không biết nói dối, nhưng trace thì cần người đọc cho tỉnh.
---
Bụi Wire — nghiện đọc release notes lúc 2 giờ sáng