Mặc định, bộ lập lịch có quyền chuyển một luồng từ lõi này sang lõi khác bất cứ lúc nào nó thấy hợp lý. taskset và sched_setaffinity cho phép bạn ghim một luồng vào một (hay vài) lõi cố định. Lý do thường nghe: ghim giữ cho dữ liệu của luồng nằm nóng trong cache của lõi đó, tránh cái giá "cache lạnh" khi bị đá sang lõi mới. Tôi đo trong container để xem cái lợi đó lớn cỡ nào — và phát hiện nó phụ thuộc vào một biến ẩn, cộng một cú suýt gán nhầm nguyên nhân.
Vì sao ghim lõi có thể giúp
Mỗi lõi CPU có cache riêng ở tầng gần nhất (L1, thường cả L2): một bản sao nhỏ, cực nhanh, của phần bộ nhớ mà lõi đang dùng. Khi một luồng chạy lâu trên một lõi, dữ liệu làm việc của nó dần dần nằm sẵn trong cache lõi đó — "nóng". Truy cập cache nóng nhanh gấp nhiều lần đọc từ RAM.
Nếu nhân di trú luồng sang một lõi khác, cache của lõi mới không có dữ liệu đó — "lạnh" — nên những lần truy cập đầu phải nạp lại từ tầng xa hơn (L3 chung, hoặc RAM). Ghim luồng vào một lõi ngăn chuyện di trú, giữ cache nóng. Đó là lý thuyết. Tôi kiểm bằng cách cho một luồng cộng tổng một mảng lặp đi lặp lại 200 lượt, đo trung vị thời gian mỗi phần tử, trong hai chế độ: ghim một lõi cố định, và ép đổi lõi mỗi lượt (xoay qua 4 lõi để mô phỏng di trú liên tục). Tôi thử ba cỡ working-set khác nhau.
Đo: ghim chỉ giúp khi dữ liệu vừa cache
| Working set | Ghim lõi | Ép đổi lõi (199 lần) | Chênh |
|---|---|---|---|
| 64KB | 0,259 ns/phần tử | 0,341 ns | +32% |
| 512KB | 0,256 ns | ~0,30 ns | +15% |
| 4MB | 0,258 ns | 0,260 ns | ~0% |
Kết quả kể một câu chuyện có điều kiện. Với working-set 64KB — đủ nhỏ để nằm gọn trong cache mỗi lõi — ghim lõi nhanh hơn ~32%: khi bị đá sang lõi mới, luồng mất cache nóng và phải nạp lại, chậm hẳn đi. Nhưng với working-set 4MB — lớn hơn cache mỗi lõi — ghim và không ghim giống hệt nhau (0,258 so với 0,260 ns): khi dữ liệu vốn đã không vừa cache, mỗi lượt đằng nào cũng phải đọc từ RAM, nên chạy lõi nào cũng như nhau, di trú chẳng mất gì thêm.
Đây là điểm mấu chốt: lợi ích của ghim lõi phụ thuộc hoàn toàn vào việc working-set có vừa cache mỗi lõi hay không. Nếu tôi chỉ đo cỡ 4MB, tôi sẽ kết luận "ghim lõi vô dụng"; nếu chỉ đo 64KB, tôi sẽ kết luận "ghim lõi luôn giúp 30%". Cả hai đều sai một nửa. Working-set là biến ẩn quyết định câu trả lời.
Một lần tôi đo hớ: cái đuôi nhọn không phải do di trú
Còn một chuyện nữa suýt lừa tôi. Ngoài trung vị, tôi để ý giá trị lớn nhất (max) trong mỗi loạt 200 lượt. Lần đo đầu tiên, bản ép-đổi-lõi có một lượt vọt lên 1,77 ns trong khi bản ghim lõi chỉ tối đa 0,26 ns. Tôi định viết ngay một kết luận đẹp: "ghim lõi không chỉ nhanh hơn ở trung vị mà còn giảm hẳn nhiễu đuôi — di trú gây ra những cú giật trễ".
May là tôi theo kỷ luật "lặp tới khi ổn định" và chạy lại vài lần. Và bản ghim lõi — cái đáng lẽ mượt mà — cũng vọt max lên tới 2,45 ns! Cái đuôi nhọn đó xuất hiện ở cả hai chế độ, bất kể ghim hay không. Nó không phải do di trú lõi; nó là nhiễu của tầng ảo hóa: container chạy trong một máy ảo trên Apple Silicon, và thỉnh thoảng cả tiến trình bị hypervisor hoặc máy chủ ngắt quãng một nhịp, gây một cú giật trễ ngẫu nhiên — có ghim hay không cũng vậy.
Bài học đo lường kép ở đây. Thứ nhất, một lần đo có thể vẽ ra một mối quan hệ nhân quả không tồn tại: nếu tôi tin ngay cái max của lần đầu, tôi đã gán cái đuôi nhiễu của môi trường cho chính thứ tôi đang muốn đo (di trú lõi). Thứ hai, trung vị là bạn khi có nhiễu đuôi: cái tín hiệu thật, lặp lại ổn định qua nhiều lần chạy, nằm ở trung vị (32% cho 64KB, 0% cho 4MB), còn cái max thì nhảy loạn và không nói gì về ghim lõi. Đo trong môi trường ảo hóa, phải luôn hỏi: cái biến động này là của thứ mình đo, hay của cái nền mình đang đứng?
Một tầng nữa: NUMA
Trên máy này (một cụm 10 lõi, không lộ tầng cache) hiệu ứng ghim lõi khá khiêm tốn. Nhưng trên các máy chủ lớn nhiều socket, có thêm một tầng khiến affinity quan trọng hơn hẳn: NUMA (Non-Uniform Memory Access). Ở đó bộ nhớ được chia theo từng nút gắn với từng nhóm lõi; một lõi đọc bộ nhớ của nút của nó thì nhanh, đọc bộ nhớ của nút khác thì chậm hơn đáng kể vì phải đi qua liên kết giữa các socket. Khi đó ghim lõi không chỉ giữ cache nóng mà còn giữ cho luồng ở gần vùng bộ nhớ nó đã cấp — nếu để nhân đá luồng sang một nút NUMA khác trong khi dữ liệu vẫn nằm ở nút cũ, mỗi lần truy cập bộ nhớ đều trả thêm cái giá "đi xa". Trên phần cứng như vậy, affinity (kèm numactl để ghim cả bộ nhớ) có thể là khác biệt hai chữ số phần trăm cho các tải lớn — nhiều hơn hẳn con số 32% ở cỡ cache mà tôi đo được trong máy ảo phẳng này.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: ghim lõi là công cụ có điều kiện, không phải nút "tăng tốc" vạn năng. Nó giúp nhất với các luồng có working-set nhỏ, ổn định, chạy lâu — một luồng xử lý gói mạng nóng, một vòng lặp tính toán trên một khối dữ liệu vừa cache. Với các tải lớn hơn cache hoặc truy cập rải rác khắp bộ nhớ, ghim gần như không đổi gì. Trước khi ghim, hãy hỏi: working-set của luồng này có vừa cache mỗi lõi không? Nếu không, đừng kỳ vọng.
Hệ quả thứ hai: ghim lõi cũng có mặt trái. Ghim cứng một luồng vào một lõi có thể khiến nó phải chờ trong khi các lõi khác rảnh, làm giảm khả năng nhân cân bằng tải. Nó đáng dùng khi bạn biết mình đang tối ưu một luồng nóng cụ thể, hoặc muốn cách ly các luồng khỏi giẫm chân nhau (ghim mỗi luồng một lõi riêng). Ghim bừa mọi luồng vào cùng vài lõi thường hại nhiều hơn lợi.
Hệ quả thứ ba là bài học đo lường bao trùm: tách tín hiệu khỏi nhiễu nền trước khi kết luận. Con số mang theo: ghim lõi giữ cache nóng và nhanh hơn ~32% khi working-set vừa cache (64KB), nhưng ~0% khi vượt cache (4MB) vì lúc đó tải là memory-bound — và cái đuôi trễ nhọn trong máy ảo là nhiễu của tầng ảo hóa, không phải do di trú lõi, chỉ lộ ra khi lặp đủ nhiều. Ghim đúng chỗ thì đáng; ghim vì tin đồn thì chỉ ràng buộc bộ lập lịch mà chẳng mua thêm tốc độ.
Thử ba mươi giây
Chạy taskset -cp $$ để xem shell hiện tại được phép chạy trên những lõi nào (thường là tất cả). Rồi thử ghim một tác vụ nặng CPU vào đúng một lõi: taskset -c 0 <lệnh-của-bạn> và so thời gian với chạy tự do. Để thấy luồng nhảy lõi khi không ghim, chạy một vòng lặp bận rộn rồi trong cửa sổ khác gõ watch -n0.2 "grep -w Cpus_allowed_list /proc/<pid>/status; ps -o psr -p <pid>" — cột psr là lõi hiện tại, và bạn sẽ thấy nó thỉnh thoảng đổi số khi máy bận. Ghim bằng taskset -cp 0 <pid> và cột đó đứng yên — đó là affinity đang giữ luồng ở một chỗ, đúng cái bài này đo.