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
}

Ảnh chụp đoạn mã Go nền tối minh hoạ dead code elimination trong Go hai tầng, compiler xoá mã không bao giờ chạy hoặc kết quả không ai dùng linker xoá hàm không ai gọi khỏi binary, một DCE trong hàm nhánh chết và dead store const DEBUG bằng false func coNhanhChet x int if DEBUG hằng false cả nhánh bị xoá for i bằng 0 i nhỏ hơn 1000 i cộng cộng x cộng bằng i nhân i y bằng x nhân x nhân x tính nhưng gạch dưới bằng y không ai dùng phép nhân bị xoá return x cộng 1, hai assembly hai hàm ra mã máy y hệt main coNhanhChet STEXT size bằng 16 ADD 1 R0 R0 chỉ còn x cộng 1 RET main khongCoChet STEXT size bằng 16 ADD 1 R0 R0 giống hệt RET không còn lệnh MUL không vòng lặp không nhánh compiler chứng minh DEBUG luôn false và y không ai đọc xoá sạch hàm phức tạp co lại bằng hàm tầm thường, ba DCE mức hàm linker hàm không gọi bị bỏ khỏi binary package lib 200 hàm F0 tới F199 func main fmt Println lib F0 3 chỉ gọi F0 linker bỏ 199 hàm F1 tới F199 khỏi binary linker Go xây đồ thị ai gọi ai từ main giữ hàm chạm tới được bỏ phần còn lại tree shaking tầng thấp, bốn vì sao quan trọng binary nhỏ hơn chỉ trả tiền cho mã thật dùng biên dịch điều kiện const bool cổng nhánh nhánh không dùng biến mất phá benchmark kết quả không dùng bị xoá bài go noinline

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:

Ảnh chụp bảng kết quả đo thật nền tối dead code elimination hai tầng go build gcflags trừ S assembly so kích thước binary Go 1.23 arm64 10 core, DCE trong hàm nhánh chết cộng dead store biến mất hàm coNhanhChet chứa if DEBUG loop 1000 cộng y bằng x nhân x nhân x mã máy size bằng 16 ADD RET hàm khongCoChet chỉ return x cộng 1 mã máy size bằng 16 ADD RET hai hàm ra mã máy y hệt nhau 16 byte chỉ ADD cộng RET không còn lệnh MUL x nhân x nhân x bị xoá không vòng lặp không nhánh compiler chứng minh DEBUG luôn false và y không ai đọc, DCE mức hàm linker hàm không gọi bị bỏ khỏi binary chương trình dùng 1 hàm F0 199 hàm kia không gọi kích thước 2155591 B dùng cả 200 hàm F0 tới F199 kích thước 2303797 B chênh lệch linker bỏ 199 hàm 148206 B khoảng 145 KB khi chỉ gọi F0 linker bỏ hẳn 199 hàm còn lại khỏi binary tiết kiệm khoảng 145 KB tree shaking tầng thấp giữ mã chạm tới được từ main bỏ phần còn lại, cốt lõi DCE trong hàm nhánh chết const false dead store xoá lúc biên dịch DCE mức hàm linker bỏ hàm không ai gọi đo 145 KB xác nhận gcflags trừ S assembly so kích thước binary liên hệ chính DCE này phá benchmark khi bỏ kết quả

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ề

  1. 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ông MUL) — vì compiler chứng minh được chúng không bao giờ ảnh hưởng kết quả.
  2. 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.
  3. DCE là con dao hai lưỡi cần hiểu: nó cho const DEBUG biê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.