Nghiên cứu
Giải mã kiến trúc bộ nhớ GPU: Kỹ thuật đảo ngược đường dẫn tải dữ liệu trên RTX 4090
(giờ Việt Nam)
Tóm tắt AI
Bài viết thực hiện kỹ thuật đảo ngược đường dẫn phần cứng trên RTX 4090, làm sáng tỏ cơ chế phân mảnh cache L1/L2 và hàm băm địa chỉ phức tạp. Tác giả cung cấp mã nguồn dự đoán phân mảnh cho các dòng GPU cao cấp như 4090 và L40S.
Bản dịch AI

Bài viết trước của chúng tôi đã theo sát một kernel cộng vector — c[i] = a[i] + b[i], mỗi luồng (thread) xử lý một số thực (float) — từ nvcc cho đến các warp. Chúng tôi đã đi sâu vào chi tiết cách kernel được khởi chạy, nhưng cũng bỏ sót khá nhiều điều.
Lần này, chúng tôi sẽ giải quyết những thiếu sót đó và theo dõi lộ trình mà một lệnh SASS quan trọng (lệnh load toàn cục) đi qua phần cứng — trong trường hợp này là một chiếc RTX 4090 đang đặt dưới bàn làm việc của tôi. Chúng tôi thực hiện kiểu kỹ thuật đảo ngược (reverse engineering) này vì lý do hiệu năng, ít nhất là về mặt nguyên tắc (để có lý do thuyết phục, hãy xem phần 'Why these details matter' trong bài báo về microbenchmarking của Citadel). Đối với công việc tương tự áp dụng cho các GPU phù hợp với sản xuất hơn, hãy tiếp tục theo dõi. NVIDIA không ghi chép nhiều về chi tiết lộ trình này, ít nhất là không ở mức độ chúng tôi mong muốn, vì vậy chúng tôi sẽ tự xác định nó bằng cách thực hiện các thí nghiệm đo thời gian trên chính phần cứng.
Kernel CUDA mà chúng tôi đang nghiên cứu có hai dòng trong phần thân hàm:
Nếu kiểm tra mã SASS đã biên dịch, bạn sẽ thấy các lệnh vận hành những dòng đó:
Chúng có nhiệm vụ load các phần tử của vector b (các lệnh cho a cũng tương tự, chúng tôi đang theo dõi b) từ bộ nhớ toàn cục vào một thanh ghi (register), nơi chúng có thể được cộng với các phần tử của a để thực thi kernel. Một lệnh LDG.E yêu cầu bốn byte trong mỗi 32 lane. Việc phục vụ lệnh này cần bốn sector 32-byte, một dòng cache (cache line), một lần dịch địa chỉ, một lần đi qua crossbar, một trong ba mươi sáu slice L2, và khi không tìm thấy ở bất cứ đâu, nó sẽ kích hoạt (activate) và thực hiện bốn lần đọc cột tại một chip DRAM. Chính hành trình của lệnh này đi qua phần cứng và quay trở lại là điều chúng tôi sẽ cố gắng theo dõi.
Để bắt đầu: warp của chúng ta nằm trên một trong bốn phân vùng phụ (sub-partition) của SM, cùng với mười một warp thường trú khác. Mỗi chu kỳ, bộ lập lịch của phân vùng phụ chọn một warp đủ điều kiện và phát lệnh tiếp theo của nó trên 32 lane cùng một lúc. Warp của chúng ta thắng hai lần: một lần cho lệnh IMAD.WIDE, và vài chu kỳ sau đó (khi các địa chỉ đã nằm trong R4 và R5) cho lệnh LDG.
Câu chuyện của chúng ta bắt đầu với LDG.
Từ warp đến bộ nhớ đệm L1
Hãy bắt đầu với lệnh này. LDG.E R4, [R4.64] là một lệnh load toàn cục 32 bit từ địa chỉ 64 bit được lưu trong các thanh ghi R4 và R5 (R5 xuất hiện do chú thích.64: các thanh ghi có kích thước 32 bit), lưu kết quả vào thanh ghi R4. Để load dữ liệu, trước tiên chúng ta phải lấy địa chỉ đó từ các thanh ghi.
Một hàng của tệp thanh ghi (register file) chứa R4 cho tất cả 32 lane cùng một lúc (việc đọc được dàn dựng trong bộ thu toán hạng trước). Việc dàn dựng này dành cho các lệnh có nguồn chia sẻ chung một bank của tệp thanh ghi, vì mỗi bank chỉ phục vụ một lần đọc mỗi chu kỳ. Có hai bank, được chọn bởi bit thấp của số thanh ghi, vì vậy một cặp liền kề luôn bao gồm cả hai bank. Một hàng khác chứa R5. Warp đọc cả hai mục, tạo ra 256 byte được đọc dưới dạng 32 địa chỉ 64-bit riêng biệt, mỗi địa chỉ cho một lane.
Việc đọc địa chỉ làm tăng thêm tối đa một chu kỳ. Một lệnh load bộ nhớ chia sẻ (shared-memory load) lấy địa chỉ từ thanh ghi mất 24 chu kỳ từ khi phát lệnh đến khi sử dụng lần đầu, và cùng lệnh đó với địa chỉ là giá trị tức thời (immediate) mất 23 chu kỳ. (LDG không thể nhận giá trị tức thời).
Với tất cả các địa chỉ đã được giải quyết, lệnh được phát đến đơn vị load/store (LSU). LSU nhận lệnh và các địa chỉ toán hạng của nó, thực hiện một số phép tính địa chỉ (nếu cần) (đơn vị này có thể cộng các độ lệch tức thời, [R4.64] không mang độ lệch nào để cộng, và phạm vi load), rồi gửi opcode ('load các địa chỉ này', ở dạng nhị phân), một mặt nạ 32-bit của các lane đang hoạt động, các địa chỉ đã tính toán và số hiệu thanh ghi chứa kết quả. Điểm đến tiếp theo là bộ gom (coalescer).
Mỗi lệnh LDG.E trong mỗi lane yêu cầu 4 byte, nhưng điểm đến tiếp theo của chúng ta, bộ nhớ đệm L1, được đánh địa chỉ theo các sector 32 byte. Công việc của bộ gom là tìm ra số lượng sector L1 tối thiểu cần truy xuất để phục vụ các yêu cầu 4-byte của chúng ta.
Bộ gom tính toán rằng nó nên phát ra 4 yêu cầu sector liên tiếp, cho 128 byte mà warp đã yêu cầu.
Đi vào bộ nhớ đệm L1
Yêu cầu cho bốn sector 32-byte liên tiếp được gửi đến bộ nhớ đệm L1.
Đơn vị tổ chức của bộ nhớ đệm L1 thậm chí còn ít chi tiết hơn: các dòng 128 byte. 4 sector liên tiếp của chúng ta đại diện cho 4 phần của một dòng duy nhất, vì vậy một yêu cầu được gửi đến L1 cho dòng cache đó.
Đầu tiên, chúng ta phải xác định xem dòng đó đã có trong cache hay chưa. Cache được chia thành các nhóm slot gọi là các tập (set) (về mặt kỹ thuật, L1 cache trên 4090 là 4-way set-associative). Các cache nằm trên một dải liên tục giữa fully associative (bất kỳ dòng cache nào cũng có thể được lưu trữ ở bất cứ đâu) và 'direct-mapped' (mỗi dòng cache chỉ có thể được lưu trữ ở một nơi), và địa chỉ của một dòng xác định tập mà nó thuộc về. Một tập trên card này chứa bốn slot, và mỗi slot mang một thẻ (tag) xác định dòng trong đó. Việc tra cứu so sánh cả bốn thẻ với thẻ của dòng mà nó muốn. Địa chỉ nó sử dụng là địa chỉ ảo được sử dụng trong chương trình. Tập mà một dòng rơi vào được tạo ra từ địa chỉ ảo của dòng đó bằng một lược đồ băm (hashing scheme), lược đồ này khá phức tạp (xem phụ lục), không chỉ là một phần của các bit, để các truy cập cách quãng lũy thừa của 2 (như các cột của ma trận, tensor, v.v.) không liên tục đánh vào cùng một tập và gây nhiễu.
Nếu một trong bốn thẻ khớp và các sector chúng ta muốn nằm trong slot đó, dữ liệu sẽ được đọc ra và lệnh load hoàn tất. Vì chúng ta đang load tất cả dữ liệu của mình lần đầu tiên, yêu cầu của chúng ta bị miss (trượt), và phải đi sâu hơn vào hệ thống bộ nhớ.
Một lần hit L1 trả về trong khoảng 15,4 ns — 40 chu kỳ. Con số này đến từ một luồng đuổi theo một chuỗi phụ thuộc thông qua một hoán vị ngẫu nhiên của các dòng thường trú trong L1, với độ trễ đuổi theo.
Tìm kiếm L2: dịch địa chỉ
Bộ nhớ ảo đặt một cấp độ gián tiếp giữa các địa chỉ mà chương trình đặt tên và các địa chỉ mà phần cứng lưu trữ dữ liệu. Chương trình có một không gian liên tục của riêng nó, và phần cứng sắp xếp không gian đó trên các trang vật lý theo bất kỳ cách nào nó muốn. Dịch địa chỉ là bản đồ giữa chúng.
L1 mà chúng ta vừa nói đến được đánh địa chỉ ảo, vì vậy chúng ta không cần bận tâm đến việc dịch địa chỉ. Sau điểm này, chúng ta phải bắt đầu nói ngôn ngữ của phần cứng — một lần miss L1 phải được dịch trước khi nó rời khỏi SM.
Việc ánh xạ thực tế giữa địa chỉ vật lý và địa chỉ ảo được thiết lập tại thời điểm cấp phát trong driver: khi b được cấp phát, driver đã chọn các trang vật lý (2MiB) cho nó và ghi các bảng trang vào VRAM để ghi lại sự phân bổ đó.
Đơn vị dịch địa chỉ nhận vào một địa chỉ ảo và trả về một địa chỉ vật lý, theo các bảng đó. SM giữ mười sáu bản dịch gần đây nhất của nó trong một TLB, được chia sẻ giữa các warp. Lần load đầu tiên sẽ miss trong TLB này.
Chúng ta không thấy bất kỳ chi phí nào cho việc hit TLB trong bất kỳ thăm dò nào của mình. Các lần miss tốn khoảng 4,4 ns — mười một chu kỳ. Chi phí nạp lại tương tự giữ nguyên trong vòng 0,1 ns trên tất cả các trang mà chip này có thể ánh xạ, và từ bất kỳ SM nào, vì vậy cấp độ tiếp theo của bộ nhớ đệm dịch địa chỉ là phổ quát và rất rẻ.
Sau khi việc dịch địa chỉ được thực hiện, thứ rời đi là một yêu cầu cho mỗi dòng 128-byte: bây giờ với địa chỉ vật lý của dòng, cùng với mặt nạ của các sector chúng ta muốn từ nó. Của chúng ta là một yêu cầu duy nhất với tất cả bốn sector được đánh dấu.
Yêu cầu đi ra khỏi SM, qua crossbar đến bộ nhớ đệm L2.
Lạc trong L2
Yêu cầu chạy qua crossbar đến một trong 36 slice L2 2 MiB, được chọn bởi một hàm khá phức tạp của địa chỉ vật lý của nó. Bất kỳ SM nào cũng có thể hit bất kỳ slice nào. Tất cả các slice có thể phục vụ song song, vì vậy băng thông tổng hợp gấp 36 lần so với một slice đơn lẻ.
Bên trong một slice, cấu trúc cũng tương tự như L1. Mỗi slice chứa 1024 tập. Tập mà một dòng thuộc về được chọn bởi một hàm băm của địa chỉ vật lý của dòng đó. Mỗi tập bây giờ chứa 16 slot: các slice là 16-way set-associative. Các dòng có kích thước 128 byte, giống như trong L1.
Dòng không có trong L2, vì chúng ta chưa lấy nó trước đó. Mỗi slice rơi xuống một trong 12 bộ điều khiển bộ nhớ — 3 slice mỗi bộ điều khiển. Công việc của mỗi bộ điều khiển bộ nhớ là giao tiếp với một chip GDDR6X DRAM duy nhất. Yêu cầu của chúng ta được chuyển cho bộ điều khiển đó.
Một lần hit L2 tốn khoảng 127 ns — khoảng 330 chu kỳ. Mỗi SM có thể chuyển cho crossbar tối đa hai yêu cầu dòng mỗi chu kỳ, và 36 slice phục vụ độc lập. Bộ đếm cổng thoát là l1tex__m_l1tex2xbar_req_cycles_active.
Tìm thấy trong DRAM
Công việc của bộ điều khiển bộ nhớ là load dữ liệu từ chip DRAM 2 GiB của nó. Nó thực hiện điều đó bằng cách phát các lệnh đến DRAM qua một bus.
DRAM được chia thành hai bus riêng biệt mà bộ điều khiển điều khiển độc lập, gọi là các kênh (channel). Trên mỗi kênh có 16 bank: các mảng hai chiều của các ô nhớ. Một bank bao gồm 65.536 hàng. Phần cứng có thể mở một hàng tại một thời điểm (một lần kích hoạt - activate, tốn kém), và sau đó trả về bất kỳ cột 32-byte nào từ hàng đó (một lần đọc - read, rẻ khi hàng đang mở).
GDDR6X
Địa chỉ được phân tách một lần cuối cùng để khớp với cấu trúc bộ nhớ này. Nó chọn ra một kênh, một bank, một hàng và một cột. Bốn sector của chúng ta là bốn cột của một hàng.
Vì vậy, để phục vụ lệnh load của chúng ta, bộ điều khiển bộ nhớ trước tiên phải gửi một lệnh kích hoạt (activate), và sau đó là bốn lệnh đọc (read).

Bài viết được AI dịch và tổng hợp tự động từ Hacker News Nổi bật (buzzing.cc bản dịch tiếng Trung). Liên kết bài gốc ở phía trên. Dữ liệu đồng bộ qua API công khai được ghi nguồn tại AI HOT (canonical) ↗. AIHOT.vn luôn dẫn nguồn đầy đủ — nếu bạn thấy điểm cần chỉnh sửa, hãy gửi ý kiến tại trang phản hồi.