Ở 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ó, và cơ chế tên là 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.
Đ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,0x — khô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:
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. Không lợi gì. objdump cho thấy tại 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.
Một lần tôi đo hớ: tồn tại khác 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 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.
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 đo lường: một tối ưu "tồn tại 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ì sao điều này quan trọng khi lập trình
Hệ quả đầ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.
Hệ quả 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, 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.
Hệ quả thứ ba là bài học đo lường bao trùm: luôn đo ở đúng cấu hình bạn sẽ ship, và xác minh tối ưu bằng mã sinh ra, đừng tin nó tồn tại là đủ. Con số mang theo: IPCP truyền hằng qua ranh giới hàm; bản clone chuyên biệt (.constprop) — nướng hằng vào để mở đường cho tối ưu như chia-thành-nhân — chỉ bật ở -O3/-fipa-cp-clone, không ở -O2 trơn (nên đo lần đầu ra 1,0×, không lợi gì); bật lên thì hằng k=7 biến sdiv thành nhân ma thuật, nhanh 1,3×; và IPCP chỉ áp khi compiler thấy hằng (cùng file hoặc LTO). Một tối ưu có tồn tại không có nghĩa nó đang chạy — mức tối ưu và ranh giới biên dịch mới quyết định.
Thử ba mươi giây
Viết một hàm 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 và 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.