Điều gì xảy ra bên trong khi chạy một kernel CUDA
(fergusfinn.com)- Ngay cả một chương trình CUDA cộng vector đơn giản cũng phải đi qua pipeline biên dịch, các lời gọi driver, hàng đợi lệnh GPU, lập lịch warp, phân cấp bộ nhớ và semaphore hoàn tất trước khi thu được kết quả
2.000000 nvcctách mã host và mã device, tạo PTX bằngcicc, tạo SASS bằngptxas, rồi đóng gói cubin và PTX vào fatbin và đưa vào trong tệp thực thi Linux- Cú pháp launch
vadd<<<4096, 256>>>được chuyển thành host launch stub, còn các đối sốda,db,dc,nđược truyền tới driver thông qua CUDA runtime vàlibcuda.so.1 - Việc thực thi trên GPU bắt đầu bằng QMD, pushbuffer, GPFIFO,
GP_PUT, thao tác ghi MMIO doorbell; 128 SM của RTX 4090 thực thi cấu hình 4096 block và 256 thread theo đơn vị warp - Kernel này bị chi phối bởi băng thông bộ nhớ do mật độ tính toán thấp, cần truyền 12 byte cho mỗi phép cộng float; Nsight Compute ghi nhận 10.78μs, 79.65% đỉnh DRAM, warp issue 5.17%
Kernel ví dụ và phạm vi quan sát
- Chương trình ví dụ dùng kernel CUDA
vaddđể cộng hai mảng float và lưu vào mảng thứ ba- Xử lý 1.048.576 float với
n = 1 << 20 - Cấu hình launch là
vadd<<<4096, 256>>>(da, db, dc, n), dùng4096 * 256 = nthread
- Xử lý 1.048.576 float với
- Khi biên dịch cho RTX 4090 bằng
nvcc -arch=sm_89và chạy, chương trình in rac[0]=2.000000 c[n-1]=2.000000 - Ngay cả kết quả một dòng này cũng có sự tham gia của hàng chục triệu lệnh CPU, device file, khoảng 900 lời gọi
ioctlvà thanh ghi doorbell được memory-map
Cách nvcc tạo tệp thực thi
- Dùng
nvcc --keepcó thể xem trực tiếp các artifact của pipeline biên dịchvadd.ptx: PTX của mã device docicctạovadd.sm_89.cubin: SASS của mã device doptxastạovadd.fatbin: fatbin đóng gói cubin và PTXvadd.cudafe1.stub.c: host launch stub và mã đăng ký kernelvadd.o: object host cuối cùng có chứa fatbin
- Mã host được xử lý bởi trình biên dịch host, còn kernel device
vaddđi qua các bướcciccvàptxas - PTX là ISA ảo, dùng vô số thanh ghi ảo có kiểu và không phản ánh trực tiếp số thanh ghi phần cứng thực tế
- PTX ví dụ bao gồm phép tính
blockIdx.x * blockDim.x + threadIdx.x, kiểm tra biên, global load, float add và global store - Con trỏ CUDA mặc định là generic pointer, nên được chuyển sang global address bằng
cvta.to.globalrồi dùngld.global mul.wide.s32chuyển index thành offset theo đơn vị 4 byte làsizeof(float)và mở rộng từ 32 bit lên 64 bit
- PTX ví dụ bao gồm phép tính
- SASS là lệnh thực tế theo từng kiến trúc; trong output cho RTX 4090, nó xuất hiện dưới dạng cô đọng hơn PTX
S2Rsao chép các thanh ghi đặc biệt nhưSR_CTAID.X,SR_TID.Xsang thanh ghi thông thường- Tổ hợp
mul.widevàaddcủa PTX được gộp thànhIMAD.WIDEtrong SASS - Phép chuyển đổi
cvtađược hấp thụ vào quá trình định địa chỉ
- Toán hạng
c[0x0][...]trỏ tới constant bank 0 do driver quản lý- Các con trỏ
a,b,cnằm tại0x160,0x168,0x170 nnằm tại0x178- Launch geometry như
blockDim.xvà các giá trị ABI cũng nằm trong cùng bank
- Các con trỏ
- cubin là tệp ELF, cùng định dạng container với tệp thực thi Linux
- fatbinary đóng gói cubin và PTX cùng nhau
- Trên RTX 4090 này, SASS được thực thi thực tế, nhưng PTX được đưa vào làm fallback để driver có thể JIT compile trên kiến trúc khác
- PTX là plain text dài dòng nên
nvccmặc định nén nó
Cách mã host chuẩn bị launch
- Frontend trình biên dịch
cudafe++chèn một constructor ẩn chạy trướcmain- Constructor này đăng ký fatbinary được nhúng với CUDA runtime
- Nó liên kết function pointer phía host
vaddvới tên device kernel đã bị mangle trong fatbin
- Cú pháp
vadd<<<4096, 256>>>(da, db, dc, n)được chuyển thành host launch stub được sinh rada,db,dc,nđược căn chỉnh và đưa vào argument buffer trong host memory lần lượt ở các offset0,8,16,24- Các offset này tương ứng với vị trí
0x160,0x168,0x170,0x178mà SASS đọc trong constant bank 0
- Stub gọi
__cudaLaunchvà truyền địa chỉ hàm dummyvaddphía host- Địa chỉ này không phải địa chỉ hàm để CPU thực thi, mà được dùng làm key để tra bảng đăng ký của runtime
- Runtime tìm device symbol name tương ứng rồi chuyển sang
libcuda.so.1, user-mode driver mã nguồn đóng
- Ở lần gọi GPU đầu tiên, CUDA runtime mở động
libcuda.so.1và tạo context- Trong
stracecó thể thấy/lib/x86_64-linux-gnu/libcuda.so.1được mở - Context bao gồm một channel để CPU giao tiếp với GPU
- Trong
- Từ CUDA 12.2, module loading mặc định là lazy
- Việc upload SASS cubin được trì hoãn cho đến khi một kernel cụ thể được launch lần đầu
- Có thể điều khiển bằng
CUDA_MODULE_LOADING
Hàng đợi lệnh truyền công việc cho GPU
- GPU không nhận lời gọi hàm rồi jump tới entry point như CPU
- Nó đọc driver command stream trong host memory qua bus PCIe
cuLaunchKernelđưa launch command đã hoàn chỉnh vào stream này và báo cho GPU
- Ở lần chạy đầu, driver sao chép SASS của kernel vào bộ nhớ GPU
- Cấp phát code buffer và sao chép SASS
- Channel có hai cấu trúc cốt lõi nằm trong host RAM
- pushbuffer: vùng nhớ nơi driver ghi các method là lệnh GPU
- GPFIFO: ring buffer con trỏ trỏ tới các span của pushbuffer
- Một GPFIFO entry gồm hai word 32 bit biểu diễn
(base, length)của span pushbuffer - GPU và driver theo dõi vị trí tiêu thụ và sản xuất công việc bằng hai cursor
GP_GET: cho biết GPU đã tiêu thụ đến đâuGP_PUT: cho biết driver đã sản xuất đến đâu- Cả hai đều nằm trong cấu trúc per-channel gọi là USERD
- Khi launch kernel, driver ghi các method vào span pushbuffer, để GPFIFO entry trỏ tới nó rồi tăng
GP_PUT - Trên GPU hiện đại, host engine không liên tục giám sát cursor nên cần doorbell
- GPU map một cửa sổ thanh ghi nhỏ vào process
- Driver ghi work-submit token của channel vào thanh ghi doorbell
- Sau khi nhận doorbell, host engine đọc
GP_PUTrồi lấy GPFIFO entry và span pushbuffer bằng DMA
Thông tin thực thi chứa trong QMD
- Launch bắt đầu bằng một burst method
SET_INLINE_QMD_ADDRESS_A/BvàLOAD_INLINE_QMD_DATA - QMD(Queue Meta Data) là launch descriptor của compute grid
- Bao gồm kích thước grid và block là
4096,256 - Bao gồm số thanh ghi mỗi thread và yêu cầu shared memory
- Bao gồm địa chỉ bắt đầu chương trình và địa chỉ constant bank chứa đối số kernel
- Cũng bao gồm vị trí để báo hoàn tất
- Bao gồm kích thước grid và block là
- Các đối số do host stub đóng gói được driver sao chép vào constant bank, và địa chỉ bank đó được ghi trong QMD
- QMD cho GPU biết vị trí SASS, cách cấu hình chương trình song song và vị trí tín hiệu hoàn tất
cuLaunchKerneltrả về ngay khi doorbell được rung- Lời gọi là bất đồng bộ, nên CPU có thể tiếp tục chạy trong khi công việc GPU đang diễn ra
SM, warp và occupancy
- Host engine chuyển QMD cho compute work distributor
- Thành phần này tồn tại một lần trên toàn GPU
- Nó phân phối linear SASS instruction stream tới các SM để thực thi như một chương trình song song
- GPU mục tiêu GeForce RTX 4090 dùng 128 SM
- Launch gồm 4096 block và 256 thread mỗi block
- Mỗi SM có instruction cache cục bộ, còn active warp giữ program counter
- Từ Volta trở đi có mô hình Independent Thread Scheduling với program counter và call stack theo từng thread
- Việc issue vẫn diễn ra theo đơn vị warp
- Trong kernel ví dụ, resource limit quyết định block residency
256 threads = 8 warpsmỗi blockptxasdành 16 thanh ghi mỗi thread- Theo tiêu chí thanh ghi, có thể có 16 block mỗi SM
- Dung lượng thread là 1.536 active thread mỗi SM, nên chỉ có
1536 / 256 = 6block - Vì vậy tối đa 6 block mỗi SM, tức 48 warp ở trạng thái resident
- SM được chia thành 4 processing block, tức sub-partition
- 48 resident warp được phân phối đều cho 4 sub-partition
- Mỗi warp scheduler quản lý 12 active warp khi đầy
- Mỗi cycle, nó chọn một eligible warp và dispatch lệnh tiếp theo tới 32 lane
Điều kiện để warp ở trạng thái eligible
- GPU không trích xuất nhiều phụ thuộc động từ một thread đơn như thực thi out-of-order của CPU
- Nó đặt nhiều resident warp; khi xảy ra stall thì chuyển sang warp khác để che giấu latency
- Compiler lập lịch timing có thể dự đoán, còn hardware scoreboard xử lý phần khó dự đoán
- Lệnh SASS 128 bit chứa control-code payload do
ptxasghi- Lệnh có fixed latency chứa static stall count
- Yield hint cho biết có nhường priority scheduler hay không
- Thao tác variable latency dùng 6 physical scoreboard barrier theo từng warp
- Trong đoạn SASS ví dụ, hai
LDG.Eset cùng scoreboard barrierB2FADDcóB2là wait-on- Warp đó ở trạng thái ineligible cho đến khi hai load quay về và barrier được clear
- Trong thời gian đó, scheduler chọn warp khác trong cùng sub-partition
- Đoạn chuyển từ
FADDsangSTG.Eđược xử lý bằng fixed latencyFADDcóstall=5, park warp trong vài cycle cho đến khi kết quảR9sẵn sàng- Không cần barrier riêng
- Control payload này bị ẩn trong output mặc định của
nvdisasm- Nó nằm trong word 64 bit thứ hai của raw 128-bit encoding từ
cuobjdump -sass - Layout không được tài liệu hóa mà được tái dựng bằng microbenchmarking
- Nó nằm trong word 64 bit thứ hai của raw 128-bit encoding từ
Truy cập bộ nhớ và đo hiệu năng
- Khi warp thực thi
LDG.E, 32 thread lần lượt tính địa chỉ của mình- Ví dụ truy cập mảng float liên tiếp, nên toàn bộ warp yêu cầu một khối liên tục
32 * 4 = 128 bytes
- Ví dụ truy cập mảng float liên tiếp, nên toàn bộ warp yêu cầu một khối liên tục
- Load/store unit của SM thực hiện request coalescing
- Gộp 32 yêu cầu 4 byte thành 4 sector request 32 byte
- Nếu truy cập không liên tiếp, có thể phải đọc nhiều dữ liệu hơn mức cần thiết
- Coalesced request trước hết kiểm tra L1 Data Cache cục bộ của SM
- Nếu miss, nó đi qua crossbar interconnect tới slice L2 Cache 72MB
- Nếu vẫn miss ở L2, nó đi qua memory controller và memory bus tới GDDR6X VRAM
- Store
STG.Evề nguyên tắc đi theo cùng đường ngược lại - Các chỉ số Nsight Compute cho thấy kernel này bị memory-bound
launch__grid_size: 4.096launch__block_size: 256launch__registers_per_thread: 16launch__waves_per_multiprocessor: 5.33sm__warps_active.avg.pct_of_peak: 82.77%smsp__issue_active.avg.pct_of_peak: 5.17%dram__throughput.avg.pct_of_peak: 79.65%gpu__time_duration.sum: 10.78μs
- Kernel có mật độ tính toán rất thấp
- Thực hiện 1 phép cộng float cho mỗi tổng cộng 12 byte truyền tải: hai load 4 byte và một store 4 byte
- Xét riêng DRAM read, đọc 8,4MB trong 10.78μs, tương đương khoảng 780GB/s, xấp xỉ 4/5 mức đỉnh
- Output
c4MB nằm vừa trong L2 72MB, nên không bị flush xuống DRAM cho đến khi device-to-host copy đọc nó
Quá trình kết quả quay về CPU
- Kernel launch trả về CPU ngay khi doorbell được rung, nên GPU phải thông báo hoàn tất riêng
- Khi cả 4096 block đều retire, GPU post completion semaphore chứa trong QMD
- Fence field của QMD nằm ở các word 23–24
- Trong default stream,
cudaMemcpy(c, dc, ...)được đặt sau kernel- GPU copy engine bị gated cho đến khi semaphore được bật
- Vì
cvẫn đang dirty trong L2 72MB, lần đọc của copy engine được xử lý từ L2 mà không cần vòng đi-về DRAM - Dữ liệu di chuyển qua PCIe sang host memory
- Khi copy kết thúc, copy engine post semaphore của chính nó
- Việc chờ
cudaMemcpyở host kết thúc ctrở lại là host memory thông thườngprintfđọcc[0]vàc[n-1]từ RAM rồi in ra stdout
- Việc chờ
Cách nhìn vào bên trong launch
- Chỉ đọc open kernel modules thì khó xác nhận trực tiếp một số hành vi vì
libcudalà mã nguồn đóng - Method write không đi qua syscall mà được ghi trực tiếp vào write-combined buffer đã được mapping, nên muốn xem pushbuffer thì phải đọc memory
- Có thể dùng shim
LD_PRELOADđể bọcmmapvà ghi lại các vùng được mapping từ/dev/nvidia*- Nếu test program gọi hàm dump của shim ngay sau launch, nó có thể in pushbuffer đã map
- Dump tìm burst method tương ứng với
SET_INLINE_QMD_ADDRESS_A
- Header method của pushbuffer chứa opcode, payload count, subchannel index và register offset dưới dạng bit field
0x0318làSET_INLINE_QMD_ADDRESS_A0x0320 + i * 4làLOAD_INLINE_QMD_DATA(i)- Trong dump có thể thấy burst increasing-method với count 66; hai address word và 64 QMD word, tổng cộng QMD 256 byte, được đưa inline
- Word 12 trong QMD là
0x1000, word 18 là0x100, tương ứng với 4096 và 256 của launch
- Driver setup được thực hiện bằng
ioctl- Với chương trình một kernel,
straceghi nhận 948 lời gọiioctl - Phần lớn là setup một lần
- Các file descriptor chính là
/dev/nvidiactlvà/dev/nvidia-uvm - Magic byte ioctl của NVIDIA resource manager là
0x46, tức'F' - Command number
0x2Ađược diễn giải làNV_ESC_RM_CONTROL, còn0x2BlàNV_ESC_RM_ALLOC
- Với chương trình một kernel,
- Trong
vadd.cudafe1.stub.cdonvcc --keeptạo ra cũng có thể xem mã đăng ký lúc khởi động- Hàm có gắn
__attribute__((__constructor__))chạy trướcmain __cudaRegisterBinaryvà__cudaRegisterEntryliên kết host function pointervaddvới device entry point_Z4vaddPKfS0_Pfi
- Hàm có gắn
1 bình luận
Các ý kiến trên Hacker News
Một bài viết thú vị, và phần giải thích về semaphore của stream mặc định cũng rất hay
Thích ở chỗ CUDA ngầm xử lý việc đồng bộ hóa lệnh, còn các lệnh song song thì cho phép dùng có chọn lọc thông qua stream
Điều này tương phản với Vulkan, vốn ngay từ đầu đã đẩy toàn bộ độ phức tạp của đồng bộ hóa cho người dùng
Về phía phần cứng thì có một số tài liệu công khai
Không nhất thiết phải đọc mã nguồn kernel để tìm tài liệu về method hay định dạng QMD
Xem https://github.com/NVIDIA/open-gpu-doc/blob/master/classes/c...
Rất hữu ích
Đặc biệt phần về doorbell và QMD là hữu ích nhất, vì cho thấy cú pháp chạy CUDA thực sự được nối với nội dung gửi xuống GPU như thế nào
Phần lớn các bài giải thích dừng lại quanh kernel, block, warp; còn bài này giúp lần theo đường đi CPU→driver→GPU dễ hơn nhiều
Mã điều khiển phức tạp hơn một chút so với mô tả trong bài
Thực tế nó giống tra bảng hơn là một bit trong control word
Hiện nay có những công ty lấy việc tối ưu kernel để chạy nhanh hơn làm nghiệp vụ chính
Tò mò liệu một ngày nào đó các công ty đó có bị một thư viện mã nguồn mở làm việc này cực tốt vượt qua hay không
Với Nvidia thì có vẻ họ có thể tung ra thứ như vậy bất cứ lúc nào
Hoặc cũng có thể mọi chuyện sẽ tốt hơn khi các nhà cung cấp lớn thâu tóm các công ty này để biến tốc độ suy luận thành
moatTuy nhiên, nhìn vào việc các mô hình tiến bộ trên những benchmark liên quan như kernelbench, tôi nghĩ rốt cuộc các giải pháp tổng quát hơn cũng sẽ xuất hiện
Vấn đề là mỗi thế hệ phần cứng mới thường lại có các ràng buộc hoặc tính năng mà mô hình cũ chưa từng thấy
Ví dụ tcgen05 của Blackwell từng là một trường hợp ngoài phân phối
Nếu các mô hình bắt đầu khái quát hóa tốt hơn thì đây có thể không phải rào cản chí mạng, nhưng ít nhất hiện tại nó vẫn là trở ngại
[1] https://kernelbench.com/
Tôi hiếm thấy ai mong muốn phụ thuộc nhiều hơn vào thư viện của Nvidia
Vì các chi tiết của workload — tức các tham số chính xác, cách biểu diễn dữ liệu trong bộ nhớ, phạm vi giá trị, v.v. — làm chiến lược tối ưu hóa khác nhau rất nhiều
Tôi vừa hoàn thành thạc sĩ HPC và đã học các môn CUDA, MPI+CUDA, OpenCL; nếu đọc được bài này trước khi vào học thì chắc đã giúp ích hơn nhiều
Đặc biệt thích phần trước sau đoạn nói về việc một warp có thể thực thi nghĩa là gì
Trước hết, đây là một bài viết hay, đào khá sâu vào nhiều ngóc ngách
Tuy nhiên, nếu không đi qua
runtime APIcủa CUDA thì rất nhiều phần mang màu sắc voodoo trong không gian người dùng sẽ biến mấtDùng driver API, nhận mã nguồn kernel dưới dạng chuỗi rồi biên dịch bằng trình biên dịch runtime của NVIDIA, bạn sẽ thấy rõ hơn chuyện gì đang diễn ra
Không phải toàn bộ, nhưng khá nhiều phần trở nên minh bạch
Phiên bản “thô sơ” hơn nằm ở đây:
https://github.com/NVIDIA/cuda-samples/tree/master/cpp/0_Int...
Nếu muốn xem cùng nội dung đó ở dạng API C++ hiện đại, dễ đọc hơn nhiều nhưng vẫn hoàn toàn minh bạch, hãy xem cái này:
https://github.com/eyalroz/cuda-api-wrappers/blob/master/exa...
Đây là chương trình ví dụ trong thư viện chỉ gồm header CUDA API wrappers của tôi
Có thể phát triển bằng cách thay đổi mã trong lúc đang chạy, khá thú vị
Trên bare metal à?