bài về CFS ta xem nhân chia một lõi cho nhiều luồng. Nhưng máy có nhiều lõi, và câu hỏi thú vị hơn là: nhân trải các luồng ra nhiều lõi thế nào để tận dụng song song? Ở bài về affinity ta thấy ghim luồng vào một lõi giữ cache nóng. Bài này đo mặt trái của chuyện đó: tôi ghim tất cả luồng lại "cho gọn" và vô tình vứt bỏ hầu hết sức mạnh của máy.

Kernel cân bằng lõi

Bộ cân bằng tải của nhân

Máy này có 10 lõi. Khi bạn tạo nhiều luồng bận CPU mà không ghim chúng vào lõi nào, nhân không nhồi tất cả vào một chỗ — nó có một bộ cân bằng tải (load balancer) tự động trải các luồng ra các lõi, lý tưởng là mỗi lõi một luồng bận, để chúng chạy thật sự song song. Hơn nữa, nếu tải trở nên lệch (một lõi có hai luồng trong khi lõi khác rảnh), nhân sẽ di trú một luồng sang lõi rảnh để san bằng. Nhờ cơ chế này, bạn có được thông lượng gấp nhiều lần trên nhiều lõi gần như miễn phí — chỉ cần tạo luồng và để nhân lo.

Tôi đo bằng cách chạy 8 luồng bận CPU trong ~1,5 giây, theo hai cách, rồi đọc xem mỗi luồng cuối cùng chạy trên lõi nào (qua sched_getcpu và trường processor trong /proc/<tid>/stat).

Đo: để nhân tự trải, hay tự nhốt mình

8 luồng CPU-bound, chạy ~1,5s:

Để nhân tự trải (không ghim):
  tổng công việc = 854.562 đơn vị
  các luồng chạy trên lõi: 8  3  1  4  9  7  5  6

Ghim HẾT vào lõi 0:
  tổng công việc = 165.906 đơn vị
  các luồng chạy trên lõi: 0  0  0  0  0  0  0  0

Hai dòng "các luồng chạy trên lõi" nói tất cả. Khi không ghim, tám luồng của tôi được nhân trải ra tám lõi khác nhau (8, 3, 1, 4, 9, 7, 5, 6) — chạy song song thật, và làm được 854.562 đơn vị công việc. Khi tôi ghim hết vào lõi 0, cả tám luồng chen chúc trên một lõi duy nhất, thời-chia nhau (mỗi luồng được 1/8 lõi như CFS đã đo), và chỉ làm được 165.906 đơn vị — bằng khoảng một phần năm. Chín lõi còn lại ngồi không hoàn toàn.

Một lần tôi đo hớ: "kiểm soát" quá tay

Đây chính là chỗ tôi suýt sai. Sau khi viết bài affinity — nơi ghim luồng vào một lõi giúp giữ cache nóng — tôi mang tâm lý "ghim là kiểm soát tốt" sang bài này, và ý định đầu tiên của tôi là ghim hết tám luồng worker vào một lõi "cho chúng gần nhau, chia sẻ cache". Đo ra 165.906 — chậm hơn năm lần so với để nhân tự lo. Cái "kiểm soát" của tôi thực chất là tự nhốt cả tám luồng vào một lõi và bỏ phí chín lõi kia.

Bài học là hai mặt của cùng một công cụ. Affinity giúp khi bạn có một luồng nóng và muốn giữ nó ở một lõi để cache khỏi lạnh. Nhưng khi bạn có nhiều luồng song song muốn dùng nhiều lõi, ghim chúng vào ít lõi là phản tác dụng — bạn vứt bỏ chính cái song song mà nhân sẵn lòng cho. Con số 854.562 so với 165.906 không phải chuyện cache; nó là chuyện một lõi so với tám lõi. Nếu tôi chỉ đọc bài affinity rồi kết luận "ghim luôn tốt", tôi đã tối ưu ngược. Đây đúng là "kiểm soát quá tay có khi tệ hơn để nhân tự lo" — bộ cân bằng tải của nhân đã được viết bởi những người hiểu phần cứng hơn tôi rất nhiều, và với tải song song thông thường, tin nó là lựa chọn đúng.

Nhân cân bằng khi nào, và không mù quáng

