Suốt mấy phần vừa rồi, cùng một phép toán cứ 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 3 và phần 10 lại cho thấy lõi làm nhiều phép mỗi chu kỳ. Thoạt nhìn mâu thuẫn, nhưng không phải — vì mỗi lệnh có hai chi phí chứ không phải một.

Hình dung một tiệm giặt là. Hỏi "giặt một mẻ mất bao lâu?" có hai câu trả lời đúng mà khác hẳn nhau. Một mẻ từ lúc bỏ vào tới lúc lấy ra mất 90 phút — đó là độ trễ, và nó quan trọng nếu bạn phải chờ mẻ này xong mới giặt mẻ sau (đồ mẻ sau nhuộm từ nước mẻ trước chẳng hạn). Nhưng nếu tiệm có 8 máy chạy song song, thì cứ mỗi ~11 phút lại có một mẻ xong — đó là thông lượng, và nó mới là con số bạn cần khi có cả đống đồ độc lập cần giặt. CPU y hệt: mỗi lệnh có độ trễ (latency) và thông lượng (throughput), và chúng chênh nhau bất ngờ. Tôi đo cả hai cùng lúc cho từng phép trong container gcc:13 trên host ARM, kèm một bất ngờ về phép chia.

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. (Chờ mẻ giặt xong mới giặt mẻ tiếp.)

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. (Tám máy giặt chạy song song.)

Để đ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. Con số nói ngược: fdiv độ trễ 10,7 ck nhưng thông lượng chỉ 1,3 ck — cả phép chia cũng chồng được ~8 lần. Lõi Apple này giữ được khoảng 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ất ở cả 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" hóa ra là một huyền thoại nữa của CPU đời cũ mà tôi lỡ mang theo.

Ba niềm tin bị đo cho gãy

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". Con số cho thấy 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 và throughput riêng.

Rồi hai niềm tin nữa cũng gãy. 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 đều đến từ việc nhìn nhầm con số hoặc tin 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.

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

Việc đầ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.

Việc 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ả 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.

Cái phân biệt latency/throughput này không phải chuyện riêng của C — nó là lăng kính để nhìn hiệu năng ở mọi tầng, chỉ đổi đơn vị. Một request tới database: latency là thời gian một truy vấn trả về, throughput là số truy vấn/giây cả hệ thống chịu được — và tối ưu cho cái này thường không giúp cái kia, một lý do connection pool và xử lý theo lô tồn tại. Trong lập trình bất đồng bộ (async/await Node, Go, hệ actor), cả mô hình là để tách phụ thuộc: trong khi một request đang chờ I/O (latency cao), CPU phục vụ request khác, đẩy throughput lên — đúng tinh thần "8 máy giặt song song" ở tầng ứng dụng. Ngay việc benchmark một service cũng phải hỏi câu này: đo p99 latency (một yêu cầu mất bao lâu) hay đo QPS tối đa (chịu được bao nhiêu/giây)? Hai con số, hai cách tối ưu — lẫn lộn chúng là nguồn của vô số quyết định sai.

Muốn tự thấy thì 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 độc lập nhanh khoảng tám lần dù cùng số phép nhân. Rồi làm lại với phép chia số thực: latency 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 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.