Phần 3 cho thấy một chuỗi phép tính phụ thuộc chạy chậm vì mỗi lệnh phải đợi lệnh trước, còn tách thành nhiều chuỗi độc lập thì nhanh vài lần. Câu hỏi tự nhiên tiếp theo: một lõi CPU thật sự chạy được bao nhiêu lệnh độc lập cùng một lúc?

Hình dung một siêu thị có tám quầy thu ngân mở cùng lúc. Nếu khách xếp thành một hàng dài mà ai cũng chờ người trước trả tiền xong mới tới lượt, thì bảy quầy kia bỏ không — thông lượng đúng bằng một quầy. Nhưng nếu khách chia đều ra tám hàng độc lập, cả tám quầy chạy song song, cửa hàng phục vụ nhanh gấp bội. Lõi CPU hiện đại chính là cái siêu thị tám quầy đó: nó siêu vô hướng (superscalar), mỗi chu kỳ phát nhiều lệnh độc lập vào nhiều cổng thực thi. Cái quyết định là bạn có xếp việc thành nhiều hàng độc lập hay dồn hết vào một hàng. Phần này tôi đo thẳng độ rộng phát lệnh ấy trong container gcc:13 trên host ARM, và con số lớn đến bất ngờ.

Phát nhiều lệnh mỗi chu kỳ — nếu chúng độc lập

Một lõi CPU không xử lý tuần tự từng lệnh. Nó có nhiều đơn vị thực thi (ALU cộng, nhân, đơn vị nạp/lưu, đơn vị số thực…) và một bộ máy sắp xếp lại lệnh (out-of-order) tìm những lệnh đã sẵn sàng — đủ toán hạng, không chờ ai — rồi phát nhiều lệnh cùng một chu kỳ. Số lệnh tối đa phát được mỗi chu kỳ là độ rộng phát lệnh (issue width), và số lệnh thực tế hoàn thành mỗi chu kỳ là IPC (instructions per cycle).

Điều kiện để đạt IPC cao là tính độc lập — tám hàng thay vì một. Nếu các lệnh xếp thành một chuỗi phụ thuộc (mỗi lệnh cần kết quả lệnh trước), lõi không thể chạy song song, bị trói ở tốc độ một lệnh mỗi độ trễ (như phần 3 đã đo). Nhưng nếu bạn cho lõi nhiều luồng công việc độc lập, nó lấp đầy các cổng và IPC vọt lên. Câu hỏi đo: lấp được tới đâu?

Cách đo: nhiều chuỗi cộng độc lập, đếm lệnh mỗi chu kỳ

Tôi dùng một phép đơn giản nhất — phép cộng số nguyên, độ trễ đúng 1 chu kỳ. Viết K "chuỗi" cộng độc lập: mỗi chuỗi là một thanh ghi tự cộng dồn (a += 1), K thanh ghi khác nhau chạy song song. Dùng assembly nội tuyến (add) để trình biên dịch không gộp hay tối ưu mất, mỗi vòng lặp thực thi 32 nhóm K phép cộng.

Mẹo hiệu chỉnh đồng hồ: với K=1, chỉ một chuỗi phụ thuộc — mỗi add chờ add trước, mà độ trễ cộng là 1 chu kỳ, nên nó chạy đúng 1 phép cộng mỗi chu kỳ. Đo được ns mỗi phép cộng ở K=1 chính là thời gian một chu kỳ. Từ đó, với K chuỗi, số phép cộng mỗi chu kỳ = (ns/add ở K=1) ÷ (ns/add ở K). Con số này bắt đầu ở 1 và cho biết lõi phát song song được bao nhiêu. Biên dịch với -O2 -fno-tree-vectorize để không lẫn với SIMD (vector hóa là một dạng song song khác, xem phần 4).

Đo: từ 1 lên ~7,5 phép cộng mỗi chu kỳ

