bài về lát thời gianbài về nice ta thấy bộ lập lịch tự quyết tiến trình chạy ở nhân nào. Nhưng ta cũng ép được nó: CPU affinity cho phép ghim (pin) một tiến trình vào một nhân cụ thể, buộc nó ở yên đó. Lời hứa quen thuộc là: ghim giữ cache ấm — tiến trình không bị dời sang nhân khác rồi phải nạp lại toàn bộ cache từ đầu, nên chạy nhanh hơn. Nghe rất hợp lý. Bài này đo trực tiếp cái "nhanh hơn" đó — và phát hiện nó gần như không tồn tại, vì một lý do khiến tôi phải xem lại chính giả định của mình.

Đóng vào một core

CPU affinity và lời hứa cache ấm

Mỗi nhân CPU có cache riêng (L1, L2) giữ dữ liệu nóng của tiến trình đang chạy trên đó. Khi bộ lập lịch dời một tiến trình sang nhân khác (migration), cache cũ trở nên vô dụng — tiến trình phải nạp lại dữ liệu vào cache của nhân mới, và trong lúc chờ nạp, nó chạy chậm. Vì vậy lý thuyết nói: nếu ghim tiến trình vào một nhân, nó không bao giờ bị dời, cache luôn ấm, chạy nhanh và ổn định hơn.

Công cụ để ghim là taskset (dòng lệnh) hoặc sched_setaffinity (trong mã). Ta chỉ định một nhân, hay một tập nhân, và nhân sẽ chỉ chạy tiến trình đó trên các nhân ấy. Câu hỏi tôi muốn đo rất cụ thể: một tiến trình nhạy cache (dữ liệu vừa đủ cache L2) chạy ghim có nhanh hơn chạy tự do không, và nhanh hơn bao nhiêu?

Đo: ghim không nhanh hơn tự do

Tôi viết một chương trình pointer-chase ngẫu nhiên trên một vùng 512 KB (vừa cache L2), lặp trăm triệu lần, đo thời gian mỗi truy cập, đồng thời đếm số lần bị dời nhân qua trường nr_migrations trong /proc/self/sched. Máy đo là aarch64, 10 CPU. So ghim vào nhân 0 với để tự do:

Tình huống GHIM TỰ DO Số lần dời
Máy rảnh 4,3 ns 4,4 ns 0 vs 0
Dưới 18 task ép dời (đo CPU-time) 5,8 ns 5,8 ns 0 vs 0–1
Tự ép dời nhân mỗi 1M lần 4,4 ns (yên) 4,6 ns (dời) ~5% chênh

Ba dòng đều kể cùng một câu chuyện: ghim gần như không nhanh hơn tự do. Trên máy rảnh, hai bản chạy y hệt (4,3 so với 4,4 ns) — và điều quan trọng: bản tự do bị dời 0 lần. Không có migration nào để mà ngăn. Tôi thử tạo áp lực bằng 18 tiến trình bận (nhiều hơn 10 nhân, để buộc bộ lập lịch cân bằng tải và dời tiến trình đo đi lung tung): kết quả vẫn bằng nhau, và bản tự do vẫn chỉ bị dời 0–1 lần trong suốt phép đo. Ngay cả khi tôi tự tay ép tiến trình đổi nhân sau mỗi triệu truy cập, nó cũng chỉ chậm hơn khoảng 5%.

Một lần tôi đo hớ: đo cho một lợi ích đã có sẵn

Tôi vào bài này chắc mẩm bản GHIM sẽ thắng — cache ấm là một cơ chế có thật, tôi chỉ cần đo xem nó nhanh hơn bao nhiêu. Nhưng GHIM không thắng ở đâu cả, và ba dòng số "bằng nhau" buộc tôi nhìn lại: tôi đã đo cho một lợi ích mà, hóa ra, đã được cho không rồi.

Lý do thứ nhất: bộ lập lịch đã tự giữ cache ấm. Linux không dời tiến trình một cách tùy tiện — nó thực hiện soft affinity (ái lực mềm): một tiến trình vừa chạy gần đây được coi là "cache-nóng" và bộ cân bằng tải tránh dời nó đi. Tham số điều khiển điều này là migration_cost_ns, trên máy tôi bằng 0,5 mili giây: nếu tiến trình đã chạy trong vòng 0,5 ms qua, nhân giả định cache của nó còn nóng và không dời. Đây chính là lý do bản "tự do" của tôi bị dời gần như 0 lần, kể cả dưới tải nặng. Thứ mà việc ghim định ngăn — cú dời làm mất cache — bộ lập lịch đã ngăn giúp rồi. Ghim vào chỉ là làm cứng một điều nhân vốn đã làm mềm.

