Hình dung một người thợ có bàn làm việc riêng, trên đó đồ nghề đã bày sẵn đúng tầm tay theo thói quen. Nếu bạn bắt anh ta đổi sang bàn khác giữa chừng, anh ta phải bày lại toàn bộ đồ nghề từ đầu — mất thời gian, dù công việc chẳng đổi. Nhưng có một trường hợp việc đổi bàn chẳng tốn gì: khi công việc lớn tới mức đồ nghề không vừa một bàn nào, anh ta phải chạy ra kho lấy liên tục — lúc đó ngồi bàn nào cũng như nhau, vì cái tốn thời gian là những chuyến ra kho, không phải cái bàn. Bộ nhớ đệm riêng của mỗi nhân CPU đúng là cái bàn bày sẵn đồ nghề đó, và bài này đo xem đổi nhân đắt tới mức nào — cùng lý do vì sao câu trả lời phụ thuộc hoàn toàn vào cỡ công việc.
Phần 5 đo được chuyển ngữ cảnh qua nhân đắt gấp 19 lần. Bài này đo mặt còn lại: chi phí của việc chính tiến trình bị chuyển sang nhân khác.
Cùng phép đo, đổi mỗi kích thước tập làm việc
Một tiến trình duyệt ngẫu nhiên qua một vùng nhớ, hoặc ở yên một nhân, hoặc đổi nhân sau mỗi vòng.
| Tập làm việc | Ở yên một nhân | Nhảy mỗi vòng | Chênh |
|---|---|---|---|
| 1 MB | 41.739 MB/s | 20.445 MB/s | 2,04× |
| 4 MB | 45.272 MB/s | 32.905 MB/s | 1,38× |
| 16 MB | 34.537 MB/s | 28.346 MB/s | 1,22× |
| 64 MB | 11.607 MB/s | 11.586 MB/s | 1,00× |
| 256 MB | 8.220 MB/s | 8.054 MB/s | 1,02× |
Đường cong rất rõ: chậm gấp đôi khi dữ liệu vừa bộ nhớ đệm, không ảnh hưởng gì khi dữ liệu vượt bộ nhớ đệm.
Vì sao
Bộ nhớ đệm L1 và L2 thuộc về từng nhân. Nhảy sang nhân khác là bỏ lại toàn bộ bộ nhớ đệm đã nóng và bắt đầu lại từ đầu — đổi bàn, bày lại đồ nghề.
Với tập 1 MB, dữ liệu vừa gọn trong L2 và mọi lần đọc đều trúng bộ đệm — cho tới khi tiến trình đổi nhân. Lúc đó nó phải nạp lại toàn bộ. Với tập 256 MB, không có bộ đệm nào chứa nổi. Mọi lần đọc đều phải ra RAM dù ở nhân nào, nên việc ở đâu không còn ý nghĩa — công việc quá lớn thì chuyến nào cũng ra kho.
Nghĩa là ghim CPU chỉ đáng làm khi
Hai điều kiện, cần cả hai:
- Tập dữ liệu nóng vừa trong bộ nhớ đệm của một nhân.
- Tiến trình chạy liên tục đủ lâu để bộ đệm kịp nóng.
Với ứng dụng duyệt qua hàng trăm megabyte mỗi giây — phần lớn dịch vụ web, cơ sở dữ liệu quét bảng — ghim CPU không cho gì. Và nó có cái giá: ghim làm mất khả năng cân bằng tải của nhân. Nhân bị ghim quá tải trong khi nhân khác rảnh, và bộ lập lịch không sửa được. Đo trước khi ghim. Nếu chênh lệch dưới 10%, đừng làm.
Lý do thứ hai để ghim, không liên quan tới bộ nhớ đệm
Nhắc lại phần 5: hai tiến trình trao đổi dữ liệu dày đặc trên cùng một nhân chuyển ngữ cảnh nhanh hơn 19 lần — 0,63 µs so với 12,4 µs. Lý do đó độc lập hoàn toàn với kích thước tập làm việc. Với một cặp tiến trình ping-pong qua ống hoặc socket, ghim chung một nhân có thể là thay đổi lớn nhất bạn làm được.
Hai lý do, hai cách ghim:
taskset -c 3 ./mot-tien-trinh-tinh-toan # giu bo nho dem nong
taskset -c 3 ./ca-hai-tien-trinh-trao-doi # giam chi phi danh thuc
NUMA: tầng tiếp theo của cùng ý tưởng
Trên máy chủ nhiều socket, RAM cũng thuộc về từng nhóm nhân. Truy cập RAM của nhóm khác đi qua liên kết giữa các socket và chậm hơn đáng kể.
numactl --hardware
cat /sys/devices/system/node/node0/cpulist
Máy tôi đo chỉ có một nút NUMA — /sys/devices/system/node/ trống — nên tôi không đo được phần này. Đó là giới hạn của phép đo, và tôi không suy diễn con số. Điều chắc chắn: nguyên tắc giống hệt, chỉ ở tầng lớn hơn. Bộ nhớ đệm thuộc về nhân; RAM thuộc về nút. Chạy trên nút này mà dữ liệu ở nút kia là phiên bản NUMA của việc nhảy nhân.
Công cụ:
numactl --cpunodebind=0 --membind=0 ./chuong-trinh # buoc ca hai ve nut 0
numastat -p <pid> # xem truy cap tu xa
numastat có cột numa_miss và numa_foreign — chúng khác 0 nghĩa là tiến trình đang đọc RAM của nút khác.
Bộ lập lịch đã cố giữ ái lực rồi
Linux không tuỳ tiện chuyển tiến trình sang nhân khác. CFS có khái niệm ái lực mềm: nó ưu tiên giữ tiến trình ở nhân cũ và chỉ chuyển khi mất cân bằng đủ lớn. Nên phép đo trong bài — ép đổi nhân mỗi vòng — là trường hợp xấu nhất, không phải hành vi mặc định. Trong thực tế, một tiến trình chạy liên tục thường ở yên một nhân khá lâu.
Kiểm tiến trình của bạn có bị chuyển nhiều không:
grep -E 'processor|se.nr_migrations' /proc/<pid>/sched
nr_migrations tăng nhanh nghĩa là bộ lập lịch đang chuyển nó thường xuyên — thường vì có quá nhiều tiến trình sẵn sàng chạy, hoặc vì tiến trình liên tục ngủ và thức.
Mẫu số chung: đưa việc tới chỗ dữ liệu đã nóng, đừng bắt dữ liệu chạy theo việc
Cái lõi ở đây — hiệu năng phụ thuộc vào việc dữ liệu nóng ở gần nơi xử lý hay không, và lợi ích của việc giữ chúng gần chỉ hiện khi dữ liệu đủ nhỏ để "ở gần" có nghĩa — lặp lại ở mọi tầng có phân cấp cục bộ/từ xa. Ghim đúng chỗ chỉ đáng khi có một chỗ để mà nóng lên.
Trong thế giới Java, đây đúng là thread affinity và thiết kế thân thiện cache: một ThreadPoolExecutor để tác vụ nhảy giữa các luồng/nhân làm mất cache nóng, và các framework độ trễ thấp (LMAX Disruptor, Aeron) ghim luồng vào nhân chính vì lý do trong bài — nhưng chỉ đáng khi working set vừa L2. Ở tầng phân tán, đây là data locality của Spark/Hadoop: đưa tính toán tới node đang giữ dữ liệu thay vì kéo dữ liệu qua mạng — cùng nguyên tắc "đừng bắt dữ liệu chạy theo việc", chỉ ở quy mô cụm.
Trong Go, GOMAXPROCS và việc goroutine bị chuyển giữa các P/M mang đúng đánh đổi này; và mẫu sharded counter (mỗi nhân một biến đếm riêng, gộp sau) tồn tại để mỗi luồng làm việc trên dữ liệu cục bộ của nó thay vì tranh một dòng cache chung — đúng "giữ dữ liệu nóng ở gần nơi xử lý".
Còn ở cơ sở dữ liệu, đây là toàn bộ tinh thần của locality: connection pinning để tái dùng buffer đã nóng, partition/sharding theo khoá để một truy vấn chạm đúng một node, và bộ đệm gần (Redis/CDN) đặt dữ liệu cạnh nơi dùng. Query nhanh khi dữ liệu nó cần đã nằm trong buffer pool của đúng node đang xử lý; chậm khi phải kéo từ đĩa hay từ shard khác — phiên bản CSDL của "nhảy nhân".
Mẫu số chung: mọi hệ thống có phân cấp gần-xa (cache của nhân, RAM của nút, đĩa của node, bộ đệm cạnh người dùng) đều thưởng cho việc giữ xử lý ở cạnh dữ liệu đã nóng — nhưng phần thưởng đó chỉ tồn tại khi dữ liệu đủ nhỏ để 'ở gần' vừa được; khi nó lớn tới mức chỗ nào cũng phải ra tầng xa, locality mất hết ý nghĩa. Nên trước khi ghim/pin/shard cho locality, câu hỏi đúng không phải "làm sao giữ nó cố định một chỗ" mà là "tập dữ liệu nóng có vừa cái 'gần' đó không, và tiến trình có sống đủ lâu để chỗ đó kịp nóng" — vì nếu không, bạn chỉ mất khả năng cân bằng tải mà chẳng đổi được gì.
Muốn tự xem tiến trình của bạn có bị chuyển nhân nhiều không:
p=<pid-can-xem>
last=""; doi=0
for i in $(seq 1 200); do
c=$(awk '{print $39}' /proc/$p/stat 2>/dev/null) || break
[ -n "$last" ] && [ "$c" != "$last" ] && doi=$((doi+1))
last=$c
done
echo "200 lan lay mau: $doi lan doi nhan, nhan cuoi = $last"
Trường thứ 39 của /proc/<pid>/stat là nhân đang chạy. Lấy mẫu liên tục rồi đếm số lần nó đổi cho biết bộ lập lịch đang giữ tiến trình ở yên tới đâu. Số lần đổi gần 0 với một tiến trình tính toán liên tục là bình thường — CFS giữ ái lực mềm khá tốt. Con số cao nghĩa là có tranh chấp CPU, và đó mới là lúc taskset đáng thử — nhưng chỉ sau khi đã kiểm tập làm việc có vừa bộ nhớ đệm không.
Nếu nhân của bạn được biên dịch với CONFIG_SCHED_DEBUG, có cách trực tiếp hơn:
grep nr_migrations /proc/<pid>/sched
Nhiều nhân dựng sẵn — kể cả nhân của Docker Desktop mà tôi đo — không có tệp này, nên cách lấy mẫu ở trên là phương án chạy được ở mọi nơi.
Phần sau: cgroup v2 — cách nhân thực sự giới hạn CPU, bộ nhớ và I/O.