Ở 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ự. 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, không phải để "giữ cache nóng cho một luồng đơn lẻ".
Một lần tôi đo hớ: ghim không phải nút tăng tốc
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.
Bài học đo lường: trước khi "sửa" một vấn đề bằng một cơ chế, đ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. Ghim để "chống di trú" chỉ có nghĩa nếu luồng của bạn đang bị di trú và đang mất cache vì thế — hãy đo (ví dụ đếm số lần migration bằng perf) trước khi tin. Với một luồng CPU-bound đơn lẻ, gần như chắc chắn không cần ghim; scheduler đã lo. Affinity đáng dùng ở ca đặc thù: giảm giao tiếp chéo lõi (đặt luồng hay trao đổi gần nhau — 69 ns mỗi lần cộng dồn với hàng triệu lần là thật), cô lập lõi cho tác vụ real-time (tránh mọi jitter di trú), hay giữ luồng gần bộ nhớ NUMA của nó. 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ì sao điều này quan trọng khi lập trình
Hệ quả đầ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.
Hệ quả 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.
Hệ quả thứ ba là tinh thần đo lường: đo xem vấn đề có thật trước khi áp giải pháp — kẻo chữa bệnh không tồn tại. Con số mang theo: ghim (affinity) một luồng CPU-bound đơn lẻ KHÔNG làm nhanh hơn — đo được không ghim 1,29 ms vs ghim 1,36 ms (còn chậm 5,3%), vì bộ lập lịch vốn không di trú luồng bận vô cớ nên chẳng có "mất cache do di trú" để sửa; affinity CHỈ giúp ca đặc thù: giảm giao tiếp chéo lõi (ping-pong 2 luồng 2 lõi tốn 69 ns/round-trip qua cache coherence -> ghim gần nhau), real-time (cô lập lõi tránh jitter), NUMA. Ghim là công cụ chuyên dùng, ghim sai (dồn tác vụ nặng vào 1 lõi) còn làm chậm. Tin scheduler cho trường hợp thường.
Thử ba mươi giây
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. Ba mươi giây đ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ý.