Bài trước thấy inline là một tối ưu quý — nhanh hơn tới 2,7 lần. Vậy tại sao lại có một directive để chặn nó? Bởi vì chính cái tối ưu đó có thể khiến benchmark của bạn đo ra một con số vô nghĩa: gần bằng 0. Nếu bạn từng viết một benchmark cho kết quả "nhanh đến khó tin", rất có thể compiler đã xoá sạch công việc bạn định đo. Bài này mổ xẻ cạm bẫy đó, cách //go:noinline cứu nó, và các directive compiler khác.

Cạm bẫy: hàm thuần bị inline rồi xoá sạch

Xét một benchmark trông hoàn toàn hợp lý:

func binhphuong(x float64) float64 { return x * x }

func BenchmarkSai(b *testing.B) {
	for n := 0; n < b.N; n++ {
		binhphuong(float64(n))   // kết quả bị BỎ ĐI
	}
}

Vấn đề: binhphuong là hàm thuần (kết quả chỉ phụ thuộc input, không hiệu ứng phụ) và kết quả không được dùng. Sau khi compiler inline nó vào vòng lặp, nó thấy: một phép tính không ai dùng kết quả, không hiệu ứng phụ → dead code, xoá luôn. Benchmark của bạn giờ đo một vòng lặp rỗng.

Ảnh chụp đoạn mã Go nền tối minh hoạ chỉ thị go noinline khi nào chủ động chặn inline, inline thường tốt nhưng đôi khi phá phép đo directive ép compiler giữ ranh giới lời gọi, một cạm bẫy benchmark hàm thuần bị inline rồi xoá sạch func binhphuong x float64 return x nhân x func BenchmarkSai for n bằng 0 n nhỏ hơn b chấm N n cộng cộng binhphuong float64 n kết quả bỏ đi inline cộng thuần cộng kết quả không dùng dead code đo ra khoảng 0 ns sai, hai cách sửa hai lựa chọn cách A go noinline giữ ranh giới lời gọi func binhphuongNI x float64 return x nhân x cách B gán kết quả vào biến sink cấp package var sink float64 func BenchmarkDung var s float64 for n bằng 0 n nhỏ hơn b N n cộng cộng s cộng bằng binhphuong float64 n sink bằng s buộc giữ công việc directive phải nằm ngay dòng trên func không có dòng trống cú pháp go noinline không khoảng trắng sau gạch chéo, ba xác nhận directive có tác dụng gcflags trừ m can inline sq hàm thường sqNI không xuất hiện go noinline đã chặn, bốn vài directive compiler liên quan go noinline chặn inline hàm này benchmark pprof rõ stack gỡ lỗi go nosplit bỏ kiểm tra chia stack chỉ dùng runtime tầng thấp go noescape khai hàm asm không để tham số escape nguy hiểm gcflags trừ l cờ build tắt inline toàn bộ không phải directive directive là hợp đồng với compiler không phải gợi ý dùng sai nosplit noescape có thể gây hỏng bộ nhớ âm thầm

Hình 1: Cạm bẫy benchmark và hai cách sửa. //go:noinline giữ ranh giới lời gọi; biến sink giữ kết quả. Directive phải nằm ngay trên func.

Đo thật: 0,23 ns hay 0,70 ns

Cùng một phép đo, ba cách viết:

Ảnh chụp bảng kết quả đo thật nền tối cạm bẫy inline trong benchmark và cách go noinline cứu go test bench benchtime bằng 1s Go 1.23 arm64 10 core binhphuong x bằng x nhân x, cùng một phép đo ba cách viết SaiInline bỏ kết quả hàm inline 0,2261 ns/op SAI compiler xoá sạch công việc DungNoInline go noinline 0,7047 ns ĐÚNG giữ lời gọi thật DungSink gán vào sink 0,8005 ns ĐÚNG giữ kết quả thật bản SAI đo khoảng 0,23 ns nhanh gấp khoảng 3 lần con số thật khoảng 0,7 ns vì hàm thuần được inline rồi kết quả không dùng nên bị dead-code elimination nếu tin con số này bạn kết luận sai rằng hàm gần như miễn phí, xác nhận directive chặn inline go build gcflags trừ m can inline sq hàm thường sqNI không xuất hiện go noinline đã chặn thành công không có dòng can inline sqNI nghĩa là directive có hiệu lực directive là lệnh cứng cho compiler không phải gợi ý, cốt lõi vấn đề benchmark hàm thuần cộng bỏ kết quả inline xoá công việc đo khoảng 0 go noinline giữ ranh giới lời gọi không xoá được số thật biến sink cách khác giữ kết quả cũng cho số thật đo thật 0,23 ns sai vs 0,70 ns noinline lệch khoảng 3 lần directive hợp đồng cứng với compiler đặt ngay trên func

