Suốt hai sê-ri "đo thật", gần như mọi phép đo đều có một biến tên SINK hay volatile mà tôi cộng kết quả vào. Đó không phải thói quen thừa — nó là lá chắn chống lại một tối ưu có thể biến phép đo của bạn thành lời nói dối hoàn hảo. Bài này mổ xẻ chính tối ưu đó: khử mã chết (dead-code elimination, DCE), và đo cái cảnh tượng ngoạn mục nhất của nó — một vòng lặp hai tỉ phép tính đo ra đúng số không.

Khử mã chết

DCE: xóa mọi thứ không ai quan sát

Khử mã chết là một trong những tối ưu cơ bản và đầu tiên nhất của trình biên dịch. Nguyên tắc đơn giản: nếu một tính toán không hề ảnh hưởng tới thế giới bên ngoài — kết quả của nó không được đọc, không được in, không ghi ra I/O, không đổi một biến toàn cục quan sát được — thì trình biên dịch có quyền xóa nó đi. Chương trình vẫn cho ra đúng kết quả quan sát được, chỉ là không phí công tính những thứ vô ích. Với chuẩn C, đây là phép biến đổi hợp lệ: hai chương trình cho cùng hành vi quan sát được thì tương đương, dù một cái làm ít việc hơn.

DCE có ba dạng hay gặp. Tính rồi vứt: một biểu thức hay vòng lặp mà kết quả không ai dùng — bỏ cả phép tính. Dead store (ghi chết): ghi giá trị vào một biến hay ô mảng rồi không bao giờ đọc lại — bỏ lệnh ghi. Mã không tới (unreachable): lệnh nằm sau return, hay trong if(0) — xóa hẳn vì không đường nào chạy tới. Tôi đo cả ba.

Đo: hai tỉ phép tính, không nano giây nào

Tôi viết một hàm chạy vòng lặp hai tỉ lần, mỗi lần làm vài phép nhân, xor, dịch bit — công việc thật, đáng lẽ mất hơn một giây. Nhưng ở phiên bản đầu, tôi không dùng kết quả s sau vòng. Đo:

Vòng 2 tỉ phép tính:
  KHÔNG quan sát kết quả : 0,00000 ns/vòng   (compiler XÓA cả vòng)
  CÓ volatile sink       : 1,36954 ns/vòng   (số thật)

Phiên bản không quan sát kết quả đo ra đúng 0 nano giây một vòng. Không phải CPU nhanh vô hạn — mà là vòng lặp không hề tồn tại trong mã máy. Trình biên dịch nhìn thấy s được tính rồi vứt đi, nên nó xóa toàn bộ vòng; hàm chỉ còn lệnh ret. Kiểm bằng đĩa mã máy xác nhận: hàm rỗng. Phiên bản dưới cộng s vào một biến volatilevolatile buộc trình biên dịch phải thực sự ghi giá trị (nó có thể được đọc bởi phần cứng), nên vòng phải chạy thật, và giờ đo ra 1,37 ns/vòng. Cùng một vòng lặp, cùng một trình biên dịch: khác biệt duy nhất là có "quan sát" kết quả hay không, và nó là khác biệt giữa đo đượcđo ra số không.

Một lần tôi đo hớ: đây là cái bẫy nền tảng của mọi benchmark

Chỗ đo hớ ở đây không phải một lần riêng lẻ — nó là lý do tồn tại của mọi biến SINK trong cả hai sê-ri. Định kiến ngây thơ là: "tôi viết code, kẹp đồng hồ hai đầu, đọc số — thế là đo xong". DCE bác bỏ điều đó tận gốc: nếu code bạn đo không có tác dụng quan sát được, trình biên dịch xóa nó, và bạn đo thời gian của hư vô. Một người viết benchmark "chứng minh thuật toán X nhanh 0,1 ns" mà quên dùng kết quả đã không đo X — họ đo một hàm rỗng, và con số 0,1 ns đẹp đẽ kia là con số của không gì cả.

