Tưởng tượng một xưởng may có dịch vụ "may đo riêng" ghi rõ trong tờ quảng cáo. Bạn là khách quen, tuần nào cũng đặt đúng một kiểu áo, đúng một số đo — về lý họ thừa sức cắt sẵn một cái rập riêng cho bạn để may nhanh hơn hẳn. Nhưng khi bạn tới, họ vẫn đưa ra cái áo cỡ chung, đo lại từ đầu mỗi lần. Dịch vụ may đo có ghi trong quảng cáo, chỉ là nó chỉ được kích hoạt cho khách gói cao cấp. Compiler có một tính năng đúng như vậy, và tôi đã tưởng nó luôn bật.
Ở gập hằng và lan truyền hằng, compiler thay các biểu thức hằng bằng kết quả bên trong một hàm. Nhưng nếu một hàm luôn được gọi với cùng một hằng số từ bên ngoài thì sao — compiler có nhìn xuyên qua ranh giới hàm để dùng hằng đó không? Câu trả lời là có, cơ chế tên IPCP (interprocedural constant propagation — lan truyền hằng liên thủ tục). Tôi vào đo nó, và vấp đúng một chuyện: một tối ưu tồn tại không có nghĩa nó đang chạy.
IPCP: truyền hằng qua ranh giới hàm
Khi compiler thấy một hàm static (nội bộ) luôn được gọi với cùng một hằng số ở một tham số, nó có thể truyền hằng đó vào thân hàm và tối ưu theo: gập các biểu thức phụ thuộc tham số đó, loại bỏ nhánh chết (nhánh mà với hằng này không bao giờ chạy), và — mạnh nhất — sinh một bản clone chuyên biệt của hàm, tên kiểu process.constprop.0, trong đó hằng đã được "nướng" sẵn. Đúng cái rập may đo riêng.
Điều này khác nội tuyến hàm: nội tuyến chép cả thân hàm vào chỗ gọi (bỏ luôn lời gọi). IPCP thì giữ lời gọi, nhưng gọi tới một phiên bản chuyên biệt đã biết hằng — hữu ích cho hàm quá lớn để nội tuyến. Điều kiện tiên quyết: compiler phải thấy được hằng. Nếu hàm nằm ở file khác biên dịch riêng (mà không bật LTO), hoặc được gọi qua con trỏ hàm động, thì compiler không biết hằng và IPCP không áp được.
Đo: lần đầu ra 1,0× — chẳng lợi gì
Tôi dựng một hàm static chia mỗi phần tử cho một tham số k, gọi từ hai nơi: một nơi truyền hằng k=7, một nơi truyền k đọc từ biến volatile (động, compiler không thấy giá trị). Nếu IPCP hoạt động, bản k=7 sẽ được chuyên biệt hóa, và vì k giờ là hằng, phép chia biến thành nhân ma thuật (magic multiply — chia cho hằng số đã đo) thay cho lệnh chia thật đắt tiền.
Đo trong container gcc:13 ở -O2, kết quả làm tôi khựng lại:
k hằng số (7) : 0,4564 ns/phần tử
k động (volatile) : 0,4565 ns/phần tử (1,0×)
Bằng nhau. Chẳng lợi gì. objdump cho thấy vì sao: chỉ có một hàm process duy nhất, dùng lệnh chia thật sdiv với k trong thanh ghi. Không có bản .constprop nào. Cả hai nơi gọi — kể cả nơi truyền hằng 7 — đều đi vào cùng cái process chung với sdiv. Tôi tưởng mình viết sai, hoặc IPCP hỏng.
Tôi lẫn "có trong compiler" với "đang chạy"
Tôi vào đo với niềm tin: "compiler tự động chuyên biệt bất kỳ hàm nào được gọi với hằng số, nên tôi chẳng cần bận tâm". Con số 1,0× nói tôi đã tin sai một điều rất tinh vi.
Sự thật là: ở -O2, IPCP có làm phân tích lan truyền hằng, nhưng việc sinh bản clone chuyên biệt (.constprop) là một tối ưu riêng, tên -fipa-cp-clone, và nó chỉ bật mặc định ở -O3, không ở -O2. Nói cách khác, ở -O2 compiler "biết" k=7 ở nơi gọi đó, nhưng nó không tạo ra một bản hàm riêng để tận dụng — nên process vẫn là bản chung với sdiv, và cái lợi tôi mong đợi không bao giờ thành hình. Đúng cái xưởng may không cắt rập riêng cho khách gói thường.
Bật cloning lên — -O2 -fipa-cp-clone, hoặc đơn giản -O3 — mọi thứ đổi khác:
-O3, k hằng (process.constprop) : 0,3503 ns/phần tử (nhân ma thuật ÷7)
-O3, k động (process generic) : 0,4598 ns/phần tử (sdiv thật)
-> nhanh 1,3 lần
Giờ objdump cho thấy đúng hai bản: process.constprop.0 (và .1) dùng smull/movk 0x9249/asr — chính là công thức nhân ma thuật cho phép chia cho 7, không có sdiv; còn process chung vẫn giữ sdiv cho nơi gọi động. Hằng k=7 được truyền qua ranh giới hàm vào bản clone, và ở đó phép chia được strength reduction thành nhân. IPCP không tự nó làm phép nhân — nó mở đường cho tối ưu khác bằng cách biến k thành hằng compile-time.
Bài học đọng lại: một tối ưu "có trong compiler" không đồng nghĩa với "đang chạy ở cấu hình của bạn". Mức -O và các cờ quyết định tối ưu nào bật. Nếu tôi chỉ đọc tài liệu ("gcc có IPCP") rồi kết luận nó luôn giúp, tôi đã sai; đo ra mới thấy nó im lặng ở -O2 và chỉ sống dậy ở -O3. Và ngay cả khi bật, nó chỉ áp khi compiler thấy hằng — cùng đơn vị biên dịch hoặc LTO; gọi động hay tách file không LTO thì hằng vô hình, clone không sinh ra.
Về đến code hằng ngày
Điều đầu tiên: đừng vội tự tay chuyên biệt hóa hàm — nhưng cũng đừng cho rằng compiler luôn làm hộ. Với hàm static gọi trong cùng file bằng hằng số, -O3 sẽ tạo bản clone và tận dụng hằng; bạn không cần viết tay hai phiên bản. Nhưng ở -O2, hoặc khi hàm ở thư viện khác không LTO, cái tự động đó không xảy ra — lúc đó việc chuyên biệt hóa bằng tay (hoặc bật LTO/-O3) mới đáng.
Điều thứ hai: biết cờ nào bật tối ưu nào. IPCP cloning nằm ở -O3/-fipa-cp-clone; nội tuyến và IPCP thấy hằng cần hàm static hoặc LTO. Khi một tối ưu bạn trông đợi "không chạy", câu hỏi đầu tiên là mức -O và ranh giới biên dịch, không phải code. Dùng -fdump-ipa-cp hay đọc objdump tìm .constprop để xác nhận nó có thật sự xảy ra.
Và cái nguyên tắc "chuyên biệt hóa theo giá trị hay gặp" này hóa ra là trái tim của rất nhiều thứ nhanh ở các ngôn ngữ khác — chỉ khác là chúng làm lúc chạy. JVM là ví dụ đẹp nhất: JIT theo dõi một callsite, thấy nó gần như luôn gọi cùng một kiểu cụ thể thì nó chuyên biệt hóa chỗ đó (monomorphic inline cache), thậm chí inline luôn — và deoptimize nếu giả định sai. V8 của JavaScript làm y hệt với "hidden class". Còn CSDL thì có prepared statement với generic plan vs custom plan: Postgres có thể tạo một kế hoạch riêng theo đúng tham số bạn truyền, hoặc dùng một kế hoạch chung tái sử dụng — và nó quyết định dựa trên chi phí, đúng kiểu compiler cân nhắc có nên clone hay không. Mẫu số chung: chuyên biệt hóa theo hằng/giá trị hay gặp là một trong những đòn tối ưu mạnh nhất ở mọi tầng — nhưng nó luôn có điều kiện kích hoạt, và biết điều kiện đó là biết vì sao đôi khi nó không xảy ra.
Muốn tự thấy thì viết static long f(int k, const int* a, int n){ long s=0; for(int i=0;i<n;i++) s+=a[i]/k; return s; } và gọi nó ở một chỗ với f(7, ...). Biên dịch gcc -O2 -S rồi tìm trong assembly: bạn sẽ thấy một lệnh sdiv — phép chia thật, hằng 7 chưa được tận dụng. Giờ đổi sang gcc -O3 -S (hoặc thêm -fipa-cp-clone) và tìm lại: xuất hiện một hàm f.constprop.0 không còn sdiv mà là smull cộng dịch bit — nhân ma thuật cho phép chia 7. Ba mươi giây đó cho bạn thấy cùng một dòng code sinh mã khác hẳn chỉ vì đổi mức tối ưu, và vì sao "đo ở đúng cấu hình" không phải lời khuyên thừa.