Bạn mở trình theo dõi hệ thống, thấy "10 CPU", rồi kết luận ngay: chạy 10 luồng là nhanh gấp 10 lần. Có hai giả định giấu trong câu đó, và cả hai đều đáng đo. Một là "10 CPU" chưa chắc là 10 lõi thật — có khi chỉ là 5 lõi vật lý, mỗi lõi được nhân đôi thành hai luồng logic nhờ SMT (Intel gọi là Hyperthreading). Hai là kể cả 10 lõi vật lý thật, chưa chắc bạn được 10 lần tốc độ.
Hình dung SMT như một quầy thu ngân có hai hàng khách nhưng chỉ một cái máy tính tiền. Khi khách hàng này đang lục ví tìm tiền lẻ, thu ngân không ngồi chơi mà quay sang quét hàng cho khách hàng kia. Thời gian chết được tận dụng, nên phục vụ được nhiều hơn. Nhưng nếu cả hai khách cùng lúc cần máy tính tiền, họ vẫn phải chờ nhau — một máy vẫn là một máy. Đó chính là SMT: một lõi vật lý có nhiều đơn vị thực thi thường không dùng hết mỗi chu kỳ, nhất là khi một luồng kẹt chờ bộ nhớ; cho hai luồng logic chia nhau lõi thì luồng này kẹt, luồng kia lấp chỗ trống. Hệ điều hành thấy gấp đôi số "CPU", nhưng sức tính không hề gấp đôi — hai luồng vẫn tranh chung một bộ ALU.
Và điểm quan trọng: không phải CPU nào cũng có SMT. Nhiều lõi ARM, gồm Apple Silicon, mỗi lõi chỉ chạy đúng một luồng. Cách chắc chắn duy nhất là hỏi thẳng phần cứng.
Máy này không có SMT, và 10 lõi cho có 6,4 lần
lscpu trong container cho tôi câu trả lời dứt khoát: Thread(s) per core: 1. Không SMT. Mười "CPU" ở đây là mười lõi vật lý thật, mỗi lõi một luồng.
Giờ tới câu hỏi thứ hai. Tôi cho T luồng cùng chạy một khối tính toán độc lập, đo throughput gộp, chuẩn hóa sao cho một luồng bằng 1 và lý tưởng là bằng T:
| Số luồng | Throughput (lý tưởng = số luồng) |
|---|---|
| 1 | 1,00 |
| 2 | 2,00 |
| 4 | 3,56 |
| 8 | 5,67 |
| 10 | 6,39 — đỉnh |
| 12 | 5,48 |
| 20 | 6,06 |
Throughput tăng khi thêm luồng, nhưng đạt đỉnh chỉ khoảng 6,4 lần ở 10 luồng, không phải 10. Lý do: mười lõi này không đồng đều. Apple Silicon trộn lõi hiệu năng (P, nhanh) với lõi tiết kiệm (E, chậm) — như một đội có vài người khỏe, vài người yếu. Một luồng đơn được xếp lên lõi P nhanh làm chuẩn; khi chạy đủ 10 luồng, vài luồng rơi vào lõi E chậm, nên tổng sức chỉ bằng chừng 6,4 lõi-P. "Mười lõi" không dịch thẳng thành "mười lần".
Và vượt qua 10 luồng thì throughput phẳng lì, thậm chí dao động xuống. Thêm luồng khi đã hết lõi vật lý chỉ khiến hệ điều hành chia thời gian, chuyển ngữ cảnh qua lại, chứ chẳng thêm chỗ chạy song song. Nếu máy này có SMT, đáng lẽ throughput còn nhích lên chút ít quá mốc lõi vật lý nhờ lấp chỗ trống — nhưng nó phẳng, đúng như Thread/core = 1 đã báo.
Hai định kiến trái ngược
Định kiến quen thuộc của phản xạ tối ưu: cứ ném thêm luồng vào là nhanh hơn. Sai — throughput chỉ tăng tới số lõi, quá đó thêm luồng chỉ tốn chuyển ngữ cảnh và làm chậm nhau. Đây chính là lý do dựng thread pool đúng bằng số lõi gần như luôn thắng việc đẻ hàng nghìn thread.
Định kiến ngược lại: số CPU logic là số sức tính thật, N luồng là nhanh N lần. Cũng sai, theo hai cách. Trên máy có SMT, số luồng logic gấp đôi lõi vật lý, nhưng hai luồng chung lõi chia nhau ALU nên không cho gấp đôi sức — thường lợi 15–30% cho tải hay stall, gần như 0 cho tải tính toán thuần. Còn trên máy không SMT như cái này, 10 lõi vẫn không cho 10 lần vì lõi không đồng đều. Số lõi, logic hay vật lý, không dịch thẳng thành bội tốc độ.
Ngoài đời thì sao
Quy tắc gọn cho tải nặng CPU: đặt số luồng bằng số CPU logic của máy, đừng đẻ thêm. Trong C++ là std::thread::hardware_concurrency(), và mọi ngôn ngữ đều có cái tương đương — Java lấy Runtime.availableProcessors() làm mặc định cho ForkJoinPool, Go đặt GOMAXPROCS theo số CPU. Vượt qua đó chỉ thêm chuyển ngữ cảnh, không thêm sức.
Nhưng phân biệt cho rõ: điều trên chỉ đúng với tải nặng CPU. Với tải I/O-bound — luồng phần lớn thời gian ngồi chờ mạng hay đĩa — thì đẻ nhiều luồng hơn số lõi lại hợp lý, vì chúng chờ chứ có tranh CPU đâu; đây là lý do một web server phục vụ hàng nghìn kết nối vẫn ổn với vài chục lõi. Và nếu máy đích có lõi không đồng đều (Apple Silicon P/E, ARM big.LITTLE) hay có SMT, thì đừng nhân số lõi với tốc độ một lõi nhanh để đoán tổng sức — phải đo scaling thật.
Muốn tự kiểm thì chạy lscpu tìm dòng Thread(s) per core: bằng 1 là không SMT, bằng 2 là có. Rồi viết một chương trình cho T luồng cùng làm một vòng tính toán độc lập, đo tổng thời gian với T bằng 1, 2, 4, số-lõi, và gấp đôi số lõi, vẽ throughput theo T. Nó tăng tới quãng số lõi rồi phẳng. Nếu đỉnh thấp hơn số lõi kha khá thì máy bạn có lõi không đồng đều. Số CPU là số chỗ chạy, không phải số lần nhanh — và chỉ đo mới cho bạn biết bao nhiêu song song là thật.