Mỗi lần bạn viết xs[i] trong Go, compiler chèn một kiểm tra: i có nằm trong [0, len(xs)) không? Nếu không, panic. Đây là cái giá của an toàn bộ nhớ — Go không cho đọc ngoài biên như C. Nhưng compiler đủ thông minh để xoá kiểm tra đó khi chứng minh được chỉ số luôn hợp lệ — gọi là bounds check elimination (BCE). Bài này đo BCE bằng công cụ thật, và trả lời trung thực một câu ít ai đo: check biên thực sự tốn bao nhiêu, và chiêu "reslice để gợi ý BCE" có đáng không.

Cơ chế: khi nào compiler xoá được check

Compiler xoá kiểm tra biên khi chứng minh được chỉ số hợp lệ. Hai mẫu phổ biến nhất luôn được xoá:

func tongRange(xs []int) int {
	s := 0
	for _, x := range xs { s += x }   // range: 0 check biên
	return s
}
func tongIndex(xs []int) int {
	for i := 0; i < len(xs); i++ { s += xs[i] }  // i<len(xs) chứng minh → 0 check
}

Điều kiện i < len(xs) cho compiler bằng chứng rằng xs[i] luôn trong biên. Ngược lại, truy cập mà compiler không chứng minh được vẫn giữ check:

func lay3(xs []int, i int) int {
	return xs[i] + xs[i+1] + xs[i+2]   // 3 check biên riêng (không biết i, i+1, i+2 hợp lệ)
}

Ảnh chụp đoạn mã Go nền tối minh hoạ bounds check elimination trong Go, mỗi truy cập slice mảng phải kiểm chỉ số trong biên an toàn bộ nhớ compiler xoá check khi chứng minh được chỉ số hợp lệ, một vòng lặp đúng chuẩn 0 check BCE xoá hết func tongRange xs int for underscore x range xs s cộng bằng x range 0 check biên func tongIndex xs int for i bằng 0 i nhỏ hơn len xs i cộng cộng s cộng bằng xs i i nhỏ hơn len xs chứng minh 0 check điều kiện i nhỏ hơn len xs cho compiler bằng chứng chỉ số luôn hợp lệ nên xoá kiểm tra biên trong thân vòng, hai truy cập không chứng minh được còn check func lay3 xs int i int return xs i cộng xs i cộng 1 cộng xs i cộng 2 ba check biên riêng func lay3Toiuu xs int i int xs bằng xs i hai chấm i cộng 3 một check slice rồi return xs 0 cộng xs 1 cộng xs 2 ba truy cập miễn check, ba xem check ở đâu cờ debug SSA go build gcflags trừ d bằng ssa check_bce debug bằng 1 lay3 dòng 19 Found IsInBounds nhân 3 lay3Toiuu dòng 24 Found IsSliceInBounds nhân 1 tongRange tongIndex không dòng nào đã xoá sạch không có dòng Found nghĩa là hàm đó không còn kiểm tra biên nào, bốn nhưng chi phí thật của một check rất nhỏ check biên bằng CMP cộng nhánh CPU dự đoán gần như hoàn hảo luôn đúng biên trên CPU hiện đại nhánh luôn trong biên được dự đoán chính xác nên gần như miễn phí xoá check giúp ích ở vòng cực nóng nhưng đừng vặn vẹo code vì nó mà chưa đo

Hình 1: Vòng range và i < len(xs) được BCE xoá hết check; lay3 giữ 3 check; reslice xs[i:i+3] gộp còn 1 check. Xem bằng cờ debug SSA.

Xem check ở đâu: cờ debug SSA

Go có cờ để in chính xác truy cập nào còn giữ check:

go build -gcflags=-d=ssa/check_bce/debug=1
  lay3:19        Found IsInBounds x3        // 3 check còn lại
  lay3Toiuu:24   Found IsSliceInBounds x1   // reslice: chỉ 1
  tongRange / tongIndex: (không dòng nào)    // đã xoá sạch

Không có dòng Found nghĩa là hàm đó không còn kiểm tra biên. Đây là công cụ chuẩn để kiểm tra một vòng lặp nóng có bị check thừa không.

Đo thật: check tốn bao nhiêu, reslice có đáng?

Đây là phần ít ai đo và cho kết quả bất ngờ. Ta so lay3 (3 check) với lay3Toiuu (reslice xs[i:i+3] còn 1 check), và so mặc định với -gcflags=-B (tắt hẳn kiểm biên) để lộ chi phí thuần:

Ảnh chụp bảng kết quả đo thật nền tối bounds check tốn bao nhiêu và chiêu reslice có đáng go test bench so mặc định với gcflags trừ B tắt hẳn kiểm biên Go 1.23 arm64 10 core, lay3 3 check vs reslice 1 check data 1000 phần tử Lay3Check 3 check biên mặc định có check 0,759 ns trừ B không check 0,606 ns Lay3Toiuu reslice 1 check mặc định 0,93 ns trừ B 0,816 ns bất ngờ chiêu reslice chậm hơn 0,93 so 0,759 ns bản thân xs i hai chấm i cộng 3 tính header slice mới tốn hơn 3 check biên vốn đã rẻ chênh mặc định so với trừ B chỉ khoảng 0,15 ns cho 3 check khoảng 0,05 ns mỗi check, vòng tổng lớn 10000 phần tử default xấp xỉ trừ B SumRange for range mặc định 2320 ns trừ B 2350 ns SumIdx i nhỏ hơn len xs mặc định 2320 ns trừ B 2360 ns debug SSA xác nhận cả hai vòng đã 0 check sẵn BCE xoá hết nên trừ B không giúp gì không có check nào để bỏ đây là cách viết đúng range hoặc i nhỏ hơn len xs, cốt lõi trung thực BCE có thật range i nhỏ hơn len xs 0 check xác nhận bằng cờ debug check còn lại truy cập không chứng minh được lay3 3 check chi phí thật rất nhỏ khoảng 0,05 ns mỗi check CPU dự đoán tốt chiêu reslice có thể phản tác dụng đo trước khi dùng lời khuyên viết vòng lặp rõ ràng để compiler tự xoá

