Hình dung bạn giao cho một nhân viên cực kỳ thực dụng nhiệm vụ tính một bảng số khổng lồ. Anh ta nhận ra một điều: từ trước tới giờ, chưa ai từng hỏi tới kết quả bảng đó — nó tính xong rồi cất vào ngăn kéo và không bao giờ được mở ra. Là người ghét việc vô ích, anh ta lặng lẽ bỏ luôn không tính, dành thời gian cho việc khác, và không ai nhận ra vì kết quả anh ta cất đi đằng nào cũng chẳng ai xem. Trình biên dịch làm đúng anh nhân viên đó, và cái "không ai hỏi kết quả" là thứ có thể biến một phép đo hiệu năng thành lời nói dối hoàn hảo.
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 chính tối ưu này: khử mã chết (dead-code elimination, DCE). Bài này mổ xẻ nó trong container gcc:13 (ARM), 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.
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. Đúng anh nhân viên bỏ luôn không tính cái bảng chẳng ai hỏi. Phiên bản dưới cộng s vào một biến volatile — volatile 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 và đo ra số không.
Cú vấp: đây là cái bẫy nền tảng của mọi benchmark
Chỗ vấp ở đâ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. 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ề đến code hằng ngày
Điều đầ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 mà bài đo lường vi mô đúng cách đã liệt kê.
Điều 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 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ờ.
Và đây là chỗ đáng nhớ nhất, vì "chỉ làm thứ có ai đó quan sát" là một nguyên lý sâu vượt xa trình biên dịch C. JVM làm phiên bản runtime của nó, và đó là lý do đo hiệu năng Java khó hơn C: JIT cũng khử mã chết, nên các thư viện benchmark như JMH phải cung cấp một Blackhole.consume() — đúng vai volatile sink — để lừa JIT rằng kết quả được dùng; quên nó là đo ra số không, y hệt. Rực rỡ nhất là ở tầng dữ liệu, nơi nguyên lý này thành tính năng cố ý: đánh giá lười (lazy evaluation) của Spark và các stream không thực thi gì cho tới khi một "action" thật sự cần kết quả — cả một chuỗi biến đổi có thể bị bỏ nếu không ai đọc đầu ra; và bộ tối ưu SQL cắt bỏ các cột, các join không ảnh hưởng kết quả cuối, đúng như DCE cắt code chết. Mẫu số chung: mọi hệ thống tối ưu đủ thông minh đều chỉ làm phần việc ảnh hưởng tới đầu ra quan sát được — điều tuyệt vời khi nó dọn việc thừa giúp bạn, nhưng là cái bẫy chết người khi bạn đo lường, vì cách duy nhất buộc nó làm thật là làm cho kết quả bị quan sát.
Muốn tự thấy thì viết một vòng 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ó.