| name | go-perf-bounds-check-elimination |
| description | Guides bounds-check elimination (BCE) in Go — the compiler checks every slice/array index and the SSA pass removes the ones it proves redundant. Covers seeing surviving checks with go build -gcflags="-d=ssa/check_bce/debug=1", prover-friendly idioms (the _ = b[n-1] / b = b[:n] hint, range loops, constant vs variable offsets), the inlining link, and why -B is a footgun not a fix. Fires on "eliminate bounds checks", "why is this loop slow", "bounds check in hot loop", "optimize this numeric loop". Routes should-I to go-perf-methodology, slices to go-perf-slices. |
| allowed-tools | Read, Glob, Grep |
| compatibility | Claude Code, Codex CLI, Gemini CLI |
Go Bounds-Check Elimination
Go is memory-safe: every index b[i] and most slice expressions b[:n] carry an implicit runtime
check that panics on an out-of-range access. The SSA compiler runs a bounds-check elimination (BCE)
pass that proves many of those checks redundant and deletes them. This skill owns making that
pass visible and writing loops it can satisfy.
Decide whether to bother first. BCE wins are small — each check is one well-predicted
compare-and-branch, worth chasing only in a numeric/byte loop a profile has already proven hot. The
"should I optimize this at all" judgment routes to go-perf-methodology; slice growth/aliasing
to go-perf-slices and go-slices-and-maps.
All Go below builds clean under gofmt/go vet; the check_bce output shown was produced on Go
1.26 (go version go1.26.0).
1. Seeing the Remaining Checks
The compiler's SSA passes accept debug flags via -gcflags. The BCE pass reports every check it
could not eliminate:
go build -gcflags="-d=ssa/check_bce/debug=1" .
It prints one line per surviving check — silence means a fully eliminated expression:
./bce.go:4:3: Found IsInBounds # an index check b[i] that was NOT eliminated
./bce.go:26:7: Found IsSliceInBounds # a slice-expression check b[:n] that survived
IsInBounds is the index check (b[i]); IsSliceInBounds is the slice-expression check
(b[:n], b[lo:hi]). The flag applies to the package you build; add all= to instrument
dependencies. This output is the only trustworthy evidence — never claim a loop is
bounds-check-free without it. (To see the compare+branch a check costs, dump assembly with
go build -gcflags=-S and find the branch into panicIndex.)
2. What the Prover Already Does for Free
The most important measured fact: the canonical loop needs no help. Both of these emit zero
Found IsInBounds lines on Go 1.26 — the bound from the loop condition flows to the index:
func sumIndex(b []byte) int {
n := 0
for i := 0; i < len(b); i++ {
n += int(b[i])
}
return n
}
func sumRange(b []byte) int {
n := 0
for _, v := range b {
n += int(v)
}
return n
}
First rule: don't fight a problem you don't have. Write the obvious
for i := 0; i < len(b); i++ { b[i] } or for i := range b { b[i] }, run the flag, and only reach
for an idiom if a check survives. Adding _ = b[len(b)-1] to these loops changes nothing — they were
already clean.
3. The Hint Idiom — and Exactly How Far It Reaches
Hints matter when you index several positions from one base outside a counted loop. Three writes
to b[i], b[i+1], b[i+2] produce three surviving checks:
func triple(b []byte, i int) {
b[i] = 1
b[i+1] = 2
b[i+2] = 3
}
A single proof up front collapses them. Two forms work — measured to one check total, with the
indexed writes free:
func tripleAssertHigh(b []byte) {
_ = b[2]
b[0] = 1
b[1] = 2
b[2] = 3
}
func tripleConstSlice(b []byte) {
b = b[:3]
b[0] = 1
b[1] = 2
b[2] = 3
}
The critical, measured caveat: the constant-index trick is what makes that work. With a
variable base it does not fully transfer — because the prover cannot establish i >= 0, so
i and i+1 are not bounded below even after asserting b[i+2]:
func tripleVar(b []byte, i int) {
_ = b[i+2]
b[i] = 1
b[i+1] = 2
b[i+2] = 3
}
Asserting b[i+2] only frees the identical expression b[i+2]. The hint is reliable for
constant offsets from a base; for variable offsets, verify with the flag, and prefer
restructuring to a counted loop the prover already handles (§2).
4. Two Slices, One Index
Copying a into b by index checks b[i] every iteration — the range proves a[i] but says
nothing about b:
func copyLoop(a, b []int) {
for i := range a {
b[i] = a[i]
}
}
Cut b to a's length once before the loop — trading a per-iteration index check for a single
slice check, so the loop body goes clean (measured: one IsSliceInBounds, zero IsInBounds):
func copyLoopBCE(a, b []int) {
b = b[:len(a)]
for i := range a {
b[i] = a[i]
}
}
(For a plain copy use copy(b, a); this pattern is for real per-element work on two slices.)
5. How BCE Interacts with Inlining
A bound proven in a caller reaches an index in a callee only if the callee is inlined — across a
real call boundary the compiler re-checks, since it can't assume the caller's invariant. A tiny
helper func at(b []byte, i int) byte { return b[i] } carries its own check at every call site;
inlining it lets the surrounding loop's proof eliminate it. So inlining is frequently the
precondition for elimination. The budget and //go:noinline are owned by go-perf-inlining —
check there first if a check survives only because a helper didn't inline.
6. Why -B Is a Footgun, Not a Fix
-gcflags=-B disables bounds checking globally — build with it and all Found IsInBounds output
disappears because the checks are simply gone. That is not BCE; it removes the safety net. A program
built with -B will read or write out of bounds silently instead of panicking, turning a clean
panic into memory corruption or a security hole. Use -B for exactly one thing: measuring the
ceiling — build both ways, benchmark, and see how much the surviving checks cost. If the gap is
small (it usually is), leave the checks in. Never ship -B. The same caution applies to unsafe to
dodge a check the compiler would have elided anyway — prove it survives with the flag first.
7. Don't
- Don't claim a loop is bounds-check-free without the flag. Run the
check_bce build and read
the output — confidence is not evidence.
- Don't add hints to a canonical counted/range loop.
for i := 0; i < len(b); i++ and
for i := range b are already eliminated on 1.26; _ = b[n-1] there is noise (and a panic on the
empty slice).
- Don't assume the constant-index hint transfers to variable offsets.
_ = b[i+2] does not
free b[i]/b[i+1] — the prover can't bound i below zero (§3).
- Don't ship
-gcflags=-B — it deletes the safety check, not the redundancy; use it only to
measure the ceiling (§6), and don't reach for unsafe to skip a check the flag shows is already
gone (try go-perf-simd if a real win remains).
- Don't reach for BCE before a profile proves the loop hot. The win is one compare+branch per
check; route the "is it worth it" to
go-perf-methodology.
- Don't re-slice inside the loop — it re-introduces a check every iteration; slice once up
front (§4).
8. Routing to Related Skills
This plugin (go-perf-* depth):
go-perf-methodology — the should-I-optimize-this-at-all gate; BCE is a last-mile tweak.
go-perf-inlining — why a check survives across a call boundary; inlining as BCE's precondition.
go-perf-slices — slice growth, reuse, prealloc; re-slicing cost.
go-perf-simd — when scalar BCE isn't enough and the loop wants vectorization.
go-perf-compiler-intrinsics — math/bits and microarchitecture levels for hot numeric code.
Marketplace (go-* correctness):
go-slices-and-maps — slice-is-a-view, aliasing, 3-index slices, len/cap semantics.
go-strings-bytes-runes — []byte/string indexing correctness.
go-performance — the measure-first overview this plugin deepens.
9. Reference Files
Wrong/right BCE anti-patterns from LLM-generated Go, each with measured check_bce output and
citations:
${CLAUDE_SKILL_DIR}/references/common-mistakes.md
Source provenance for every claim in this skill:
${CLAUDE_SKILL_DIR}/references/sources.yaml