Suốt mấy phần vừa rồi, cùng một phép toán lại hiện ra với những con số khác nhau: phần 11 đo phép nhân "3 chu kỳ", còn phần 3phần 10 lại cho thấy lõi làm nhiều phép mỗi chu kỳ. Không mâu thuẫn — vì mỗi lệnh có hai chi phí, không phải một: độ trễ (latency) và thông lượng (throughput). Phần này đo cả hai cùng lúc cho từng phép, trong container gcc:13 trên host ARM, để thấy chúng khác nhau tới mức nào và khi nào bạn phải nhìn con số nào — cộng một bất ngờ về phép chia.

Thông lượng và độ trễ của lệnh

Hai con số cho một lệnh

Độ trễ (latency) là thời gian từ lúc một phép bắt đầu tới lúc kết quả sẵn sàng dùng. Nó quan trọng khi phép sau cần kết quả phép trước — một chuỗi phụ thuộc.

Thông lượng (throughput) là số phép độc lập lõi làm xong mỗi chu kỳ. Vì lõi vừa sâu (pipeline — bắt đầu phép mới trước khi phép cũ xong) vừa rộng (nhiều cổng, phần 10), nó có thể có nhiều phép "đang bay" cùng lúc. Con số này quan trọng khi bạn có nhiều phép độc lập.

Để đo tách bạch, tôi chạy cùng một lệnh hai kiểu: latency = chuỗi phụ thuộc (mỗi phép ăn kết quả phép trước); throughput = 8 chuỗi độc lập song song (đủ để lấp pipeline). Rồi chia thời gian ra ns mỗi phép.

Đo: cùng lệnh, hai giá chênh 6–8 lần

Host ARM, g++ -O2, 1 chu kỳ ≈ 0,214 ns:

   phép | latency (chuỗi phụ thuộc) | throughput (độc lập) | chênh
   -----|---------------------------|----------------------|------
   add  | 1,1 ck (0,228 ns)         | 0,2 ck (0,040 ns)    | 5,7x
   mul  | 3,2 ck (0,682 ns)         | 0,4 ck (0,085 ns)    | 8,0x
   fmul | 3,3 ck (0,707 ns)         | 0,5 ck (0,111 ns)    | 6,4x
   fdiv | 10,7 ck (2,276 ns)        | 1,3 ck (0,285 ns)    | 8,0x

Đọc bảng: cùng một phép nhân số nguyên tốn 3,2 chu kỳ khi nằm trong chuỗi phụ thuộc, nhưng chỉ 0,4 chu kỳ khi các phép độc lập — chênh 8 lần. Không phải hai loại lệnh khác nhau; là một lệnh mul, đo hai cách. Trong chuỗi phụ thuộc, mỗi mul phải đợi kết quả trước (trả trọn độ trễ 3,2 ck). Khi độc lập, lõi phóng phép mới mỗi ~0,4 ck mà không cần phép cũ xong (trả 1/thông lượng). Mọi phép đều thế: add chênh 5,7x, fmul 6,4x — vì lõi này vừa sâu vừa rộng nên độc lập che gần hết độ trễ.

Bất ngờ: cả phép chia cũng chồng được

Tôi chắc mẩm phép chia sẽ là ngoại lệ. Bộ chia phần cứng làm việc lặp (phần 16), nên tôi tin nó "làm từng cái một" — thông lượng ≈ độ trễ, chồng nhiều phép chia độc lập cũng vô ích. Đo phá tan: fdiv độ trễ 10,7 ck nhưng thông lượng chỉ 1,3 ckcả phép chia cũng chồng được ~8 lần. Lõi Apple này giữ được ~8 phép chia độc lập "đang bay" cùng lúc; bộ chia của nó pipelined hơn tôi tưởng.

Điều đó không biến chia thành rẻ: fdiv vẫn chậm nhấtcả hai con số — độ trễ 10,7 ck (gấp ~3 lần fmul) và thông lượng 1,3 ck (gấp ~7 lần add). Nhưng nó cũng hưởng lợi từ tính độc lập giống mọi phép khác. "Div không pipeline" là một huyền thoại nữa của CPU đời cũ mà tôi đã mang theo.

Một lần tôi đo hớ: "mỗi lệnh một chi phí" và "div không pipeline"

Tôi vào đo với mô hình đơn giản: "tra bảng, mỗi lệnh có một con số chu kỳ, cộng lại là ra thời gian". Đo phá tan: mỗi lệnh có hai con số chênh nhau 6–8 lần. Một mul tốn 3,2 ck hay 0,4 ck tùy nó nằm trong chuỗi phụ thuộc hay giữa các phép độc lập. Cộng "số chu kỳ mỗi lệnh" mà không biết mình đang nhìn latency hay throughput là ước lượng sai tới gần chục lần. Đây chính là lý do các bảng tra (ví dụ của Agner Fog) luôn liệt kê latency throughput riêng.