Lý do thứ hai: phần cứng này khiến migration rẻ. Khi tôi ép dời liên tục, penalty chỉ ~5% — vì đây là aarch64 (chip Apple), nơi nhiều nhân chia sẻ cache ở tầng cao, nên một tiến trình dời sang nhân lân cận không mất trắng cache. Trực giác "dời nhân = mất cache = chậm hẳn" đúng với một số kiến trúc cũ nhưng không đúng với phần cứng này. Đừng bê trực giác phần cứng cũ sang mới.

Bài học đo lường: đừng cho rằng một tối ưu thủ công có ích chỉ vì cơ chế của nó nghe hợp lý — hãy đo xem thứ nó định ngăn có thật sự đang xảy ra không. Cache ấm là thật, migration làm mất cache là thật, nhưng cả hai gộp lại không cho tôi một chút tốc độ nào từ việc ghim, vì migration hầu như không xảy ra (nhờ soft affinity) và khi xảy ra cũng rẻ (nhờ cache chia sẻ). Tôi đã suýt viết một bài "ghim vào core để tăng tốc" với những con số bịa ra trong đầu; phép đo bác bỏ nó.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên là đừng ghim CPU để mong tăng thông lượng — bạn gần như không được gì. Bộ lập lịch hiện đại đã giữ cache ấm rất tốt qua soft affinity; ghim thêm bằng tay hiếm khi làm một tải tính toán chạy nhanh hơn, và còn có thể làm chậm đi nếu bạn ghim tiến trình vào một nhân đang bận trong khi các nhân khác rảnh — bạn tự tay tước đi sự linh hoạt mà nhân dùng để cân bằng tải. "Ghim cho nhanh" thường là tối ưu tưởng tượng.

Hệ quả thứ hai là lợi ích thật của affinity là tính xác định và cô lập, không phải tốc độ trung bình. Có những lúc ghim rất đáng, nhưng vì lý do khác: để đảm bảo một tiến trình nhạy độ trễ luôn có một nhân riêng không bị ai tranh (giảm đuôi trễ — tail latency — chứ không phải tăng tốc trung bình); để đuổi các tiến trình nền ồn ào ra khỏi nhân dành cho việc quan trọng; để tránh cú dời hiếm hoi gây một khựng bất ngờ trong một hệ thống thời gian thực; hoặc để giữ tiến trình gần vùng bộ nhớ của nó trên máy NUMA nhiều socket (nơi truy cập bộ nhớ của socket khác thật sự chậm). Đó là những lợi ích đảm bảocô lập, đo bằng độ ổn định và đuôi trễ, không phải bằng ns trung bình.

Hệ quả thứ ba là một bài học đo lường lặp lại: trước khi áp một tối ưu, đo xem vấn đề nó giải có tồn tại không. Con số mang theo: ghim tiến trình vào một core gần như không tăng tốc một tải nhạy cache — máy rảnh, dưới tải, hay ép dời liên tục đều cho kết quả bằng nhau — vì bộ lập lịch đã tự giữ cache ấm (soft affinity, migration_cost 0,5 ms) và phần cứng chia sẻ cache khiến dời rẻ; lợi ích thật của affinity là tính xác định và cô lập (đuôi trễ, NUMA), không phải thông lượng. Đừng ghim vì nghe nói nhanh; ghim khi bạn cần một sự bảo đảm mà bộ lập lịch không hứa.

Thử ba mươi giây

Xem một tiến trình đã bị dời nhân bao nhiêu lần: grep nr_migrations /proc/<pid>/sched — con số này thường nhỏ đáng ngạc nhiên, cho thấy bộ lập lịch giữ tiến trình khá cố định mà bạn không cần làm gì. Muốn thử ghim, chạy một tải của bạn hai lần: một lần bình thường, một lần với taskset -c 0 ./tai-cua-ban, rồi so thời gian — nhiều khả năng chúng gần như bằng nhau, đúng như bài này đo. Xem affinity hiện tại của một tiến trình bằng taskset -cp <pid>. Và nếu bạn đang định ghim để "tăng tốc", hãy đo nr_migrations trước: nếu nó đã gần 0, thì chẳng có cú dời nào để bạn ngăn — cache đã ấm sẵn rồi.