Bài //go:noinline đã chạm tới một hiện tượng: compiler xoá công việc mà không ai dùng kết quả, khiến benchmark đo ra số 0. Đó chính là dead code elimination (DCE) — một trong những tối ưu cơ bản nhất. Nhưng DCE ở Go hoạt động ở hai tầng khác nhau với hai công cụ khác nhau. Bài này mổ xẻ cả hai bằng bằng chứng thật: assembly cho tầng compiler, và kích thước binary cho tầng linker.
Tầng 1: DCE trong hàm (compiler)
Compiler phân tích luồng dữ liệu trong mỗi hàm và xoá hai loại "mã chết": nhánh không bao giờ chạy (điều kiện luôn false) và tính toán không ai dùng (dead store). Xét hàm này:
const DEBUG = false
func coNhanhChet(x int) int {
if DEBUG { // hằng false → cả nhánh bị xoá
for i := 0; i < 1000; i++ { x += i * i }
}
y := x * x * x // tính nhưng...
_ = y // ...không ai dùng → phép nhân bị xoá
return x + 1
}

Hình 1: DCE hai tầng. Trong hàm: nhánh if DEBUG và phép nhân dư bị xoá. Mức hàm: linker bỏ hàm không ai gọi. Cả hai co binary lại.
Bằng chứng: hai hàm ra mã máy y hệt
So coNhanhChet (có nhánh chết + dead store) với khongCoChet (chỉ return x+1) bằng -gcflags=-S:
main.coNhanhChet STEXT size=16 main.khongCoChet STEXT size=16
ADD $1, R0, R0 ADD $1, R0, R0
RET RET
Mã máy y hệt nhau — cùng 16 byte, cùng hai lệnh ADD + RET. Không còn lệnh MUL (phép nhân x*x*x bị xoá), không vòng lặp 1000 vòng, không nhánh. Compiler chứng minh DEBUG luôn false nên cả nhánh if là mã chết, và y không ai đọc nên phép tính tạo ra nó là vô ích. Kết quả: một hàm trông phức tạp co lại đúng bằng hàm tầm thường nhất.
Tầng 2: DCE mức hàm (linker)
Tầng thứ hai hoạt động lúc liên kết: linker xây đồ thị "ai gọi ai" bắt đầu từ main, giữ mọi hàm chạm tới được, và bỏ hẳn phần còn lại khỏi binary. Đây là "tree shaking" tầng thấp. Đo thật với một package lib chứa 200 hàm lớn F0..F199:

Hình 2: Trong hàm — hai hàm ra mã máy y hệt (size=16). Mức hàm — dùng 1 hàm cho binary 2,155 MB, dùng cả 200 hàm cho 2,303 MB; linker bỏ 199 hàm không dùng, cắt ~145 KB.
- Dùng 1 hàm (
F0, 199 hàm kia không gọi): binary 2.155.591 B. - Dùng cả 200 hàm: 2.303.797 B.
- Chênh lệch: 148.206 B (~145 KB) — chính là 199 hàm bị linker bỏ khi không ai gọi.
Đây là lý do binary Go tuy "béo" (runtime + GC nhúng sẵn) nhưng không phình theo mọi hàm trong mọi thư viện bạn import — chỉ phần thực sự dùng mới vào binary.
Ứng dụng thực tế
Cờ debug bằng hằng const biên dịch điều kiện không tốn gì. Mẫu const DEBUG = false + if DEBUG { log... } cho bạn code debug mà khi tắt thì biến mất hoàn toàn khỏi binary — không phải kiểm if lúc chạy. Đây là cách "conditional compilation" của Go, dùng hằng thay vì macro tiền xử lý.
Import package lớn không tự động làm binary phình. Nhờ linker DCE, import net/http nhưng chỉ dùng một hàm sẽ không kéo cả package vào — chỉ phần chạm tới được. Đừng ngại import thư viện lớn vì sợ kích thước, trừ khi bạn thực sự dùng phần lớn nó.
DCE giải thích cạm bẫy benchmark. Như bài //go:noinline, chính DCE xoá công việc khi kết quả không dùng. Hiểu DCE giúp bạn viết benchmark đúng: luôn cho compiler thấy kết quả được dùng (biến sink) để nó không xoá thứ bạn đang đo.
Đánh đổi cần cân nhắc
Interface/reflection chặn DCE mức hàm. Nếu một hàm chỉ được gọi qua interface hoặc qua reflection (reflect.Value.Call), linker không phải lúc nào cũng chứng minh được nó không dùng, nên phải giữ. Đây là một lý do reflection làm binary lớn hơn — nó làm mờ đồ thị "ai gọi ai". Dùng interface/reflection có cái giá kích thước.
DCE không cứu được logic thừa mà compiler không chứng minh được là chết. Nếu điều kiện phụ thuộc biến runtime (không phải hằng), compiler không xoá nhánh — nó phải giữ vì có thể chạy. DCE chỉ xoá cái chứng minh được không bao giờ chạy; đừng trông đợi nó dọn dẹp code thừa mà bạn nên tự xoá.
Đừng lạm dụng const DEBUG toàn cục. Tuy tiện, một cờ hằng biên dịch nghĩa là muốn bật debug phải build lại. Với log cần bật/tắt lúc chạy, dùng biến thật (chấp nhận chi phí kiểm if) hoặc thư viện log có cấp độ. Chọn theo nhu cầu: hằng cho cái không đổi trong một bản build, biến cho cái điều chỉnh lúc chạy.
Ba ý mang về
- DCE trong hàm xoá nhánh chết và tính toán không dùng lúc biên dịch: đo thật, một hàm có
if DEBUG(loop 1000) và phép nhân dư biên dịch ra mã máy y hệt hàm chỉreturn x+1(cùng size=16, chỉADD+RET, khôngMUL) — vì compiler chứng minh được chúng không bao giờ ảnh hưởng kết quả. - DCE mức hàm để linker bỏ hàm không ai gọi khỏi binary: đo thật dùng 1 trong 200 hàm cắt ~145 KB so với dùng cả 200 — "tree shaking" giữ mã chạm tới được từ
main, nên import package lớn không tự làm binary phình. - DCE là con dao hai lưỡi cần hiểu: nó cho
const DEBUGbiên dịch điều kiện miễn phí và giải thích cạm bẫy benchmark, nhưng interface/reflection chặn nó (làm binary lớn) và nó chỉ xoá cái chứng minh được là chết, không dọn hộ code thừa của bạn.
Phần sau ta xem một tối ưu compiler tinh vi hơn liên quan tới interface: Phần sau mổ xẻ devirtualization — cách compiler đôi khi biến một lời gọi qua interface (dispatch động) thành lời gọi trực tiếp inline được, và điều kiện để nó xảy ra.