Một đầu bếp giỏi để sẵn dao, thớt, gia vị trong tầm tay tại đúng một bàn bếp — đổi bàn liên tục thì mỗi lần lại phải bày lại đồ nghề, mất thời gian. Nhưng nếu món anh ta nấu cần nguyên liệu chất đầy cả kho lạnh chứ không phải vài thứ trên bàn, thì đứng bàn nào cũng phải chạy vào kho, việc "giữ đồ trong tầm tay" chẳng còn giúp gì. Ghim luồng vào một lõi CPU đúng là chuyện cái bàn bếp đó, và cái lợi của nó phụ thuộc một biến ẩn mà tôi suýt quên hỏi. 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. taskset và sched_setaffinity cho phép ghim một luồng vào một lõi cố định, giữ dữ liệu nóng trong cache lõ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.
Cái đuôi trễ nhọn không phải do di trú — suýt nữa tôi đổ oan
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.
Điều rút ra 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ề đến code hằng ngày
Điều đầ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.
Điều 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.
Và cả hai bài học ở đây — "affinity chỉ giúp khi tải là cache-bound" và "đừng đổ cái đuôi trễ cho di trú khi thủ phạm là nhiễu nền" — vọng thẳng vào việc chỉnh hiệu năng ở tầng cao. Trong Java độ-trễ-thấp, người ta pin thread (qua thư viện như OpenHFT Affinity) cho các luồng nóng, nhưng cái làm p99 giật thường lại là GC pause hay JIT compile — đổ cho di trú lõi là chữa nhầm bệnh, phải đo bằng GC log/JFR mới thấy thủ phạm thật. Go tự động không cho pin goroutine (scheduler M:N), nên bạn hiếm khi cần affinity ở tầng app; và một "spike" trong benchmark Go rất hay là do GC hoặc goroutine preemption chứ không phải lõi. Ngay cả CSDL: một truy vấn "thỉnh thoảng chậm" trên máy chia sẻ thường là noisy neighbor (VM khác giành I/O/CPU) chứ không phải plan đổi — đúng cái đuôi nhiễu tầng ảo hóa ở đây. Mẫu số chung: trước khi gán một cú giật hiệu năng cho cơ chế bạn đang chỉnh, phải loại trừ nhiễu nền (GC, hypervisor, hàng xóm ồn) bằng cách lặp nhiều lần và nhìn trung vị — cái đuôi max một lần chạy hay vẽ ra nhân quả không có thật. Gói lại bằng số: ghim lõi giữ cache nóng và nhanh hơn ~32% khi working-set vừa cache (64KB: 0,259 so với 0,341 ns), giảm còn +15% ở 512KB, và ~0% khi vượt cache (4MB: 0,258 so với 0,260 ns) vì lúc đó tải là memory-bound; còn cái đuôi trễ nhọn (max vọt 1,77–2,45 ns) xuất hiện ở cả hai chế độ, là nhiễu của tầng ảo hóa chứ không phải do di trú lõi.
Muốn tự thấy thì 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.