Bài trước kết thúc bằng một loạt câu hỏi: ai giữ bảng trang, ai xử lý page fault, ai quyết định chương trình nào được chạy trên lõi nào? Câu trả lời là hệ điều hành, hay chính xác hơn là phần lõi của nó, kernel. Bài này đi qua những việc kernel làm, rồi tới một thiết bị mà ngày nay quan trọng không kém CPU: GPU.
Lộ trình: kernel và user space, cái giá của một system call, process, thread và lập lịch, thiết bị và driver, rồi CPU và GPU: vì sao chúng được thiết kế khác nhau đến vậy, đo bằng cùng một phép nhân ma trận, và khi nào GPU không nhanh hơn.
Mọi con số trong bài đều đo trên máy thật, bằng C (clang), Python 3.12 và PyTorch 2.14. GPU ở đây là GPU tích hợp trong chip của một laptop, dùng qua backend MPS của PyTorch; GPU rời trong máy chủ AI mạnh hơn rất nhiều, nhưng các hiện tượng là như nhau. Mỗi phép đo chỉ chạy vài mili giây tới vài giây.
Kernel và user space
CPU có (ít nhất) hai chế độ chạy. Ở chế độ kernel, code được làm mọi thứ: sửa bảng trang, nói chuyện với phần cứng, dừng bất kỳ chương trình nào. Ở chế độ người dùng (user space), nơi mọi chương trình bình thường chạy, những lệnh đó bị cấm: CPU từ chối thực hiện chúng và báo cho kernel. Một chương trình không thể tự mở file, gửi gói tin mạng hay xin thêm bộ nhớ; nó phải nhờ kernel làm, qua một system call.
System call là một lệnh máy đặc biệt (trên arm64 là svc, trên x86-64 là syscall): chương trình đặt số hiệu của việc cần làm và các tham số vào thanh ghi, chạy lệnh đó, và CPU nhảy vào kernel ở một địa chỉ cố định. Kernel kiểm tra yêu cầu, làm việc, rồi quay về chương trình. Cái giá của chuyến đi đó:
for (int i = 0; i < n; i++) x = plain_function(x); // an ordinary function call
for (int i = 0; i < n; i++) write(fd, &c, 1); // a real trip into the kernel and back (fd = /dev/null)
| Mỗi lần | |
|---|---|
| Gọi một hàm bình thường | 0,4 ns |
System call write() 1 byte vào /dev/null |
345 ns |
os.write() từ Python |
372 ns |
os.getpid() từ Python |
11 ns |
Ghi một byte vào /dev/null, một "file" vứt bỏ mọi thứ ghi vào nó, tức là gần như không có việc gì để làm, vẫn đắt gần 900 lần một lời gọi hàm. Phần lớn chi phí là chính chuyến đi: đổi chế độ CPU, lưu và khôi phục thanh ghi, kiểm tra tham số, và các biện pháp chống những lỗ hổng kiểu Spectre ở bài 2 (dọn dẹp trạng thái của CPU khi đi qua ranh giới kernel). os.getpid() thì rẻ bất thường: 11 ns quá nhanh cho một chuyến vào kernel, cho thấy thư viện C trả lời từ một bản đã lưu sẵn.
Hệ quả thực tế quen thuộc nhất: gom lại rồi mới gọi. File trong Python mặc định có bộ đệm: f.write("a") một nghìn lần không gây ra một nghìn system call, mà gom lại thành một vài lần ghi lớn. Đó cũng là ý tưởng đã gặp nhiều lần trong các seri trước: gom dòng thành mẩu khi stream (seri Python), gom token của nhiều người thành một batch (seri LLM). Khi mỗi lần qua một ranh giới có giá cố định, hãy qua ít lần hơn.
Process, thread và lập lịch
Một process là một chương trình đang chạy cùng mọi thứ của nó: một không gian địa chỉ riêng (bài trước), các file đang mở, quyền hạn. Một thread là một luồng thực thi bên trong process: có thanh ghi và stack riêng, nhưng dùng chung không gian địa chỉ với các thread khác của cùng process. Đo cái giá để tạo ra rồi dọn đi mỗi thứ:
| Mỗi lần | |
|---|---|
| Tạo một thread rồi chờ nó kết thúc | 13,6 µs |
fork một process con, cho nó thoát, rồi chờ |
267 µs |
Process đắt gấp khoảng 20 lần: kernel phải dựng một không gian địa chỉ mới (dù nhờ copy-on-write ở bài trước, nó không phải chép dữ liệu), bảng trang mới, rồi dọn tất cả khi process thoát. Đó là lý do seri Python khuyên tạo process một lần (một nhóm worker) rồi dùng lại, thay vì tạo mới cho mỗi việc.
Máy có vài lõi CPU, nhưng lúc nào cũng có hàng trăm thread đang tồn tại. Người quyết định thread nào được chạy trên lõi nào, trong bao lâu, là bộ lập lịch (scheduler) của kernel. Mỗi thread được chạy một lát thời gian (vài mili giây), rồi bị tạm dừng để thread khác chạy; một thread đang chờ (đọc file, chờ mạng, chờ khóa) thì không được xếp lịch cho tới khi thứ nó chờ xong. Việc đổi từ thread này sang thread khác gọi là chuyển ngữ cảnh (context switch): lưu thanh ghi của thread cũ, nạp thanh ghi của thread mới, và nếu là process khác thì đổi luôn bảng trang.
Đo bằng hai process chuyền qua lại một byte qua hai pipe: process A ghi, process B đang ngủ chờ đọc thì được đánh thức, ghi lại, process A được đánh thức:
for (int i = 0; i < trips; i++) { write(to_child[1], &c, 1); read(to_parent[0], &c, 1); }
Mỗi vòng đi rồi về mất 3,6 µs: bốn system call, cộng hai lần đánh thức một process đang ngủ. Bằng khoảng 16.000 chu kỳ CPU cho việc chuyền một byte. Mỗi lần giao tiếp giữa hai process (hay giữa hai thread phải chờ nhau) đều trả giá như vậy, và đó là lý do async I/O với một thread duy nhất (seri Python) xử lý được hàng nghìn kết nối hiệu quả hơn hàng nghìn thread.
Thiết bị và driver
Ổ đĩa, card mạng, bàn phím, GPU: mỗi thiết bị nói một "ngôn ngữ" riêng, gồm các thanh ghi điều khiển, định dạng lệnh và cách báo hiệu riêng. Phần code của kernel biết nói ngôn ngữ đó là driver. Chương trình không nói chuyện thẳng với thiết bị; nó gọi kernel (hoặc một thư viện), và driver lo phần còn lại.
Hai cơ chế giúp thiết bị không làm phiền CPU quá nhiều:
- DMA (direct memory access): thiết bị tự đọc hay ghi thẳng vào RAM, ví dụ card mạng chép cả gói tin vào bộ nhớ mà CPU không phải chép từng byte.
- Ngắt (interrupt): khi xong việc, thiết bị gửi một tín hiệu làm CPU tạm dừng việc đang làm và chạy một đoạn code của kernel để xử lý. Đó là cách một thread đang chờ mạng được đánh thức đúng lúc dữ liệu tới.
GPU cũng là một thiết bị như vậy. Khi PyTorch chạy một phép tính trên GPU, nó không chạy phép tính đó: nó ghi một lệnh vào một hàng đợi lệnh (command queue), qua thư viện đồ họa của hệ điều hành (Metal trên máy này, CUDA với GPU NVIDIA) và driver, rồi trả về ngay. GPU lấy lệnh ra và chạy sau đó, song song với CPU. CPU chỉ chờ khi chương trình thật sự cần kết quả (torch.mps.synchronize(), hay khi chép kết quả về). Mọi phép đo GPU dưới đây đều phải gọi lệnh chờ đó, nếu không thì chỉ đo được thời gian xếp hàng lệnh, không phải thời gian chạy.
CPU và GPU: hai kiểu thiết kế
Bài 2 cho thấy một lõi CPU là một cỗ máy phức tạp, được thiết kế để làm một chuỗi lệnh nhanh nhất có thể: đoán nhánh, nhìn trước hàng trăm lệnh để chạy không theo thứ tự, và nhiều tầng cache lớn để giấu độ trễ của bộ nhớ. Phần lớn diện tích của một lõi CPU dành cho những việc quản lý đó, không phải cho phép tính.
GPU đi theo hướng ngược lại: bỏ gần hết sự thông minh đó, dùng diện tích cho thật nhiều bộ tính toán đơn giản, và đặt cược rằng công việc có hàng triệu phần giống nhau, độc lập với nhau.
| CPU | GPU | |
|---|---|---|
| Số lõi | Vài tới vài chục, mỗi lõi rất mạnh | Hàng nghìn bộ tính đơn giản |
| Một lệnh làm việc trên | Một (hoặc vài, với SIMD) giá trị | Một nhóm luồng cùng lúc (32 luồng trên GPU NVIDIA) |
| Giấu độ trễ bộ nhớ bằng | Cache lớn, đọc trước, chạy không theo thứ tự | Đổi sang nhóm luồng khác trong lúc chờ |
Rẽ nhánh if |
Đoán nhánh, rất giỏi | Trong một nhóm, nếu các luồng rẽ khác nhau thì chạy cả hai nhánh lần lượt |
| Bộ nhớ | Độ trễ thấp | Băng thông rất cao |
| Giỏi nhất ở | Logic phức tạp, nhiều nhánh, một việc cần xong nhanh | Cùng một phép tính trên hàng triệu phần tử |
Dòng "rẽ nhánh" là điểm khác biệt quan trọng nhất. Trên GPU, một nhóm luồng chạy cùng một lệnh tại cùng một thời điểm. Nếu một nửa nhóm cần làm nhánh if còn nửa kia làm nhánh else, GPU chạy nhánh if (nửa kia ngồi chờ), rồi chạy nhánh else (nửa đầu ngồi chờ). Code nhiều nhánh khó đoán, thứ CPU xử lý bằng đoán nhánh, trên GPU có thể tốn gấp đôi hoặc hơn.
Đo: cùng một phép nhân ma trận
Nhân hai ma trận vuông số thực 32-bit, trên CPU và trên GPU, ở bốn kích thước (lấy lần nhanh nhất trong 5 lần, có chờ GPU xong):
import time, torch
def timed(f, sync, n=5):
f(); sync()
best = 1e9
for _ in range(n):
t = time.perf_counter(); f(); sync(); best = min(best, time.perf_counter() - t)
return best
a, b = torch.randn(size, size), torch.randn(size, size)
ga, gb = a.to("mps"), b.to("mps")
cpu = timed(lambda: a @ b, lambda: None)
gpu = timed(lambda: ga @ gb, torch.mps.synchronize)
| Kích thước | CPU | GPU | Nhanh hơn |
|---|---|---|---|
| 16 × 16 | 0,001 ms | 0,527 ms | CPU, ~500 lần |
| 256 × 256 | 0,023 ms | 0,420 ms | CPU, ~18 lần |
| 1.024 × 1.024 | 1,06 ms (2,0 TFLOP/s) | 2,70 ms (0,8 TFLOP/s) | CPU, 2,6 lần |
| 4.096 × 4.096 | 67,5 ms (2,0 TFLOP/s) | 38,8 ms (3,5 TFLOP/s) | GPU, 1,7 lần |
Hình dạng của bảng quan trọng hơn từng con số:
- Với việc nhỏ, GPU thua xa. Một phép nhân 16 × 16 chỉ có khoảng 8.000 phép tính, nhưng trên GPU mất nửa mili giây: gần như toàn bộ là chi phí gửi lệnh qua driver, chờ GPU nhận và chạy, rồi báo xong. Đo riêng một phép cộng trên một con số: 102 µs trên GPU, so với 0,7 µs trên CPU.
- GPU chỉ thắng khi có đủ việc. Ở 4.096 × 4.096 (137 tỉ phép tính), chi phí gửi lệnh trở nên không đáng kể, và hàng nghìn bộ tính của GPU mới có dịp chạy hết sức.
- CPU này mạnh bất thường ở phép nhân ma trận. 2 TFLOP/s số thực 32-bit cao hơn nhiều so với những gì vài lõi CPU thông thường làm được bằng lệnh SIMD; thư viện tính toán có lẽ đang dùng các khối tăng tốc nhân ma trận có sẵn trên chip. Trên máy chủ AI, khoảng cách ngược lại rất lớn: một GPU trung tâm dữ liệu nhanh hơn GPU tích hợp ở đây hàng chục lần, và còn nhanh hơn nữa với số 16-bit hay 8-bit (bài 5 của seri LLM) nhờ các khối tính chuyên cho ma trận.
Hai phép đo nữa:
| Thời gian | |
|---|---|
Chép 64 MiB từ RAM sang GPU (x.to("mps")) |
1,63 ms (38 GiB/s) |
| Chép 64 MiB từ GPU về | 1,93 ms |
torch.where(z > 0, z * 2, -z) trên 16 triệu số: CPU / GPU |
5,2 ms / 4,6 ms |
- Dữ liệu phải được chuyển. Trên máy này, CPU và GPU dùng chung một bộ nhớ vật lý, vậy mà chép 64 MiB vẫn mất hơn 1,6 ms. Với GPU rời, dữ liệu phải đi qua bus PCIe sang bộ nhớ riêng của GPU, thường chậm hơn nữa. Chương trình chạy trên GPU tốt là chương trình đưa dữ liệu lên một lần và làm thật nhiều việc ở đó, không chép qua lại sau mỗi bước.
- Phép "có nhánh" được viết theo kiểu GPU.
torch.wherekhông rẽ nhánh từng phần tử: nó tính cảz * 2lẫn-zcho mọi phần tử, rồi chọn kết quả theo điều kiện. Đó chính là cách GPU xử lýif: làm cả hai nhánh, giữ lại một. Ở đây cả CPU lẫn GPU đều mất khoảng 5 ms, vì thứ giới hạn là đọc và ghi 64 MiB dữ liệu, không phải phép tính; đúng như bức tường bộ nhớ ở bài 2.
Vì sao AI chạy trên GPU
Ghép lại với seri LLM: bài 2 cho thấy gần như toàn bộ một mô hình ngôn ngữ là phép nhân ma trận, bài 3 cho thấy sinh chữ bị giới hạn bởi băng thông bộ nhớ, và bài 5 cho thấy server gom nhiều người dùng vào một batch để mỗi lần đọc trọng số làm được nhiều việc hơn. Cả ba đều là sở trường của GPU: phép tính giống nhau trên khối dữ liệu khổng lồ, bộ nhớ băng thông cao, và càng nhiều việc trong một lần gửi lệnh càng tốt.
Còn những việc không nên đưa lên GPU: phép tính nhỏ (chi phí gửi lệnh lớn hơn phép tính), logic nhiều nhánh khó đoán, việc cần độ trễ thấp cho từng yêu cầu nhỏ, và bất cứ thứ gì phải chép dữ liệu qua lại liên tục. Một chương trình thật thường dùng cả hai: CPU lo điều phối, đọc dữ liệu, quyết định; GPU lo phần tính toán khối lớn.
Tóm lại
| Khái niệm | Thực chất |
|---|---|
| Kernel | Phần hệ điều hành chạy ở chế độ đặc quyền; chương trình phải nhờ nó qua system call |
| System call | 345 ns cho một lần write, gần 900 lần một lời gọi hàm; nên gom lại |
| Process và thread | Process có không gian địa chỉ riêng; tạo process đắt gấp ~20 lần thread |
| Lập lịch | Chia lát thời gian cho các thread; một lần chuyền qua lại giữa hai process ~3,6 µs |
| Driver, DMA, ngắt | Kernel nói chuyện với thiết bị; thiết bị tự chép vào RAM và báo khi xong |
| GPU là một thiết bị | Lệnh được xếp vào hàng đợi và chạy sau; phải chờ mới đo đúng |
| CPU và GPU | Vài lõi thông minh và cache lớn, so với hàng nghìn bộ tính đơn giản và băng thông cao |
| Rẽ nhánh trên GPU | Nhóm luồng rẽ khác nhau thì chạy cả hai nhánh |
| Khi nào GPU thắng | Việc lớn, giống nhau, dữ liệu đã ở trên GPU; việc nhỏ thì thua vì chi phí gửi lệnh |
Bốn bài đầu ở bên trong một chiếc máy. Bài cuối đi ra ngoài: chuyện gì xảy ra trong khoảng nửa giây giữa lúc bạn gõ viet.sarl và lúc trang hiện ra, qua từng lớp của mạng máy tính.