Hình dung một văn phòng. Có người bảo: "gán cho mỗi nhân viên một cái bàn cố định đi, để giấy tờ của họ luôn ở ngay đó, khỏi phải dọn đi dọn lại — làm việc nhanh hơn hẳn". Nghe hợp lý. Nhưng nếu người quản lý vốn đã không bắt ai đổi chỗ giữa ngày làm việc, thì gán bàn cố định cho một người vốn ngồi yên cả ngày chẳng thay đổi gì cả. Chỗ mà sơ đồ chỗ ngồi thật sự quan trọng lại là chuyện khác: hai người phải trao đổi liên tục thì nên ngồi cạnh nhau — bắt họ ngồi hai đầu tòa nhà thì mỗi lần nói chuyện là một quãng đi bộ dài. Ghim luồng vào lõi (CPU affinity) đúng là câu chuyện cái bàn cố định đó.
Ở phần CFS ta thấy bộ lập lịch tự quyết ai chạy ở đâu. Nhưng lập trình viên có thể can thiệp bằng ái lực CPU (CPU affinity): ghim một luồng vào một (hay vài) lõi nhất định qua sched_setaffinity hoặc lệnh taskset. Đây là một trong những "mẹo tối ưu" được truyền tai nhiều nhất: "ghim luồng vào lõi để giữ cache nóng, tránh bị di chuyển lung tung — nhanh hơn hẳn". Tôi đo trong container gcc:13 (10 lõi ảo, ARM), và phát hiện lời khuyên đó phần lớn sai cho trường hợp thường gặp, đúng chỉ ở vài ca đặc thù.
Ý tưởng đằng sau, và vì sao nó thường không cần
Lập luận cho affinity nghe rất hợp lý. Khi một luồng chạy trên một lõi, cache của lõi đó (L1/L2) dần "nóng" — chứa dữ liệu luồng hay dùng. Nếu bộ lập lịch di chuyển (migrate) luồng sang lõi khác, cache nóng đó bỏ lại, và luồng phải nạp lại từ đầu ở lõi mới — mất công. Ghim luồng vào một lõi ngăn di trú, giữ cache nóng, nên "phải nhanh hơn".
Lỗ hổng của lập luận: nó giả định bộ lập lịch hay di chuyển luồng vô cớ. Thực tế, các bộ lập lịch hiện đại đã rất thận trọng với di trú — chúng biết cache nóng có giá, nên không di chuyển một luồng đang bận CPU trừ khi cần cân bằng tải thật sự. Đúng người quản lý vốn không bắt nhân viên đang làm đổi chỗ. Nghĩa là với một luồng CPU-bound bình thường, không có "mất cache do di trú" để mà sửa — ghim nó chẳng cải thiện gì.
Đo: ghim không giúp, giao tiếp chéo lõi mới là điểm
Tôi đo hai thứ. Thứ nhất, một luồng nặng cache (duyệt mảng 8 MB, 20 lần) — không ghim so với ghim vào một lõi, lấy min qua nhiều lần:
(a) Luồng nặng cache (8MB × 20, min):
KHÔNG ghim : 1,29 ms
GHIM lõi 2 : 1,36 ms -> ghim KHÔNG giúp (còn chậm 5,3%)
Ghim không làm nhanh hơn — thậm chí chậm hơn 5,3% (trong khoảng nhiễu). Vì bộ lập lịch vốn đã giữ luồng bận ở một lõi; ép affinity không sửa được vấn đề không tồn tại, và đôi khi còn cản (ví dụ nếu lõi bị ghim tình cờ đang bận việc khác). Trên máy ảo này, "mẹo ghim để nhanh hơn" đơn giản không có tác dụng cho luồng bận đơn lẻ.
Thứ hai, chỗ affinity thật sự có ảnh hưởng — giao tiếp chéo lõi. Hai luồng ping-pong qua một biến atomic chung, đặt trên hai lõi khác nhau:
(b) Ping-pong 2 luồng, 2 lõi khác nhau:
69 ns mỗi round-trip (qua cache coherence giữa lõi)
Mỗi lần hai luồng ở hai lõi trao đổi qua một dòng cache chung, dòng đó phải "nảy" qua lại giữa cache của hai lõi qua giao thức cache coherence — tốn ~69 ns mỗi round-trip. Đây mới là thứ affinity ảnh hưởng được: nếu hai luồng hay trao đổi, ghim chúng vào cùng một lõi (hay hai lõi chia sẻ cache L2) giảm chi phí nảy này; ghim chúng ra xa nhau làm nó tệ hơn. Affinity là công cụ để bố trí các luồng theo cách chúng giao tiếp — đúng chuyện xếp hai người hay nói chuyện ngồi cạnh nhau — không phải để "giữ cache nóng cho một luồng đơn lẻ".
(Môi trường đo: container gcc:13 trên host ARM AArch64, 10 lõi ảo, mỗi số lấy MIN sau warmup. Con số tuyệt đối đổi theo máy và mức ảo hoá; cái bền vững là: ghim luồng bận đơn lẻ không cải thiện, còn giao tiếp chéo lõi có chi phí thật mà bố trí luồng ảnh hưởng được.)
Hai niềm tin đối nghịch, cùng sai
Tôi vào đo với niềm tin phổ biến: "ghim luồng vào lõi luôn nhanh hơn vì giữ cache nóng". Phép đo bác bỏ cho trường hợp thường: ghim một luồng bận không giúp gì (còn hơi chậm). Cái "mất cache do di trú" mà lập luận dựa vào không xảy ra — bộ lập lịch đã tránh di trú luồng bận. Trước khi "sửa" một vấn đề bằng một cơ chế, phải đo xem vấn đề đó có thật sự xảy ra không; nhiều tối ưu chữa một bệnh không tồn tại.
Nhưng có một niềm tin ngược, dễ rơi vào sau khi đọc phép đo trên, cũng sai: "vậy affinity vô dụng, đừng bao giờ dùng". Không — nhìn phần (b): giao tiếp chéo lõi tốn 69 ns mỗi round-trip, nhân với hàng triệu lần là rất thật, và affinity là công cụ đúng để cắt nó (đặt luồng hay trao đổi gần nhau). Nó còn thiết yếu cho real-time (cô lập một lõi để tránh mọi jitter di trú) và NUMA (giữ luồng gần dải bộ nhớ của nó). Sự thật nằm ở chỗ: affinity không phải nút tăng tốc mặc định mà là công cụ chuyên dùng — vô ích (đôi khi có hại) cho luồng bận đơn lẻ, nhưng đáng giá cho những ca cụ thể bạn đo được. Và nhớ mặt trái: ghim sai (dồn nhiều tác vụ nặng vào một lõi trong khi lõi khác rảnh) làm chậm hẳn — bạn tự tay phá cân bằng tải mà scheduler làm tốt.
Về đến code hằng ngày
Điều đầu tiên: đừng ghim affinity như một tối ưu mặc định — tin bộ lập lịch trước. Với phần lớn workload (luồng CPU-bound, web server, xử lý dữ liệu), scheduler đã đặt luồng hợp lý và tránh di trú vô cớ. Ghim thủ công không giúp, và dễ làm hại nếu bạn ghim cứng nhắc trong khi tải thay đổi. Chỉ ghim khi bạn đo được một vấn đề cụ thể mà ghim giải quyết (ví dụ đếm số lần migration bằng perf, thấy nó cao thật).
Điều thứ hai: dùng affinity để bố trí theo giao tiếp, không để "giữ cache nóng". Nếu hai luồng trao đổi dữ liệu tần suất cao, đặt chúng gần nhau (cùng lõi hoặc cùng cụm cache) cắt chi phí cache coherence (69 ns/round-trip × số lần). Ngược lại, hai luồng độc lập nặng CPU nên ở khác lõi để chạy song song. Affinity là công cụ sắp xếp topo, hữu ích khi bạn hiểu luồng nào nói chuyện với luồng nào.
Chỗ này ranh giới "khi nào ghim đáng" hiện rõ ở các kiến trúc thật. Phần lớn ứng dụng (Java web app, service Go) không ghim affinity — và đúng, vì scheduler lo tốt; Go thậm chí cố tình không phơi affinity ra (GOMAXPROCS chỉ giới hạn số luồng OS, không ghim lõi). Nhưng đúng ở đầu kia của phổ, các hệ thread-per-core ép sát phần cứng thì ghim là bắt buộc: ScyllaDB/Seastar, DPDK, kernel bypass network — mỗi luồng ghim cứng một lõi, tránh mọi di trú và chia sẻ chéo lõi, chính là để loại cái 69 ns kia và jitter. Trong Java, thư viện Java-Thread-Affinity của OpenHFT tồn tại riêng cho giao dịch tần suất cao vì đúng lý do đó. Và numactl --cpunodebind cho database lớn là affinity phục vụ NUMA. Mẫu số chung: không ai ghim để "cache nóng cho một luồng"; họ ghim để bố trí giao tiếp hoặc cô lập độ trễ — và chỉ khi đã đo thấy cần.
Muốn tự thấy thì nếu bạn từng thêm taskset hay sched_setaffinity vào một chương trình để "tăng tốc", hỏi: bạn có đo nó nhanh hơn không, hay chỉ tin nó phải nhanh hơn? Thử bỏ affinity đi và đo lại — rất có thể không khác gì (hoặc nhanh hơn khi tải biến động, vì scheduler tự cân bằng tốt hơn ghim cứng). Rồi hỏi vấn đề thật của bạn là gì: nếu là hai luồng giao tiếp nhiều chậm vì ở xa nhau, affinity (đặt chúng gần) mới đúng chỗ; nếu chỉ là một luồng bận, để scheduler lo. Vài phút đo "có ghim vs không ghim" đó cứu bạn khỏi một tối ưu-huyền-thoại phổ biến — và nhắc rằng cách chắc chắn nhất để biết một mẹo có hiệu quả không là đo nó, chứ không phải nghe nó nghe hợp lý.