K chuỗi add độc lập, host ARM, g++ -O2 -fno-tree-vectorize:

   K  | ns/add | add mỗi chu kỳ (IPC)
   ---|--------|---------------------
    1 | 0,228  |  1,00   <- chuỗi phụ thuộc = 1/chu kỳ
    2 | 0,113  |  2,01
    4 | 0,064  |  3,57
    6 | 0,049  |  4,65
    8 | 0,040  |  5,77
   10 | 0,030  |  7,56   <- đỉnh
   12 | 0,032  |  7,13   <- tụt nhẹ (áp lực thanh ghi)

Đọc cột cuối: với K=1, lõi làm đúng 1 phép cộng mỗi chu kỳ — một hàng, bảy quầy bỏ không. Nhưng khi tôi cho nó nhiều chuỗi độc lập, IPC leo thẳng: 2 chuỗi → 2,01; 4 → 3,57; 8 → 5,77; và đỉnh ~7,56 phép cộng mỗi chu kỳ ở K=10. Cùng một lõi, cùng loại lệnh add — chỉ khác ở chỗ tôi có bao nhiêu việc độc lập để lấp vào. Con số 1 chu kỳ suy ra là 0,228 ns, tức lõi này chạy ~4,4 GHz; và nó nuốt tới ~7-8 lệnh nguyên mỗi chu kỳ đó. Đây là một lõi rất rộng — đúng phong cách các lõi lớn của Apple Silicon, nổi tiếng với bộ máy out-of-order khổng lồ.

Chú ý mức tăng giảm dần rồi quay đầu: 1→2 gần gấp đôi, nhưng 8→10 chỉ thêm chút, và K=12 tụt xuống 7,13 — quá số thanh ghi thoải mái, bắt đầu chịu áp lực thanh ghi (register pressure) nên chậm lại. Đỉnh ~7,5 quanh K=10 là độ rộng thực dụng của lõi cho phép cộng: thêm chuỗi nữa không giúp, vì đã hết cổng phát để chạy song song.

Điều đáng kinh ngạc là chi phí biên của việc độc lập: từ K=1 tới K=8, tôi làm gấp 8 lần số phép cộng nhưng thời gian mỗi phép giảm từ 0,228 xuống 0,040 ns — tức tổng thời gian gần như không đổi dù làm gấp 8 lần công việc. Bảy phép cộng kia gần như miễn phí, vì chúng lấp vào những khe phát trống mà chuỗi đơn để lãng phí.

Hai niềm tin đối nghịch, cùng sai

Tôi vào đo với mô hình trong đầu từ thời học vi xử lý đơn giản: "CPU nạp một lệnh, thực thi, rồi nạp lệnh kế — thời gian chạy tỉ lệ với số lệnh". Con số bác ngay: cùng một lõi làm đúng 1 phép cộng mỗi chu kỳ khi các lệnh phụ thuộc, nhưng tới ~7,5 phép cộng mỗi chu kỳ khi chúng độc lập. IPC không phải 1 — nó vượt xa 1 nếu code có đủ song song mức lệnh. "Thời gian = số lệnh × chi phí một lệnh" sai tới ~7 lần, vì nó bỏ qua chuyện lõi chạy nhiều lệnh chồng lên nhau. Số lệnh chỉ là tử số; mẫu số là bao nhiêu lệnh xếp song song được.

Nhưng nếu vì thấy IPC cao mà quay sang tin "vậy thêm phép tính độc lập nào cũng tốn thêm thời gian, ít lệnh luôn nhanh hơn" thì cũng sai theo hai hướng. Thứ nhất, tới khi các cổng phát chưa đầy, thêm lệnh độc lập gần như miễn phí — từ 1 lên 8 chuỗi tôi làm gấp 8 lần phép cộng trong gần cùng thời gian, vì chúng ẩn vào khe trống. Một chương trình "làm nhiều việc độc lập" có thể chạy cùng thời gian với chương trình làm ít việc nhưng tuần tự. Thứ hai, lợi ích không vô hạn: qua đỉnh ~7,5 (K=10), thêm chuỗi hết giúp và còn hại (K=12 tụt vì áp lực thanh ghi). Độ rộng lõi là trần cứng của phần cứng.

