Một nhân CPU chỉ chạy được một luồng tại một thời điểm. Muốn nhiều luồng "cùng chạy" trên một nhân, hệ điều hành phải liên tục chuyển ngữ cảnh (context switch): cất trạng thái luồng đang chạy, nạp trạng thái luồng kế. Thao tác này xảy ra hàng nghìn lần mỗi giây và là một chi phí ẩn quen thuộc. Bài này đo nó bằng phép ping-pong kinh điển — và phép đo cho một kết quả trái ngược hoàn toàn với trực giác "dùng nhiều nhân thì nhanh hơn".

Chuyển ngữ cảnh

Chuyển ngữ cảnh và cách đo nó

Khi hệ điều hành chuyển từ luồng A sang luồng B trên cùng một nhân, nó phải lưu các thanh ghi của A, nạp các thanh ghi của B, cập nhật con trỏ ngăn xếp, và (nếu B thuộc tiến trình khác) đổi bảng trang. Cách đo cổ điển là ping-pong: hai luồng ném qua ném lại một token qua một cặp pipe. Mỗi lần một luồng đọc pipe và không có dữ liệu, nó chặn (block) và nhường nhân cho luồng kia — đúng một lần chuyển ngữ cảnh. Bằng cách bắt hai luồng bám đuôi nhau chặt chẽ, ta ép ra hàng trăm nghìn lần chuyển và chia trung bình.

Nhưng có một điều kiện tôi phải kiểm soát, và đây chính là chỗ tôi suýt đo sai: hai luồng chỉ thật sự chuyển ngữ cảnh khi chúng ở cùng một nhân. Nếu hệ điều hành đặt chúng lên hai nhân khác nhau, mỗi luồng có nhân riêng và chúng chạy song song — gần như không chuyển ngữ cảnh nào cả.

Đo: ghim một nhân 0,5 µs, hai nhân 8,7 µs

Tôi chạy ping-pong 200.000 vòng (mỗi vòng khoảng 2 lần chuyển) trong ba cấu hình. Container thấy 10 nhân, nên khi không ghim, hệ điều hành thoải mái rải hai luồng ra hai nhân:

Cấu hình µs mỗi lần chuyển voluntary_ctxt_switches
Ghim cả hai vào 1 nhân 0,527 +97,6 nghìn
Không ghim (OS rải ra 2 nhân) 8,733 +199 nghìn
Tiến trình, ghim 1 nhân 0,539 +100 nghìn

Nhìn dòng đầu và dòng hai: bản ghim vào một nhân — nơi hai luồng thật sự phải thay phiên nhau — cho ~0,5 µs mỗi lần chuyển. Bản không ghim, dùng tận hai nhân, lại chậm gấp 16 lần (8,7 µs). Cái bộ đếm voluntary_ctxt_switches xác nhận cả hai đều có hàng trăm nghìn lần nhường nhân, nên phép đo là thật.

Một lần tôi đo hớ: hai nhân không phải đang chuyển ngữ cảnh nhanh hơn

Tôi ghim hai luồng vào một nhân để ép chúng chuyển ngữ cảnh, rồi chạy thêm bản không ghim — tưởng đó sẽ là "đường nền dùng nhiều nhân, chắc nhanh hơn hoặc tương đương". Kết quả ngược đời: bản không ghim chậm gấp 16 lần. Nếu tin trực giác và báo "8,7 µs mỗi lần chuyển ngữ cảnh", tôi đã đo nhầm hẳn một thứ khác.

Lý do: khi hai luồng nằm trên hai nhân riêng, chúng gần như không chuyển ngữ cảnh — mỗi cái có một nhân cho riêng mình. Cái ping-pong lúc này không đo chi phí chuyển ngữ cảnh nữa, mà đo chi phí phối hợp giữa hai nhân: dòng cache chứa bộ đệm pipe phải nảy qua lại giữa bộ nhớ đệm của hai nhân (cache coherency traffic), và mỗi lần đánh thức luồng đang ngủ ở nhân kia cần một ngắt liên nhân (IPI). Cross-core coordination đó mới là thứ tốn 8,7 µs — không phải chuyển ngữ cảnh. Chuyển ngữ cảnh thật (khi ép chung một nhân) chỉ ~0,5 µs.

