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? Câu trả lời phá tan hình dung phổ biến "CPU đọc một lệnh, làm xong, đọc lệnh kế". Lõi hiện đại là siêu vô hướng (superscalar): mỗi chu kỳ nó phát (issue) nhiều lệnh độc lập song song vào nhiều cổng thực thi. Phần này đo thẳng độ rộng phát lệnh ấy — bao nhiêu lệnh mỗi chu kỳ — trong container gcc:13 trên host ARM, và con số lớn đến bất ngờ.

Siêu vô hướng và độ rộng phát lệnh

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. 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 — nó 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 thực thi 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ỳ — chuỗi phụ thuộc trói tay nó. 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í.

Một lần tôi đo hớ: "CPU chạy 1 lệnh mỗi chu kỳ" và "thêm việc độc lập luôn tốn thêm thời gian"

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". Đo phá tan: 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 đo cũng phá một niềm tin ngược — cái mà người mới thấy IPC cao dễ mắc: "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". 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.

Bài học đo lường: lõi CPU SIÊU VÔ HƯỚNG — phát nhiều lệnh ĐỘC LẬP mỗi chu kỳ. Chuỗi phụ thuộc = 1 lệnh/chu kỳ (đo K=1 = 0,228 ns/add = 1 chu kỳ, ~4,4 GHz); K chuỗi độc lập leo tới ~7,5 add/chu kỳ ở K=10 (IPC>>1, lõi rộng ~7-8). Thêm việc độc lập gần MIỄN PHÍ tới khi lấp hết cổng (1->8 chuỗi = 8x add, gần cùng thời gian), rồi BÃO HÒA và tụt (K=12, áp lực thanh ghi). 'CPU 1 lệnh/chu kỳ' và 'thêm lệnh luôn tốn thêm' đều SAI. Nếu tin "1 lệnh/chu kỳ" tôi đánh giá thấp lõi ~7 lần; nếu tin "thêm lệnh luôn tốn thêm" tôi bỏ lỡ chuyện việc độc lập gần như miễn phí.

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

Hệ quả đầ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ì saotớ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.

Hệ quả 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 (áp lực thanh ghi, tràn thanh ghi). Số chuỗi độc lập "vừa đủ" thường là ~độ trễ chia thông lượng của phép tính.

Hệ quả thứ ba là tinh thần đo lường: một lõi làm nhiều việc cùng lúc hơn ta tưởng — IPC là biến ẩn sau mọi con số hiệu năng. Con số mang theo: chuỗi phụ thuộc 1 lệnh/chu kỳ; lệnh độc lập leo tới ~7,5/chu kỳ (lõi rộng ~7-8, IPC>>1); thêm việc độc lập gần miễn phí tới khi lấp cổng rồi bão hòa. Thiết kế cho độc lập, đừng chỉ đếm lệnh. Cùng số phép tính, nhưng đồ thị phụ thuộc của chúng quyết định lõi rộng của bạn được dùng hết hay bỏ không — đo mới thấy.

Thử ba mươi giây

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 bạn 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.