Về đến code hằng ngày

Việc đầu tiên: viết vòng lặp nóng để lộ song song mức lệnh — nhiều luồng tính toán độc lập. Giống mẹo tách accumulator ở phần 3, nhưng nay ta biết vì sao và tới đâu: lõi có nhiều cổng, hãy cho nó nhiều việc độc lập để lấp. Tránh chuỗi phụ thuộc dài không cần thiết; nếu bài toán cho phép, tính nhiều phần song song rồi gộp. Với ~7-8 khe mỗi chu kỳ, một vòng lặp toàn phụ thuộc đang bỏ phí phần lớn lõi.

Việc thứ hai: "số lệnh" không phải thước đo tốc độ — cách chúng phụ thuộc mới là. Đừng tối ưu mù bằng cách đếm và cắt lệnh; đôi khi thêm lệnh độc lập (unroll, dùng nhiều biến tạm) nhanh hơn vì lấp khe trống, còn cắt lệnh mà giữ chuỗi phụ thuộc dài thì không giúp. Và đừng kỳ vọng vô hạn: qua độ rộng lõi, thêm việc là vô ích hoặc phản tác dụng. Số chuỗi độc lập "vừa đủ" thường là ~độ trễ chia thông lượng của phép tính.

Cái siêu thị tám quầy này giải thích một nghịch lý quen thuộc ở tầng cao: vì sao "ít dòng code" không đồng nghĩa "nhanh hơn". Cùng một lý do, nó cũng là nền cho những thứ trông chẳng liên quan. JIT của JVM và trình biên dịch Go/Rust sắp xếp lại lệnh chính để tạo ra các chuỗi độc lập cho lõi lấp — bạn viết code tuần tự, chúng cố dàn ra song song. Bộ tối ưu database làm điều tương tự ở quy mô toán tử: một kế hoạch truy vấn với các nhánh độc lập cho phép thực thi song song, giống nhiều hàng thu ngân. Và chính khái niệm IPC là thứ các profiler như perf phơi ra — một vòng lặp IPC=0,5 (đang stall chờ bộ nhớ hoặc kẹt chuỗi phụ thuộc) và một vòng IPC=6 (lấp đầy cổng) tiêu tốn CPU rất khác nhau dù "cùng số lệnh". Hiểu điều này là hiểu vì sao đọc mỗi "số lệnh" hay "dòng code" không bao giờ đủ để đoán tốc độ.

Muốn tự thấy thì viết một hàm dùng assembly nội tuyến (hoặc nhiều biến volatile để trình biên dịch không gộp) làm K chuỗi phép cộng độc lập: K=1 là một biến tự cộng dồn, K=4 là bốn biến cộng song song, v.v. Mỗi vòng lặp lặp lại nhiều chục lần thân để loãng chi phí vòng, chạy vài chục triệu vòng, lấy min. Chia thời gian cho tổng số phép cộng để ra ns mỗi phép. Với K=1 bạn được thời gian một chu kỳ (vì chuỗi phụ thuộc = 1 phép/chu kỳ). Tăng K: 2, 4, 8, 10, 12 — bạn sẽ thấy số phép mỗi chu kỳ leo lên qua 1, tới một đỉnh (độ rộng lõi của bạn, thường 4 trên nhiều x86, 6-8 trên lõi lớn ARM/Apple), rồi phẳng hoặc tụt khi hết thanh ghi. Ba mươi giây đó cho thấy điều mà "CPU chạy một lệnh mỗi chu kỳ" giấu đi: bên trong một lõi là cả một dàn cổng chạy song song, và tốc độ thật của bạn phụ thuộc bạn có cho nó đủ việc độc lập để lấp đầy hay không.