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 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.
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.
Điều rút ra: 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ề đến code hằng ngày
Điều đầ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_switches và nonvoluntary_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.
Điều 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.
Điều cuối 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. Gói lại bằng số: 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), chậm gấp 16 lần và không phải chuyển ngữ cảnh; và tiến trình chuyển (0,539 µs) gần bằng luồng (0,527 µs) 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?
Cái phát hiện "trao dữ liệu giữa hai nhân đắt hơn hẳn xử lý trên một nhân, và scheduler mặc định chưa chắc chọn tối ưu cho tải bám đuôi" này là bí mật hiệu năng của các hệ độ trễ thấp, và nó lật đổ trực giác "cứ nhiều thread là nhanh" ở mọi ngôn ngữ. Trong Java, một hàng đợi giữa producer và consumer chạy trên hai nhân khác nhau chịu đúng cái cache-line bouncing này — đó là lý do Disruptor (LMAX) thiết kế ring buffer để giảm chia sẻ cache line và người ta ghim thread bằng thread affinity library cho tải trading; và một ThreadPoolExecutor mở quá nhiều thread cho tải phụ thuộc nhau chỉ thêm context switch và cross-core traffic chứ không nhanh hơn. Go giấu context switch của goroutine (rẻ hơn OS thread nhiều vì chuyển trong user-space) nhưng vẫn chịu cross-core khi channel nối hai goroutine trên hai P khác nhau — GOMAXPROCS và cách phân goroutine ảnh hưởng thật. Cơ sở dữ liệu thấm bài này tới kiến trúc: nhiều engine hiện đại (ScyllaDB, Redis) cố ý shard theo nhân — mỗi nhân xử lý một phần dữ liệu riêng, gần như không chia sẻ, để né chính cái cross-core coordination 8,7 µs ở đây; đó là triết lý "thread-per-core". Mẫu số chung: song song không miễn phí, và với tải mà các đơn vị phụ thuộc/trao đổi nhiều với nhau, giữ chúng gần nhau (cùng nhân, cùng cache) thường thắng việc rải ra nhiều nhân; nên khi song song hóa mà không nhanh lên như kỳ vọng, hỏi "các thread/goroutine này có đang ném dữ liệu qua lại giữa các nhân không", và cân nhắc affinity hoặc shard-theo-nhân thay vì cứ thêm luồng.
Muốn tự xem thì 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ì.