Bài học đo lường: một phép đo hiệu năng chỉ đúng khi bạn kiểm soát được biến bạn định đo — và ở đây biến ẩn là việc luồng chạy ở đâu. Không ghim CPU, tôi để hệ điều hành tự quyết vị trí luồng, và nó chọn phương án tránh chuyển ngữ cảnh (rải ra nhiều nhân) — biến phép đo "chuyển ngữ cảnh" thành phép đo "giao tiếp liên nhân". Trực giác "nhiều nhân = nhanh" cũng sai với loại tải bám đuôi chặt này: hai luồng liên tục chờ nhau thì đặt chung một nhân (dữ liệu nóng trong cache, không có IPI) lại nhanh hơn tách ra 16 lần.

Còn một huyền thoại nữa tan trong bảng số: dòng ba cho thấy chuyển ngữ cảnh giữa hai tiến trình (0,539 µs) gần như bằng giữa hai luồng (0,527 µs). Sách cũ nói tiến trình chuyển đắt hơn nhiều vì phải đổi bảng trang và xả toàn bộ TLB. Nhưng CPU hiện đại gắn thẻ mỗi mục TLB bằng một mã không gian địa chỉ (PCID/ASID), nên đổi bảng trang không cần xả TLB — và khác biệt teo lại còn ~2%.

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

Hệ quả đầu tiên là giảm số lần chuyển ngữ cảnh không cần thiết trong đường nóng. Dù mỗi lần chỉ ~0,5 µs, một hệ thống chuyển hàng trăm nghìn lần mỗi giây (ví dụ quá nhiều luồng cùng tranh một khóa, hay một hàng đợi đánh thức luồng liên tục) sẽ đốt CPU đáng kể chỉ cho việc chuyển, không phải cho công việc thật. Đây là lý do các mô hình bất đồng bộ (event loop, async) và bể luồng cỡ vừa thường thắng: chúng làm nhiều việc trên mỗi lần được lịch, thay vì chuyển qua chuyển lại liên miên. Bộ đếm voluntary_ctxt_switchesnonvoluntary_ctxt_switches trong /proc/<pid>/status cho bạn thấy một tiến trình đang chuyển nhiều tới mức nào.

Hệ quả thứ hai là hiểu rằng đặt hai luồng bám đuôi lên cùng một nhân đôi khi nhanh hơn tách ra. Nếu hai luồng liên tục trao dữ liệu cho nhau (một producer–consumer chặt), tách chúng ra hai nhân bắt dữ liệu nảy qua lại giữa các cache — cái giá cross-core có thể lớn hơn cả lợi ích song song. Các hệ hiệu năng cao dùng CPU affinity (ghim luồng vào nhân) một cách có chủ đích: giữ luồng gần dữ liệu nó dùng, và giữ các luồng nói chuyện nhiều gần nhau (cùng nhân hoặc cùng cụm cache). "Nhiều nhân" chỉ giúp khi công việc thật sự song song và ít phụ thuộc nhau.

Hệ quả thứ ba là một nguyên tắc đo lường: luôn kiểm soát nơi luồng chạy khi đo bất cứ thứ gì liên quan tới CPU. Con số mang theo: chuyển ngữ cảnh thật (hai luồng chung một nhân) chỉ ~0,5 µs; con số 8,7 µs khi để hai nhân là chi phí cross-core (cache nảy + IPI), không phải chuyển ngữ cảnh; và tiến trình chuyển gần bằng luồng nhờ TLB gắn thẻ PCID. Trước khi tin một microbenchmark CPU, hãy hỏi: luồng của tôi đang chạy ở nhân nào, và tôi có đang đo đúng thứ mình nghĩ không?

Thử ba mươi giây

Chọn một tiến trình bận trên máy Linux và xem nó chuyển ngữ cảnh bao nhiêu: grep ctxt /proc/<pid>/status in ra voluntary_ctxt_switches (tự nguyện nhường, ví dụ chờ I/O) và nonvoluntary_ctxt_switches (bị lịch cướp nhân). Theo dõi hai con số này theo thời gian: nếu chúng tăng hàng chục nghìn mỗi giây, tiến trình đang tốn nhiều thời gian chuyển hơn làm. Muốn thấy hiệu ứng ghim nhân, chạy taskset -c 0 <chương-trình> để ép nó vào một nhân rồi so với chạy tự do — với tải bám đuôi hai luồng, bạn có thể ngạc nhiên thấy ghim một nhân lại nhanh hơn, đúng như bài này đo. Nơi luồng chạy quan trọng ngang việc nó làm gì.