Và đo phá thêm hai niềm tin. Một: "muốn nhanh cứ chọn lệnh có latency thấp" — sai nếu code của bạn throughput-bound (nhiều phép độc lập); lúc đó throughput mới là con số quyết định, và một lệnh latency cao vẫn có thể throughput tốt. Hai: "phép chia không pipeline nên đừng mong độc lập giúp" — sai, fdiv chồng được ~8x trên lõi này. Cả hai sai lầm đều đến từ việc nhìn nhầm con số hoặc tin vào folklore mà không đo. Bài học chung: hiệu năng thật phụ thuộc cấu trúc phụ thuộc của code nhân với đặc tính lệnh, không phải một bảng giá tĩnh.

Bài học đo lường: mỗi lệnh có HAI chi phí: LATENCY (chuỗi phụ thuộc) và THROUGHPUT (độc lập), chênh ~6-8x trên lõi ARM này — add 1,1 vs 0,2 ck; mul 3,2 vs 0,4 ck (8x); fmul 3,3 vs 0,5; fdiv 10,7 vs 1,3 ck. Cả fdiv CŨNG pipeline được ~8x (không như folklore 'div làm từng cái'). Div vẫn chậm nhất ở cả hai số. 'Mỗi lệnh một chi phí cố định' và 'div không pipeline' đều SAI. Nếu tin "một chi phí" tôi ước lượng lệch chục lần; nếu tin "div không pipeline" tôi bỏ lỡ chuyện chia độc lập vẫn nhanh 8x.

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

Hệ quả đầu tiên: biết code của bạn latency-bound hay throughput-bound, rồi nhìn đúng con số. Nếu là một chuỗi phụ thuộc dài (mỗi bước cần bước trước), bạn bị latency trói — hãy rút ngắn chuỗi hoặc phá thành việc độc lập (phần 3, 11). Nếu là nhiều phép độc lập, bạn được hưởng throughput — lo lấp đủ pipeline (nhiều accumulator, unroll đúng cách) hơn là chọn lệnh latency thấp.

Hệ quả thứ hai: tách phụ thuộc là cách chuyển từ giá latency sang giá throughput. Đây là sợi chỉ xuyên suốt sê-ri: nhiều accumulator (phần 3), nhiều con trỏ đuổi song song (MLP, phần 15), K chuỗi độc lập (phần 10) — tất cả đều biến một phép "đắt theo latency" thành "rẻ theo throughput" bằng cách cho lõi nhiều việc độc lập. Cùng số phép, giá giảm 6–8 lần.

Hệ quả thứ ba là tinh thần đo lường: một lệnh không có "giá", nó có hai giá, và code của bạn chọn giá nào. Con số mang theo: latency vs throughput chênh ~6-8x (mul 3,2 vs 0,4 ck; fdiv 10,7 vs 1,3 ck); chuỗi phụ thuộc trả latency, phép độc lập trả 1/throughput; cả div cũng pipeline. Tra bảng phải xem cả hai, và tách phụ thuộc để hưởng throughput. Cùng một dòng code số học, nhanh hay chậm 8 lần tùy bạn xâu chúng phụ thuộc hay để chúng độc lập — đo mới thấy.

Thử ba mươi giây

Chọn một phép, ví dụ nhân số nguyên, và đo hai cách. Latency: một chuỗi phụ thuộc x = x * c lặp hàng chục triệu lần (mỗi phép cần x mới nhất) — chia thời gian ra ns mỗi phép, bạn được độ trễ. Throughput: tám biến độc lập a0..a7 mỗi cái tự nhân a_j = a_j * c trong cùng vòng — tám chuỗi song song lấp pipeline — chia cho tổng số phép, bạn được 1/thông lượng. Bạn sẽ thấy bản độc lập nhanh khoảng tám lầncùng số phép nhân. Rồi làm lại với phép chia số thực: latency sẽ cao (cả chục chu kỳ), nhưng bản độc lập vẫn nhanh hơn nhiều — cả phép chia cũng chồng được trên lõi hiện đại. Ba mươi giây đó cho bạn thấy điều mà "mỗi lệnh một chi phí" giấu đi: hiệu năng không nằm trong một bảng tra tĩnh, mà trong cách bạn sắp xếp sự phụ thuộc giữa các phép — xâu chuỗi thì trả latency, để độc lập thì trả throughput, chênh nhau cả chục lần.