Hình 2: SaiInline 0,2261 ns (SAI, bị xoá). DungNoInline 0,7047 ns và DungSink 0,8005 ns (đúng). Bản sai nhanh gấp ~3 lần con số thật.

  • SaiInline (bỏ kết quả, hàm inline): 0,2261 ns — compiler xoá phần lớn công việc.
  • DungNoInline (//go:noinline): 0,7047 ns — directive giữ lời gọi, không xoá được.
  • DungSink (gán vào sink): 0,8005 ns — kết quả được giữ.

Bản sai đo nhanh gấp ~3 lần con số thật. Nếu tin nó, bạn kết luận sai rằng hàm "gần như miễn phí" và có thể ra quyết định thiết kế dựa trên số ma này. Đây là lý do //go:noinline (hoặc biến sink) là công cụ cần thiết cho benchmark vi mô đúng đắn.

Xác nhận directive có tác dụng

Directive im lặng nếu gõ sai (ví dụ // go:noinline có khoảng trắng thì chỉ là comment thường, không có tác dụng). Luôn kiểm bằng -gcflags=-m:

go build -gcflags=-m:
  can inline sq            // hàm thường: inline được
  // sqNI KHÔNG xuất hiện   // //go:noinline đã chặn thành công

Không thấy dòng can inline sqNI nghĩa là directive đã có hiệu lực. Cú pháp phải chính xác: //go:noinline (không có khoảng trắng sau //), đặt ngay dòng trên func, không có dòng trống chen giữa.

Ứng dụng thực tế

Benchmark vi mô chính xác. Đây là dùng phổ biến nhất. Khi đo một hàm nhỏ, //go:noinline (hoặc gán kết quả vào biến package-level sink) đảm bảo compiler không tối ưu mất thứ bạn đang đo. Nhiều thư viện benchmark chuẩn dùng cả hai để chắc chắn.

Đọc pprof/stack trace rõ ràng. Hàm được inline biến mất khỏi stack trace — nó hoà vào hàm cha. Nếu bạn cần một hàm hiện riêng trong profile CPU để quy trách nhiệm thời gian, //go:noinline giữ nó tách bạch. Đánh đổi: bạn cố tình làm chậm nó đi một chút để dễ quan sát.

Cô lập khi gỡ lỗi tối ưu. Đôi khi một tối ưu inline gây kết quả lạ (hiếm, thường là bug compiler hoặc tương tác với unsafe). //go:noinline tạm thời giúp khoanh vùng liệu inline có phải thủ phạm.

Đánh đổi cần cân nhắc

Đừng rải //go:noinline trong code production. Nó làm chậm code thật bằng cách tắt một tối ưu quý. Directive này là công cụ cho benchmark, profiling và gỡ lỗi — không phải để "kiểm soát" compiler trong code chạy thật. Để trong production là tự bắn vào chân hiệu năng.

Biến sink thường tốt hơn cho benchmark thật. //go:noinline chặn cả tối ưu xuyên hàm, nên đôi khi đo ra con số cao hơn thực tế (vì code thật của bạn sẽ được inline). Nếu bạn muốn đo hàm như nó chạy thật (được inline), dùng biến sink để giữ kết quả thay vì chặn inline — bạn giữ được tối ưu inline mà vẫn chống dead-code.

Cẩn thận cực độ với //go:nosplit và //go:noescape. Các directive họ hàng này nguy hiểm hơn nhiều: nosplit bỏ kiểm tra chia stack (có thể tràn stack), noescape khai rằng một hàm assembly không để tham số escape (sai là hỏng bộ nhớ âm thầm). Chúng dành cho runtime/thư viện tầng rất thấp, không phải code ứng dụng. //go:noinline là cái vô hại nhất trong nhóm.

Ba ý mang về

  1. Inline có thể làm benchmark đo ra số 0 vô nghĩa: đo thật, benchmark một hàm thuần với kết quả bị bỏ cho 0,23 ns (compiler xoá sạch bằng dead-code elimination) so với 0,70 ns thật — lệch ~3 lần, đủ dẫn tới kết luận sai.
  2. //go:noinline giữ ranh giới lời gọi để đo đúng, và biến sink cấp package là cách thay thế thường tốt hơn (giữ được cả tối ưu inline); xác nhận directive có tác dụng bằng -gcflags=-m — gõ sai cú pháp thì nó im lặng vô hiệu.
  3. Directive là hợp đồng cứng, chỉ dùng cho benchmark/profiling/gỡ lỗi: đừng để //go:noinline trong production (nó tắt tối ưu quý), và tránh xa //go:nosplit///go:noescape trừ khi viết runtime tầng thấp — dùng sai gây hỏng bộ nhớ âm thầm.

Phần sau ta xem một tối ưu compiler khác chạy âm thầm trong mọi truy cập slice/mảng: Phần sau mổ xẻ bounds check elimination — Go chèn kiểm tra biên ở đâu, khi nào compiler xoá được nó, và cách viết vòng lặp để giúp nó xoá.