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.

Bài tập làm thử

Bài 1 (đọc hiểu). Bài đo được rằng hàm sau và hàm khongCoChet(x int) int { return x + 1 } biên dịch ra mã máy y hệt nhau (cùng size=16, chỉ ADD + RET). Giải thích vì sao, chỉ ra rõ hai lý do (một cho nhánh if, một cho biến y).

const DEBUG = false

func coNhanhChet(x int) int {
	if DEBUG {
		for i := 0; i < 1000; i++ { x += i * i }
	}
	y := x * x * x
	_ = y
	return x + 1
}
Đáp án

Hai lý do: (1) DEBUG là hằng số false, compiler chứng minh được nhánh if DEBUG { ... } không bao giờ chạy nên xoá cả vòng lặp bên trong (nhánh chết). (2) y := x*x*x được tính nhưng gán vào _ — không ai đọc y — nên đây là "dead store", compiler xoá luôn phép nhân. Sau khi xoá cả hai, thân hàm chỉ còn return x + 1, giống hệt khongCoChet.

Bài 2 (phân biệt khái niệm). Bài viết mô tả DCE hoạt động ở hai tầng khác nhau với hai công cụ khác nhau để quan sát. Nêu tên hai tầng đó, công cụ để xem từng tầng, và ví dụ số liệu minh chứng cho tầng thứ hai.

Đáp án

Tầng 1 — DCE trong hàm (compiler): xoá nhánh chết/tính toán không dùng lúc biên dịch một hàm, xem bằng go build -gcflags=-S (assembly). Tầng 2 — DCE mức hàm (linker): linker xây đồ thị "ai gọi ai" từ main, bỏ hẳn hàm không ai gọi khỏi binary — xem bằng cách so kích thước file binary. Ví dụ đo thật: một package có 200 hàm, nếu main chỉ gọi 1 hàm thì binary còn 2.155.591 B; nếu gọi đủ cả 200 hàm thì 2.303.797 B — chênh ~145 KB (148.206 B) đúng bằng phần linker cắt bỏ.

Bài 3 (vận dụng thực tế). Một lập trình viên muốn có log debug chi tiết mà không tốn chi phí gì trong bản build production, và chấp nhận phải build lại mỗi khi muốn bật/tắt. Dựa vào cơ chế DCE trong bài, hãy viết một mẫu code Go thể hiện đúng ý tưởng "biên dịch điều kiện" này.

Đáp án
const DEBUG = false

func xuLy(x int) int {
	if DEBUG {
		fmt.Println("giá trị debug:", x)
	}
	return x * 2
}

Vì DEBUG là hằng const (không phải biến), compiler chứng minh được nhánh if DEBUG không bao giờ chạy khi DEBUG = false và xoá toàn bộ khối đó khỏi mã máy — không tốn cả chi phí kiểm tra if lúc chạy. Đổi DEBUG thành true và build lại thì log xuất hiện. Đánh đổi: muốn bật/tắt lúc chạy (không build lại) thì phải dùng biến thật, chấp nhận chi phí kiểm tra runtime.

Bài 4 (bẫy/đánh đổi). Vì sao dùng interface hoặc reflect.Value.Call để gọi một hàm có thể khiến hàm đó không bị linker loại bỏ dù thực tế không dùng tới, kể cả khi nó "chạm tới được" chỉ trên lý thuyết? Điều này có hệ quả gì tới kích thước binary?

Đáp án

Linker xây đồ thị "ai gọi ai" bằng cách phân tích tĩnh lời gọi trực tiếp từ main. Khi một hàm chỉ được gọi qua interface (dispatch động) hoặc qua reflection, linker không phải lúc nào cũng chứng minh được hàm đó chắc chắn không được gọi ở runtime — vì lời gọi gián tiếp làm mờ đồ thị gọi tĩnh. Do đó nó phải giữ lại hàm để an toàn, kể cả khi thực tế không đường thực thi nào chạm tới. Hệ quả: dùng nhiều interface/reflection khiến binary lớn hơn vì mất một phần lợi ích của DCE mức hàm — đây là một phần "giá" phải trả cho tính linh hoạt đó.

Bài 5 (đọc hiểu — giới hạn của DCE). Đoạn code sau có nhánh if phụ thuộc một biến đọc từ input lúc chạy (không phải hằng số). DCE trong hàm có xoá được nhánh if cauHinh.BatLog này không? Giải thích.

func xuLy2(x int, cauHinh Config) int {
	if cauHinh.BatLog {
		log.Println("x =", x)
	}
	return x * 2
}
Đáp án

Không. DCE chỉ xoá được nhánh mà compiler chứng minh được không bao giờ chạy — tức là điều kiện phải là hằng số biết trước lúc biên dịch (như const DEBUG = false). Ở đây cauHinh.BatLog là giá trị runtime (đọc từ input/cấu hình), có thể true hoặc false tùy lần chạy, nên compiler buộc phải giữ nhánh này và kiểm tra nó mỗi lần gọi. DCE không dọn hộ những logic runtime-dependent mà lập trình viên nghĩ là "thừa".

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.