Bộ 07 · Profiling và GPU
Cách tìm ra thời gian đi đâu trước khi đổi bất cứ thứ gì: vòng lặp đo - so mức sàn - định vị, memory snapshot, Nsight Systems và Nsight Compute, và vì sao fusion là cách sửa về bộ nhớ. Bài 55–58, 60, 61. Trả lời thành tiếng mỗi câu trong khoảng một phút, rồi mới xem thẻ.
Q1 · "Cứ bật torch.compile là xong" ưu tiên
Bài: 55, 61
Ghi chú
Người dùng nói decode trên endpoint Llama 3 8B của ta (một H100, BF16) có cảm giác chậm. Quản lý nói "cứ bật torch.compile là xong." Trước khi đổi bất cứ thứ gì, bạn đo cái gì, so với mức sàn nào, và làm sao quyết định phần thời gian bị thiếu thuộc về compute, memory hay overhead?
Trả lời trong 60 giây
- Đo một con số sạch trước: thời gian mỗi token đầu ra. Tôi gọi GPU synchronize trước khi dừng đồng hồ, bỏ các lần chạy warm-up, và lấy trung vị của vài lần chạy, để con số phản ánh decode ổn định chứ không phải nhiễu.
- So nó với một mức sàn: mỗi bước decode phải đọc toàn bộ trọng số, nên một bước nhanh nhất là byte trọng số ÷ băng thông = 16 GB ÷ 3,35 TB/s ≈ 4,8 ms. MBU là mức sàn đó chia cho thời gian đo được. Nếu đo được 25 ms mỗi token, MBU là 4,8 ÷ 25 ≈ 19%, nghĩa là phần lớn thời gian đang đi vào chỗ khác chứ không phải đọc trọng số.
- Định vị phần thời gian bị thiếu bằng trace: nếu các kernel thưa thớt với khoảng trống giữa chúng, GPU đang chờ CPU launch, nên đó là overhead. Nếu các kernel nối liền nhau và MBU ở 70–80%, đó là memory. Nếu chúng nối liền nhưng MBU vẫn thấp, thì một kernel chậm đang chiếm thời gian, và tôi đưa kernel đó sang Nsight Compute.
- torch.compile thực sự sửa cái gì:
mode="reduce-overhead"thêm CUDA graph cộng fusion, nên nó chỉ sửa overhead. Với Llama 7B của bài (trên A100), nó đưa decode từ 25,5 lên 107 token/s, chuyển MBU từ 17% lên 72%. Nếu decode đã memory-bound, chỉ ít byte hơn mới giúp được: trọng số int8 đạt 157 token/s vì đọc ít byte hơn mỗi bước làm bản thân mức sàn được nâng lên. - Đổi một thứ, đo lại, và ghi lại, vì nút thắt dịch chuyển sau mỗi lần sửa.
Các con số chính: mức sàn = byte trọng số ÷ băng thông; MBU = mức sàn ÷ thời gian đo được.
Câu hỏi tiếp theo hay gặp: "Sau compile MBU là 75%. Tiếp theo làm gì?" Ở 75%, bước đã gần giới hạn băng thông, nên decode giờ là memory-bound và các đòn bẩy còn lại đều xoay quanh số byte mỗi token. Tôi sẽ hoặc lượng tử hoá trọng số để mỗi bước đọc ít byte hơn, hoặc batch thêm người dùng vào mỗi lần đọc trọng số để một lần đọc sinh nhiều token hơn.
Mẹo
Tôi dừng ở "decode chắc là memory-bound" mà không tính MBU, và không nói được kiểu trace nào nghĩa là overhead hay memory, hoặc torch.compile thực sự sửa cái nào.
Q2 · OOM ở giờ thứ 6 với 60 GB allocated ưu tiên
Bài: 56
Ghi chú
Server của ta chạy ổn hàng giờ, rồi chết vì CUDA out-of-memory vào khoảng giờ thứ 6. Lúc crash, torch.cuda.memory_allocated() báo 60 GB trên card 80 GB. Memory snapshot có thể cho thấy gì, và sao có thể hết bộ nhớ khi còn trống 20 GB?
Trả lời trong 60 giây
- Capture thế nào: gọi
torch.cuda.memory._record_memory_history()để bắt đầu ghi,_dump_snapshot()để ghi ra file, và mở nó trong memory_viz. Mỗi lần cấp phát trong snapshot mang theo kích thước và stack trace đã tạo ra nó, nên bất kỳ dải bộ nhớ nào cũng lần ngược được về một dòng code. - Bộ nhớ khoẻ mạnh trông như răng cưa: có một mức nền phẳng gồm trọng số và workspace. Mỗi request leo lên khi KV cache của nó lớn dần 128 KB mỗi token, nên một request 2.000 token thêm 2.000 × 128 KB ≈ 262 MB, rồi rơi xuống khi request xong.
- Rò rỉ trông như mức nền leo dần: mỗi đáy lại cao hơn đáy trước, vì có thứ không bao giờ được giải phóng. Stack trace trên dải không bao giờ giải phóng chỉ ra dòng vẫn đang giữ tham chiếu.
- Phân mảnh hiện ra thành khoảng hở giữa reserved và allocated: caching allocator giữ các segment lớn và cắt tensor ra từ đó. Tensor với kích thước và vòng đời khác nhau băm vụn các segment, nên 20 GB trống nhưng nằm rải rác, và một request lớn cần vùng liền mạch sẽ thất bại. Đó là cách có thể hết bộ nhớ khi mới allocated 60 trên 80 GB.
- Cách sửa: đặt
expandable_segments:True, dùng pool kích thước cố định (đó là lý do vLLM phân trang KV cache), và tạo ít tensor tạm kích thước thay đổi hơn.
Các con số chính: allocated = tensor đang sống; reserved = allocator đang giữ; KV mỗi token của Llama 3 8B = 128 KB.
Câu hỏi tiếp theo hay gặp: "Sao không phải do một request quá dài?" Request quá dài sẽ thất bại ngay lần đầu nó đến, không phải sau 6 giờ chạy ổn. Hơn nữa, một engine thật cấp phát trước KV cache, nên KV cache không thể lớn tới mức gây out-of-memory.
Mẹo
Tôi đã đoán "KV cache bùng nổ" hoặc "một request vượt độ dài tối đa". Cả hai đều không khớp với crash sau hàng giờ, và không giải thích được OOM khi còn trống 20 GB.
Q3 · Một trace Nsight Systems sạch của server đang chạy ưu tiên
Bài: 57
Ghi chú
Bạn chạy nsys profile trên một server vLLM đang chạy. File rất lớn và phần lớn là nạp model, và trong lúc decode, hàng GPU hiện một khối mờ đục cho mỗi bước. Sửa từng vấn đề thế nào, và bạn đọc ra ba điều gì từ một cửa sổ decode sạch?
Trả lời trong 60 giây
- Sửa cửa sổ:
--delay=180 --duration=120bỏ qua việc nạp model và các request lạnh đầu tiên, nên capture chỉ ghi lưu lượng ổn định.--kill=nonegiữ server sống sau khi thu thập xong, để profiling không làm sập dịch vụ. - Sửa khối mờ đục: mỗi bước decode là một CUDA graph được phát lại, đó là lý do hàng GPU hiện một khối mỗi bước.
--cuda-graph-trace=nodemở graph ra thành từng kernel, dù thêm overhead đáng kể. Tôi cũng đặtVLLM_WORKER_MULTIPROC_METHOD=spawn, vì nsys không theo được một worker được fork. - Đọc 1, khoảng trống: lỗ hổng trong stream GPU khi người dùng đang chờ nghĩa là thiết bị đang rảnh, nên không phải vấn đề của kernel. Nếu làn API phía trên im lặng, không có gì được yêu cầu từ GPU; nếu nó bận, việc đã được phát đi nhưng không tới kịp.
- Đọc 2, nhịp độ: nếu làn launch dày đặc nhưng các kernel bên dưới là những lát mỏng, CPU đang quyết định nhịp. Nếu các kernel nằm vai kề vai và rộng hơn lời gọi launch của chúng, GPU đang quyết định nhịp.
- Đọc 3, các lần chép: decode ổn định nên giữ các hàng bộ nhớ gần như im lặng. Nếu có các lần chép rộng hoặc theo nhịp giữa host và device, có trạng thái nào đó đang đi qua PCIe ở mỗi bước.
Các con số chính: --trace=cuda,nvtx,osrt; range NVTX ("prefill", "decode") đánh nhãn các pha trên timeline.
Câu hỏi tiếp theo hay gặp: "Khi nào chuyển sang Nsight Compute?" Chỉ khi trace cho thấy GPU bận từ mép này tới mép kia và một kernel có tên chiếm thời gian, vì chỉ khi đó vấn đề mới nằm bên trong một kernel thay vì giữa các kernel.
Mẹo
Tôi chưa dùng Nsight Systems và không kể được các cờ về cửa sổ, CUDA graph, hay ba cách đọc.
Q4 · "Occupancy tệ quá, chỉnh lại block size đi" ưu tiên
Bài: 58, 61
Ghi chú
Nsight Compute trên GEMM decode của ta cho thấy duration dài gấp 3 lần production, memory throughput 92%, compute 2%, achieved occupancy 25%. Một đồng nghiệp muốn chỉnh lại block size. Bạn nói gì với họ?
Trả lời trong 60 giây
- Duration gấp 3 lần là sản phẩm phụ của profiling.
ncuphát lại lần launch một lần cho mỗi nhóm metric, mặc định 46 lượt, và nó khôi phục bộ nhớ giữa các lượt, tuần tự hoá các lần launch và ghim xung nhịp. Vì vậy duration không nói gì về production, chỉ giá trị metric là có nghĩa. - 92% memory và 2% compute là kết quả khoẻ mạnh với kernel này. GEMM decode có intensity khoảng 1 FLOP mỗi byte, thấp xa so với điểm gãy ≈ 295 FLOP mỗi byte của H100. Vì vậy nó nên nằm ở giới hạn bộ nhớ với phần tính toán rảnh, nghĩa là nó đã ở mức sàn.
- Occupancy thấp tự nó không kết tội kernel. Thêm warp chỉ có ích nhờ che giấu latency, và khi memory throughput đã gần trần, chúng không còn gì để lấy thêm. Occupancy chỉ đáng lo khi nó thấp và cả hai throughput cũng thấp.
- Vậy chỉnh block size không mua được gì. Cách duy nhất để tăng tốc kernel này là ít byte hơn, bằng cách lượng tử hoá trọng số, hoặc nhiều token hơn mỗi lần đọc trọng số, bằng batching.
- Tôi sẽ dự đoán các con số Speed Of Light từ roofline trước khi mở báo cáo, để nếu báo cáo không khớp với dự đoán, sự không khớp đó có ý nghĩa.
Các con số chính: điểm gãy = 989 TFLOPS ÷ 3,35 TB/s ≈ 295; GEMM decode I ≈ kích thước batch; attention decode I ≈ 4 với GQA.
Câu hỏi tiếp theo hay gặp: "Nếu L2 hit rate trên GEMM đó cao thì sao?" Hit rate cao nghĩa là một số byte tôi đã đếm chưa bao giờ rời cache, nên tôi sẽ tính lại giới hạn trước khi tin báo cáo. Nhưng tôi vẫn nhớ tỷ lệ: 16 GB trọng số không thể quay vòng trong L2 50 MB.
Mẹo
Tôi chưa dùng Nsight Compute và không đọc được báo cáo: thời gian phát lại, cặp Speed Of Light và occupancy đều cần được giải thích.
Q5 · Ba op từng phần tử ở eager mode ưu tiên
Bài: 60, 61
Ghi chú
Trong PyTorch eager, một layer chạy x = x * scale, x = x + bias, x = gelu(x) trên một activation BF16 lớn. Vì sao nó chậm khi phép tính rất đơn giản? Gộp thành một kernel thay đổi gì, nó nhắm vào cái giỏ nào, và các giá trị trung gian nằm ở đâu?
Trả lời trong 60 giây
- Mỗi op eager bắt đầu và kết thúc ở HBM: nó đọc x từ HBM và ghi x trở lại. Vậy ba op là ba chuyến đi về, tức 6 lần truyền cỡ một tensor, tất cả chỉ cho vài FLOP mỗi phần tử.
- Mỗi op tự nó là memory-bound: một op đơn giản làm 1 FLOP mỗi phần tử trong khi đọc 2 byte và ghi 2 byte, nên intensity của nó là 1 ÷ 4 ≈ 0,25 FLOP mỗi byte. Thấp xa so với điểm gãy ≈ 295, nên mỗi kernel là memory-bound.
- Fusion chỉ đọc một lần và ghi một lần tổng cộng: đó là 2 lần truyền thay vì 6, nên lưu lượng giảm ≈ 3 lần và nhanh hơn ≈ 3 lần. Nó nhắm vào giỏ memory, và cũng giúp overhead, vì 3 lần launch thành 1.
- Sau đó nó vẫn memory-bound: kể cả với phần tính thêm của GELU, intensity của kernel đã gộp chỉ là vài FLOP mỗi byte, còn rất xa 295. Vậy fusion làm nó nhanh hơn, không làm nó thành compute-bound.
- Các giá trị trung gian ở lại trong register của mỗi thread, truy cập mất ≈ 1 chu kỳ, 256 KB mỗi SM, nên chúng không bao giờ quay về HBM. Các op theo tile như matmul và FlashAttention thì đặt dữ liệu tạm trong shared memory.
Các con số chính: 4096 × 4096 BF16 = 33,5 MB; một lần đọc + ghi = 67 MB ÷ 3,35 TB/s ≈ 20 µs so với 0,25 µs phép tính (≈ 80 lần).
Câu hỏi tiếp theo hay gặp: "Khi nào fusion làm một kernel thành compute-bound?" Chỉ khi các op từng phần tử được gộp vào thứ vốn đã dùng lại mỗi byte hàng trăm lần, như phần epilogue của matmul. Riêng chuỗi op từng phần tử không bao giờ chạm điểm gãy, vì mỗi byte chỉ nuôi vài FLOP.
Mẹo
Tôi đã nói fusion đưa op thành compute-bound. Nó cắt bớt byte, nhưng intensity vẫn rất nhỏ, nên kernel vẫn memory-bound, chỉ là nhanh hơn.
Tự xây
Bộ này chưa có bài tập. Cách luyện tốt nhất là làm thật: bấm giờ một bước decode có và không có torch.compile(mode="reduce-overhead"), tính MBU cho mỗi trường hợp, và capture một cửa sổ Nsight Systems cho mỗi trường hợp để thấy các khoảng trống biến mất.