Bộ cân bằng tải không chạy liên tục — làm vậy sẽ tốn CPU và làm luồng nhảy lõi loạn xạ. Nó cân bằng ở vài thời điểm: định kỳ (mỗi nhịp đồng hồ nhân kiểm tra xem tải các lõi có lệch không), khi một lõi nhàn rỗi (một lõi hết việc sẽ chủ động "kéo" một luồng từ lõi bận sang), và khi một luồng vừa thức dậy (nhân chọn một lõi tốt cho nó). Chính vì vậy, ở phép đo trên, tám luồng của tôi được trải ra tám lõi gần như tức thì sau khi tạo.

Điều quan trọng là bộ cân bằng không di trú mù quáng. Di trú một luồng sang lõi khác có cái giá: cache của nó ở lõi cũ trở nên vô dụng, phải nạp lại ở lõi mới (đúng cái bài affinity đo). Nên nhân cân nhắc: nó ưu tiên giữ luồng ở lõi cũ (và cùng cụm cache/NUMA) trừ khi lợi ích cân bằng đủ lớn để đáng cái giá cache lạnh. Nói cách khác, nhân đã tự làm phép đánh đổi giữa "song song" và "cache nóng" mà tôi phải cân nhắc thủ công khi ghim — và nó làm điều đó với thông tin về topo phần cứng mà chương trình của tôi không có. Đây là một lý do nữa để tin bộ cân bằng của nhân cho tải thông thường: nó không chỉ trải luồng, mà trải một cách có ý thức về cache.

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

Hệ quả đầu tiên: với tải song song CPU-bound, mặc định (không ghim) thường là đúng. Nhân sẽ tự trải luồng ra các lõi và cân bằng khi lệch. Đừng ghim luồng trừ khi bạn có lý do đo được — và "cho gọn" hay "để kiểm soát" không phải lý do. Ghim sai làm giảm khả năng nhân cân bằng, có thể nhốt tải vào ít lõi hơn máy có.

Hệ quả thứ hai: affinity là dao hai lưỡi, dùng đúng chỗ. Nó đáng khi bạn cô lập một luồng nóng khỏi bị nhân di chuyển (giữ cache, giảm jitter cho một luồng độ-trễ-thấp), hay khi bạn muốn tách các luồng khỏi giẫm chân nhau bằng cách gán mỗi luồng một lõi khác nhau. Nó hại khi bạn gom nhiều luồng vào ít lõi. Khác biệt nằm ở "ghim ra các lõi khác nhau" (tốt cho song song) so với "ghim tụm vào một lõi" (giết song song).

Hệ quả thứ ba là một lưu ý về số lượng luồng. Vì nhân trải luồng ra lõi, một pool worker CPU-bound nên có cỡ quãng bằng số lõi — đủ để lấp mọi lõi, không thừa để tốn phí chuyển ngữ cảnh (đúng như bài CFS đã chỉ: quá số lõi thì tổng thông lượng không tăng). Con số mang theo: nhân tự trải 8 luồng bận ra 8 lõi khác nhau cho song song thật (854.562 đơn vị), còn ghim hết vào một lõi chỉ được ~1/5 (165.906) vì nhốt cả 8 luồng vào một lõi và bỏ phí phần còn lại; affinity tốt cho một luồng nóng nhưng phản tác dụng khi gom nhiều luồng song song. Với song song, hãy để nhân cân bằng, và chỉ ghim khi đo được rằng nó giúp.

Thử ba mươi giây

Chạy một chương trình đa luồng bận CPU (hay đơn giản stress-ng --cpu 8 nếu có), rồi mở htop và bấm phím để hiện cột CPU của từng luồng, hoặc chạy ps -eLo pid,tid,psr,comm | grep <tên> — cột psr là lõi hiện tại của mỗi luồng. Bạn sẽ thấy nhân đã trải các luồng ra nhiều lõi khác nhau, và các số đó đổi theo thời gian khi nhân di trú để cân bằng. Giờ thử ghim tất cả vào một lõi: taskset -c 0 ./chuong-trinh và nhìn htop — chỉ lõi 0 đỏ rực 100%, các lõi khác im lìm, và chương trình chạy chậm hẳn. Đó chính là khác biệt một-lõi so với nhiều-lõi mà bài này đo, hiện ra ngay trên màn hình giám sát.