Hình 2: Lay3Check (3 check) 0,759 ns so với Lay3Toiuu (reslice) 0,93 ns — chiêu reslice chậm hơn. Vòng tổng lớn: mặc định ≈ -B (~2.320 ns) vì đã 0 check sẵn.

Kết quả trung thực:

  • lay3 3 check: 0,759 ns. lay3Toiuu reslice 1 check: 0,93 ns — chậm hơn! Bản thân xs[i:i+3] phải tính một slice header mới, tốn hơn cả 3 check biên vốn đã rẻ.
  • Chi phí thuần của check: so mặc định (0,759) với -B (0,606) → ~0,15 ns cho 3 check, tức ~0,05 ns mỗi check. Rất nhỏ, vì CPU dự đoán nhánh "luôn trong biên" gần như hoàn hảo.
  • Vòng tổng lớn (10.000 phần tử): SumRange và SumIdx cho mặc định ≈ -B (~2.320 ns). Cờ debug xác nhận cả hai đã 0 check sẵn — nên tắt kiểm biên không giúp gì, không có check nào để bỏ.

Bài học trung thực: BCE là thật và kiểm chứng được, nhưng chi phí một check biên trên CPU hiện đại rất nhỏ, và các chiêu vi tối ưu như reslice có thể phản tác dụng.

Ứng dụng thực tế

Viết vòng lặp rõ ràng — compiler tự xoá check. Dùng for _, x := range xs hoặc for i := 0; i < len(xs); i++. Cả hai được BCE xoá sạch check mà không cần bạn làm gì. Đây là 95% trường hợp — code rõ ràng đã là code nhanh.

Kiểm vòng nóng bằng cờ debug, đừng đoán. Nếu một vòng lặp thật sự nóng (pprof chỉ ra), chạy -gcflags=-d=ssa/check_bce/debug=1 xem nó còn check không. Nếu có và bạn muốn xoá, thử tái cấu trúc — nhưng đo lại để chắc chắn có lợi.

Gán len ra biến giúp compiler trong vài mẫu. Đôi khi truy cập nhiều slice trong một vòng, gán độ dài chung ra biến và kiểm nó trước có thể giúp BCE. Nhưng như đo trên cho thấy, lợi ích thường nhỏ — chỉ đáng ở vòng xử lý hàng tỷ phần tử.

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

Chi phí check nhỏ, nhưng không phải luôn bằng 0. Ở vòng lặp cực nóng xử lý khối lượng dữ liệu khổng lồ (mã hóa, nén, xử lý ảnh), tổng chi phí check có thể tích lũy đáng kể. Ở đó BCE quan trọng — nhưng cách tốt nhất vẫn là viết code để compiler tự xoá, không phải chiêu trò.

Đừng dùng -gcflags=-B trong production. Tắt kiểm biên bỏ đi lưới an toàn bộ nhớ của Go — một chỉ số sai giờ đọc/ghi bộ nhớ tùy tiện thay vì panic sạch sẽ. Cờ này chỉ để đo chi phí check, không bao giờ để build thật.

Reslice và các chiêu BCE là con dao hai lưỡi. Như đo được, xs[i:i+3] thêm chi phí tạo slice header có thể lớn hơn phần check tiết kiệm được. Mọi vi tối ưu BCE phải đo trước-sau; nhiều "mẹo" lan truyền trên mạng đã lỗi thời vì compiler Go ngày càng giỏi BCE hơn.

Ba ý mang về

  1. BCE là thật và kiểm chứng được: vòng range và i < len(xs) được xoá sạch check (xác nhận bằng -gcflags=-d=ssa/check_bce/debug=1); truy cập không chứng minh được như lay3 giữ 3 check.
  2. Chi phí một check biên rất nhỏ trên CPU hiện đại: đo thật ~0,05 ns mỗi check (so mặc định với -B), vì nhánh "luôn trong biên" được dự đoán gần như hoàn hảo — và vòng tổng lớn cho mặc định ≈ -B vì đã 0 check sẵn.
  3. Chiêu reslice để gợi ý BCE có thể phản tác dụng: đo thật lay3Toiuu (reslice, 1 check) chậm hơn lay3 (3 check) vì tạo slice header tốn hơn — viết vòng lặp rõ ràng để compiler tự xoá, và đo trước-sau mọi vi tối ưu BCE.

Phần sau ta xem một tối ưu compiler họ hàng cũng chạy âm thầm: Phần sau mổ xẻ dead code elimination — cách compiler nhận ra và xoá mã không bao giờ chạy, vì sao nó phá benchmark (như bài //go:noinline đã chạm tới), và giới hạn của nó.