DCE còn xóa hai dạng nữa, tôi kiểm bằng mã máy. Dead store: một hàm ghi 64 giá trị vào một mảng cục bộ rồi trả về một hằng số mà không đọc mảng — trình biên dịch sinh ra 0 lệnh ghi (str), toàn bộ 64 phép ghi bị xóa vì không ai đọc chúng. Bản đối chứng dùng volatile cho mảng thì giữ nguyên vòng ghi thật. Mã không tới: một hàm có lệnh sau return và một khối if(0) — trình biên dịch xóa hết, hàm rút xuống còn bốn lệnh (chỉ so sánh và trả về). Với code thật, đây là dịch vụ tốt: nó dọn những đoạn chết mà lập trình viên vô tình để lại. Với benchmark, nó là cái bẫy: bạn tưởng đang đo một vòng, mà trình biên dịch đã bốc hơi nó.

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

Hệ quả đầu tiên, và là bài học sống còn của mọi ai muốn đo hiệu năng: luôn "quan sát" kết quả của thứ bạn đo. Có ba cách chuẩn: cộng kết quả vào một biến volatile (như SINK cả sê-ri dùng), in nó ra, hoặc dùng một rào trình biên dịch như __asm__ volatile("" : : "r"(x)) để nói "giá trị này có thể bị dùng, đừng xóa". Thiếu bước này, con số benchmark của bạn vô nghĩa — mà tệ hơn là nó trông có nghĩa, một số nhỏ đẹp khiến bạn tin. Đây chính là cái bẫy đầu tiên trong bốn cái mà bài đo lường vi mô đúng cách đã liệt kê.

Hệ quả thứ hai: hiểu DCE giúp đọc mã máy đúng. Khi bạn dùng gcc -S xem tối ưu của trình biên dịch — như sê-ri này sẽ làm liên tục — và thấy một hàm bạn viết đầy tính toán mà mã máy lại ngắn tủn, đừng ngạc nhiên: rất có thể phần lớn đã bị khử vì không quan sát được. DCE cũng phối hợp với các tối ưu khác: sau khi inline và gấp hằng số, nhiều nhánh trở thành mã chết và bị DCE dọn — đó là lý do inline một hàm với đối số hằng cho mã ngắn gọn đến bất ngờ.

Hệ quả thứ ba là bài học đo lường bao trùm: trình biên dịch không thực thi code bạn viết — nó thực thi hành vi quan sát được của code bạn viết. Con số mang theo: một vòng 2 tỉ phép tính mà kết quả không được dùng đo ra đúng 0,00 ns vì gcc -O2 xóa sạch cả vòng; thêm một volatile sink là số nhảy lên 1,37 ns thật; và DCE cũng xóa dead store (64 phép ghi không đọc → 0 lệnh str) lẫn mã sau return. Trước khi tin bất kỳ phép đo hiệu năng nào — của bạn hay của người khác — hãy hỏi: kết quả có được quan sát không? Nếu không, con số ấy không phải tốc độ của code, mà là tốc độ của sự vắng mặt.

Thử ba mươi giây

Viết một vòng lặp tính toán nặng (ví dụ cộng dồn i*i*i một tỉ lần vào một biến long), kẹp đồng hồ quanh nó, in thời gian — nhưng đừng in hay dùng biến tổng. Biên dịch gcc -O2 và chạy: bạn gần như chắc chắn thấy 0 hoặc một số nhỏ vô lý. Giờ mở mã máy bằng gcc -O2 -S và tìm hàm main — bạn sẽ không thấy vòng lặp đâu cả, nó đã bị xóa. Cuối cùng thêm một dòng printf("%ld", tong) (hay gán tổng vào một biến volatile) và làm lại: con số nhảy lên giá trị thật, và vòng lặp xuất hiện trong mã máy. Ba mươi giây đó cho bạn thấy tận mắt DCE biến một phép đo thành số không — và một dòng "quan sát kết quả" cứu nó.