Bạn mở trình theo dõi hệ thống, thấy "10 CPU", và kết luận: chạy 10 luồng thì nhanh gấp 10 lần một luồng. Hai giả định ẩn trong câu đó đều đáng đo. Thứ nhất, "10 CPU" có thể là 10 lõi vật lý, hoặc 5 lõi vật lý mỗi lõi 2 luồng logic nhờ SMT (Simultaneous Multithreading — Intel gọi là Hyperthreading). Thứ hai, kể cả 10 lõi vật lý thật, chưa chắc bạn được 10 lần tốc độ. Phần này đo cả hai. Tôi đo trong container gcc:13 trên host ARM, và trước hết phải nói thẳng một sự thật về chính cái máy này: nó không có SMT — nên bài này vừa giải thích SMT là gì, vừa dùng phép đo để chứng minh máy này không có nó, và để lộ một điều bất ngờ về mười cái lõi.
SMT: một lõi vật lý, hai luồng logic — nhưng không phải máy nào cũng có
Một lõi CPU hiện đại có nhiều đơn vị thực thi (ALU, đơn vị nạp/lưu, đơn vị dấu phẩy động) và thường không dùng hết chúng mỗi chu kỳ — khi một luồng kẹt chờ bộ nhớ hay chờ một chuỗi phụ thuộc, nhiều đơn vị nằm không. SMT tận dụng chỗ trống đó: cho hai luồng logic chạy trên cùng một lõi vật lý, chia sẻ đơn vị thực thi và cache. Khi luồng A kẹt, luồng B lấp bong bóng pipeline. Hệ điều hành thấy gấp đôi số "CPU".
Nhưng SMT không nhân đôi sức tính: hai luồng tranh cùng bộ ALU. Nó chỉ nhặt phần lõi bị bỏ trống, nên lợi nhiều khi các luồng hay stall (chờ bộ nhớ), lợi ít — thậm chí hại — khi cả hai đều compute-bound và giành cùng đơn vị. Và điều 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 một luồng. Cách chắc chắn là hỏi phần cứng.
Đo: máy này Thread/core = 1, và 10 lõi cho đỉnh chỉ 6,4 lần
Trước hết hỏi thẳng topology trong container:
lscpu (trong container gcc:13, host ARM):
Architecture: aarch64
CPU(s): 10
Thread(s) per core: 1 <- KHÔNG SMT
Thread(s) per core = 1 là câu trả lời dứt khoát: máy này không có SMT. 10 "CPU" là 10 lõi vật lý, mỗi lõi chạy đúng một luồng. Giờ đo scaling: chạy T luồng làm công việc tính toán độc lập (một chuỗi FMA compute-bound, không chạm bộ nhớ chung), đo throughput gộp, chuẩn hóa sao cho một luồng = 1 và lý tưởng = T:
T luồng tính toán độc lập, host ARM (10 CPU, không SMT), g++ -O2 -pthread:
T luồng | wall (s) | throughput chuẩn hóa (= T·t1/t(T), lý tưởng = T)
---------|----------|--------------------------------------------------
1 | 0,199 | 1,00
2 | 0,200 | 2,00
4 | 0,224 | 3,56
8 | 0,281 | 5,67
10 | 0,312 | 6,39 <- đỉnh (≈ số lõi)
12 | 0,437 | 5,48
16 | 0,507 | 6,30
20 | 0,658 | 6,06
Đọc ra hai điều. Một: throughput tăng khi thêm luồng, nhưng đạt đỉnh ~6,4 lần ở 10 luồng — không phải 10 lần dù có 10 lõi. Vì sao? 10 lõi này không đồng đều: Apple Silicon trộn lõi hiệu năng (P-core, nhanh) và lõi tiết kiệm (E-core, chậm). Một luồng đơn được xếp lên một lõi P nhanh (baseline t1 = 0,199 s); 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 ~6,4 lõi-P. "10 lõi" không dịch thẳng thành "10 lần".
Hai: vượt qua 10 luồng (12, 16, 20), throughput phẳng lì — quanh 5,5–6,3, thậm chí dao động xuống. Wall time tăng tỉ lệ (0,312 → 0,437 → 0,658 s) đúng bằng phần công việc thêm vào, nghĩa là throughput gộp không tăng. Đây là dấu hiệu kinh điển của không SMT: khi đã dùng hết lõi vật lý, thêm luồng chỉ khiến hệ điều hành chia thời gian (chuyển ngữ cảnh qua lại) chứ không có thêm chỗ chạy song song. Nếu máy có SMT, ta sẽ thấy throughput còn nhích lên một chút quá mốc lõi vật lý (nhờ lấp bong bóng), nhưng ở đây nó bằng phẳng — đúng như Thread/core = 1 báo.
Một lần tôi đo hớ: "thêm luồng là thêm throughput" và "N luồng logic = nhanh N lần"
Tôi vào đo với phản xạ tối ưu quen thuộc: "cứ tăng số luồng thì càng nhanh, cứ ném thêm thread vào là ăn thêm throughput". Đo phá tan: throughput chỉ tăng tới số lõi logic — ở máy này là 10 (bằng số lõi vật lý vì không SMT). Vượt qua đó (12, 16, 20 luồng), throughput phẳng: T=10 cho ~32 đơn vị/s, T=20 vẫn ~30, không hơn. Mấy luồng dư chỉ tốn chuyển ngữ cảnh, tranh cache, và làm chậm nhau. Đây là lý do dựng pool đúng bằng số lõi (std::thread::hardware_concurrency()) gần như luôn thắng việc đẻ vô tội vạ hàng nghìn thread.
Nhưng đo cũng chỉnh một niềm tin ngược: "số CPU logic là số sức tính toán thật — N luồng thì nhanh N lần". Sai theo hai cách. Trên máy có SMT, số luồng logic là gấp đôi số lõi vật lý, nhưng hai luồng chung một lõi chia nhau ALU — chúng không cho gấp đôi sức tính, chỉ nhặt phần lõi bỏ trống (thường lợi 15–30% cho tải hay stall, gần như 0 cho tải compute-bound thuần). Và ngay trên máy không SMT như máy này, 10 lõi cũng không cho 10 lần, vì các lõi không đồng đều (P nhanh, E chậm) — đo ra đỉnh chỉ ~6,4x. Số lõi (dù logic hay vật lý) không dịch thẳng thành bội số tốc độ.
Bài học đo lường: SMT cho 1 lõi vật lý chạy 2 luồng logic chia sẻ ALU (lợi khi stall, không gấp đôi sức tính); NHƯNG không phải máy nào cũng có — host ARM Apple Silicon này lscpu báo Thread(s) per core = 1, KHÔNG SMT. Đo scaling: throughput tăng tới số lõi logic (=10, bằng lõi vật lý) rồi PHẲNG (T=10→20 không tăng, chỉ context-switch); đỉnh chỉ ~6,4x KHÔNG 10x vì 10 lõi không đều (P nhanh + E chậm). 'Thêm luồng = thêm throughput' và 'N luồng = nhanh N lần' đều sai. Nếu tin "thêm luồng luôn nhanh hơn" tôi oversubscribe và tự làm chậm; nếu tin "số lõi = bội tốc độ" tôi kỳ vọng sai và không thấy được ảnh hưởng của SMT lẫn lõi không đồng đều.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: đặt số luồng theo số CPU logic của máy, đừng đẻ thêm. Dùng std::thread::hardware_concurrency() (hoặc nproc) làm trần cho thread pool; vượt qua đó chỉ thêm chuyển ngữ cảnh và tranh chấp, không thêm sức tính — như phép đo cho thấy throughput phẳng sau mốc lõi. Với tải I/O-bound (hay chờ) thì khác: nhiều luồng chờ cùng lúc là hợp lý, vì chúng không tranh CPU.
Hệ quả thứ hai: biết máy đích có SMT không, và lõi có đồng đều không — đừng suy số lõi thành bội tốc độ. Trên máy SMT, hai luồng "anh em" chung một lõi vật lý cạnh tranh nhau; ghim (affinity) các luồng nhạy hiệu năng lên các lõi khác nhau có thể nhanh hơn để chúng trên các lõi vật lý riêng. Trên máy lõi không đồng đều (Apple Silicon P/E, ARM big.LITTLE), một luồng rơi vào lõi E sẽ chậm hẳn — đo băng thông và scaling để biết sức thật, đừng nhân số lõi với tốc độ một lõi nhanh.
Hệ quả thứ ba là tinh thần đo lường: số "CPU" mà hệ điều hành báo là số luồng logic, không phải số nhân sức tính — và scaling thật phải đo. Con số mang theo: hỏi topology (lscpu: Thread/core, số lõi); scaling tăng tới số lõi logic rồi phẳng; đỉnh có thể < số lõi khi lõi không đồng đều (đo máy này ~6,4x trên 10 lõi, không SMT). Đặt pool = số CPU logic; đừng oversubscribe tải CPU-bound. Mười cái lõi không tự động thành mười lần nhanh — chỉ đo trên máy thật mới cho bạn con số song song thật sự có.
Thử ba mươi giây
Chạy lscpu (hoặc đọc /proc/cpuinfo) và tìm dòng "Thread(s) per core": bằng 1 là máy không SMT (mỗi lõi một luồng), bằng 2 là có SMT (số CPU logic gấp đôi lõi vật lý). Rồi viết một chương trình cho T luồng cùng làm một vòng lặp tính toán nặng độc lập (không chạm bộ nhớ chung), đo tổng thời gian với T = 1, 2, 4, số-lõi, và gấp đôi số lõi. Tính throughput gộp (tổng công việc chia thời gian) và vẽ theo T: bạn sẽ thấy nó tăng tới quãng số lõi rồi phẳng — vượt số lõi logic, thêm luồng chỉ thêm chuyển ngữ cảnh. Nếu đỉnh thấp hơn số lõi kha khá (như ~6 trên 10), rất có thể máy bạn có lõi không đồng đều (P/E). Cuối cùng, thử một tải hay stall (đuổi con trỏ qua RAM thay vì tính thuần) trên máy có SMT: lần này bạn có thể thấy throughput còn nhích lên quá mốc lõi vật lý, vì luồng anh em lấp được bong bóng chờ bộ nhớ. Ba mươi giây đó cho bạn thấy điều mà "10 CPU thì nhanh 10 lần" giấu đi: số CPU là số chỗ chạy, không phải số lần nhanh, và chỉ đo scaling trên máy thật mới nói cho bạn biết bao nhiêu song song là thật.