the question
Two methods that sum an int[]. The first takes the length from the array; the second takes it
as a parameter, the way a method that works on the first n elements of a buffer usually does.
Both loops read every element with a[i], and every a[i] in C# is checked — the runtime must
throw IndexOutOfRangeException before an out-of-range read happens, and the IL instruction
that does the read (ldelem.i4) is defined to check. Roslyn cannot remove that check; it has
no way to express an unchecked array read in IL. Whatever happens to it happens later, in the
JIT.
predict first
Both loops compile down through the same JIT, on the same array. Predict: does the compiled
loop for ByLength still contain a bounds check? Does the compiled loop for ByCount? And
for whichever one still has to check something at run time — does it check once, or once per
element?
the code
public static int ByLength(int[] a)
{
int s = 0;
for (int i = 0; i < a.Length; i++) s += a[i];
return s;
}
public static int ByCount(int[] a, int n)
{
int s = 0;
for (int i = 0; i < n; i++) s += a[i];
return s;
}work it out
Here is the IL for both methods, decoded out of the built assembly by
bench/il-jit-codegen/il-dump.cs — the two are identical apart from where the loop bound comes
from:
.method ByLength // 24 bytes of IL .method ByCount // 22 bytes of IL
IL_0000: ldc.i4.0 IL_0000: ldc.i4.0
IL_0001: stloc.0 IL_0001: stloc.0
IL_0002: ldc.i4.0 IL_0002: ldc.i4.0
IL_0003: stloc.1 IL_0003: stloc.1
IL_0004: br.s IL_0010 IL_0004: br.s IL_0010
IL_0006: ldloc.0 IL_0006: ldloc.0
IL_0007: ldarg.0 IL_0007: ldarg.0
IL_0008: ldloc.1 IL_0008: ldloc.1
IL_0009: ldelem.i4 ← the checked read IL_0009: ldelem.i4 ← the same read
IL_000a: add IL_000a: add
IL_000b: stloc.0 IL_000b: stloc.0
IL_000c: ldloc.1 IL_000c: ldloc.1
IL_000d: ldc.i4.1 IL_000d: ldc.i4.1
IL_000e: add IL_000e: add
IL_000f: stloc.1 IL_000f: stloc.1
IL_0010: ldloc.1 IL_0010: ldloc.1
IL_0011: ldarg.0 IL_0011: ldarg.1 ← n, instead of
IL_0012: ldlen ← a.Length a.Length
IL_0013: conv.i4 IL_0012: blt.s IL_0006
IL_0014: blt.s IL_0006 IL_0014: ldloc.0
IL_0016: ldloc.0 IL_0015: ret
IL_0017: ret
Now walk the proof the JIT has to construct, not the code, before looking at what it emitted.
For any a[i], it needs two facts: i >= 0 and i < a.Length.
ByLength. i starts at 0 and only ever increases, so i >= 0 is free. The loop’s own
exit condition is i < a.Length — the same comparison the check needs is already sitting
right there, run every trip, one instruction before the read. Array length is immutable in
.NET once an array exists, so nothing inside the loop body can invalidate that fact between the
comparison and the read. Every fact the check needs is already proven by code that already
runs. The check is dead weight and the JIT can delete it.
ByCount. The loop’s exit condition is i < n. n is just an int parameter — nothing in
the method signature, and nothing the JIT can see at this call site, connects it to
a.Length. The relation might hold on every call a real program makes, but “might hold on every
call I’ve seen” is not a proof, and the JIT does not guess. It cannot delete the check without
a proof. What it can do is test the one relation it actually needs — a.Length >= n — a
single time, before the loop starts, and keep two copies of the loop body: one that trusts the
test and runs unchecked, one that does not and checks every element. That is loop cloning.
the answer
Real output, unedited, from
DOTNET_JitDisasm="ByLength ByCount" dotnet run bench/il-jit-codegen/bounds-check-shapes.cs -c Release.
Both listings are the tier-1 versions — the fully optimised code each method settles into once
the runtime decides it is hot.
; Assembly listing for method B:ByLength(int[]):int (Tier1)
; optimized code
; with Dynamic PGO: fgCalledCount is 5813
G_M000_IG01: ;; offset=0x0000
push rbp
mov rbp, rsp
G_M000_IG02: ;; offset=0x0004
xor eax, eax
mov ecx, dword ptr [rdi+0x08]
test ecx, ecx
jle SHORT G_M000_IG05
G_M000_IG03: ;; offset=0x000D
add rdi, 16
G_M000_IG04: ;; offset=0x0011
add eax, dword ptr [rdi]
add rdi, 4
dec ecx
jne SHORT G_M000_IG04
G_M000_IG05: ;; offset=0x001B
pop rbp
ret
; Total bytes of code 29
Four instructions in the loop, and not one of them is a comparison against the length — exactly what the reasoning above predicted.
; Assembly listing for method B:ByCount(int[],int):int (Tier1)
; optimized code
; with Synthesized PGO: fgCalledCount is 5824
G_M000_IG01: ;; offset=0x0000
push rbp
mov rbp, rsp
G_M000_IG02: ;; offset=0x0004
xor eax, eax
xor ecx, ecx
test esi, esi
jle SHORT G_M000_IG07
G_M000_IG03: ;; offset=0x000C
test rdi, rdi
je SHORT G_M000_IG08
G_M000_IG04: ;; offset=0x0011
mov edx, dword ptr [rdi+0x08]
cmp edx, esi
jl SHORT G_M000_IG08
G_M000_IG05: ;; offset=0x0018
add rdi, 16
G_M000_IG06: ;; offset=0x0020
add eax, dword ptr [rdi]
add rdi, 4
dec esi
jne SHORT G_M000_IG06
G_M000_IG07: ;; offset=0x002A
pop rbp
ret
G_M000_IG08: ;; offset=0x002C
mov edx, dword ptr [rdi+0x08]
G_M000_IG09: ;; offset=0x0030
cmp ecx, edx
jae SHORT G_M000_IG10
mov r8d, ecx
add eax, dword ptr [rdi+4*r8+0x10]
inc ecx
cmp ecx, esi
jl SHORT G_M000_IG09
jmp SHORT G_M000_IG07
G_M000_IG10: ;; offset=0x0044
call CORINFO_HELP_RNGCHKFAIL
int3
; Total bytes of code 74
Read that second listing structurally and the shape is the answer: there are two loops in
it. IG06 is the same four-instruction loop as ByLength, no check. IG09 is a
seven-instruction loop with a check in it, entered only on the path that could not be proven
safe. Which one runs is decided once, at IG03–IG04, before either loop starts — not once
per element, once per call.
| block | instructions | what it is |
|---|---|---|
IG02 |
test esi, esi / jle |
is n zero or negative? then return 0 |
IG03 |
test rdi, rdi / je IG08 |
is a null? the fast loop is not allowed to assume it is not, so a null goes to the slow path where the check will throw properly |
IG04 |
mov edx, [rdi+0x08] / cmp edx, esi / jl IG08 |
the whole bounds check, done once: is a.Length >= n? If yes, every index from 0 to n-1 is provably in range |
IG05–IG06 |
4-instruction loop | the fast loop — byte-for-byte the same body as ByLength |
IG08–IG09 |
7-instruction loop with cmp ecx, edx / jae |
the slow loop, entered only when the guard failed. It checks every element, so the correct exception still gets thrown at the correct index |
IG10 |
call CORINFO_HELP_RNGCHKFAIL |
the runtime helper that throws IndexOutOfRangeException. It never returns, which is why int3 follows it |
why it works that way
The general rule is: the JIT removes a check only when it can prove the check would never
fire, using facts already established by code that runs unconditionally on the path to the
read. ByLength’s own loop condition supplies that proof for free. ByCount cannot supply
it — n carries no relationship to a.Length that the JIT can see — so the JIT falls back to
proving it once per call instead of never, which is still far cheaper than proving it on
every element.
The same question, asked eleven different ways, shows the rule is consistent rather than
coincidental. Every shape below is in bench/il-jit-codegen/bounds-check-shapes.cs, compiled in
one run, each called 400,000 times so all of them reach tier 1:
| loop shape | check in the hot loop | code bytes | how |
|---|---|---|---|
for (int i = 0; i < a.Length; i++) |
none | 29 | proved outright |
foreach (int v in a) |
none | 29 | identical code — foreach over an array is that loop |
for (int i = 0; i <= a.Length - 1; i++) |
none | 27 | proved outright |
int len = a.Length; for (int i = 0; i < len; i++) |
none | 31 | proved: the length cannot change, so the copy is as good as the original |
for (int i = 0; i < n; i++) |
none | 74 | cloned: one guard, two loops |
a.AsSpan(0, n) then i < span.Length |
none | 62 | the slice itself does the check, once |
s += a[i] * b[i], bounded by a.Length |
none | 93 | cloned on b.Length >= a.Length |
the same, after if (b.Length < a.Length) throw |
none | 95 | your guard proves it; no clone needed |
for (int i = a.Length - 1; i >= 0; i--) |
none | 54 | cloned — the backwards loop is not proved outright |
s += a[idx[i]] |
one per element | 61 | nothing can be proved about a value read from memory |
the same via Unsafe.Add(ref r, idx[i]) |
none | 48 | you removed it by hand, and the safety with it |
Three of those are worth stopping on. Caching a.Length in a local proves nothing new —
the JIT already knew the length could not change, so the cached copy earns 2 extra bytes of
code and no fewer checks. foreach over an array is not a different loop from for — it
compiles to the identical 29 bytes. And the backwards loop is the surprise: counting down
from a.Length - 1 produces a cloned loop, not a proved one — the JIT’s proof for the forward
case does not transfer to counting down, even though the set of indices touched is identical.
The s += a[idx[i]] row is the interesting failure case: idx[i] is a value read from another
array at run time, and the JIT has no way to reason about what a memory read might produce. It
cannot prove the index in range, and there is no simple relation to test once the way there was
for ByCount, so the check survives inside the loop, once per element — the disassembly for
that shape shows exactly two extra instructions in the loop body, cmp immediately followed by
a never-taken jae to the throw helper, sitting right next to the read they guard.
what this looks like in prod
The useful version of this knowledge is not “bounds checks are free”. It is that you can make the JIT’s job possible, and that is nearly always cheaper than removing the check yourself.
- Pass a
Span<T>, not an array and a count.a.AsSpan(0, n)validates once at the slice and then every loop overspan.Lengthis provably safe — the same fix as theByCountguard, written by the framework, with a type that stops the length and the buffer drifting apart. - Hoist the relation yourself when two arrays are involved.
if (b.Length < a.Length) throwbefore the loop turns a cloned loop into a proved one, gives callers a better exception thanIndexOutOfRangeExceptionfrom somewhere in the middle of your parse, and costs one comparison instead of one per element. - Do not reach for
Unsafe.Addorfixedfor a per-element check you have not confirmed survives. Most loops never keep a check at all — the table above proves that — so removing one by hand is frequently removing nothing. The risk you take on in exchange is an out-of-bounds write that corrupts an unrelated object and surfaces as an impossible bug hours later, in a different thread. If you do it anyway, do it behind an API that validates once at the boundary. - Where it does pay is where the check blocks something bigger — a loop the JIT would
otherwise vectorise or unroll, or an indirection in the innermost loop of a parser or codec.
That is a small, identifiable set of methods, and
DOTNET_JitDisasmtells you whether you are looking at one of them. - Read the disassembly before optimising, not after. Half the “obvious” rewrites in this
area — caching
Length, switchingforeachtofor, counting down instead of up — are shown above to change nothing, or to make it worse.
the same idea in other languages
| language | what it does with array indexing | the trap |
|---|---|---|
| Java | Every array access is checked, and HotSpot’s C2 removes them the same way: it proves the index in range where it can, and otherwise hoists a predicate out of the loop and keeps a fallback path | Java’s fallback is an uncommon trap that deoptimises back to the interpreter rather than a cloned loop. When the trap fires often, the method is recompiled without the assumption — so a JVM method can quietly start running interpreted code again when your data starts violating an assumption the compiler had made. |
| C / C++ | No checks. a[i] is an address computation and nothing else |
There is no fallback loop, no exception, and no signal that anything went wrong. An out-of-range write lands in whatever object happens to be next in memory. This is what C# is buying with the check. |
| Go | Checked like C#, with its own bounds-check elimination pass; go build -gcflags="-d=ssa/check_bce/debug=1" prints which checks it could not remove |
The compiler tells you where the remaining checks are — but Go has no JIT, so the answer is fixed at build time and cannot use anything learned at run time the way tier-1 code can. |
| Rust | Checked by default; the idiomatic escape is iterators, which are checked once and then compile to pointer walks | get_unchecked exists and requires unsafe, which is the point: the language makes you write down that you are taking the risk, where C#’s Unsafe.Add looks like an ordinary method call. |
| Python | list[i] is checked on every access and raises IndexError |
The check sits underneath the interpreter’s own per-operation overhead, which dwarfs it. There is no comparable win to chase here at all. |
common bugs
- Believing bounds checks always cost something. The idiomatic loop above pays nothing — there is no check left to pay for — and even the worst case in the table, a check that genuinely survives, is two instructions sitting next to work the loop was doing anyway.
- “Optimising” by caching
a.Lengthin a local. 31 bytes against 29, no check removed in either version — the JIT already knew the length could not change. It also makes the loop harder to read, and if the local and the array ever drift apart it is exactly how a check stops being removable. - Reading a listing before the method reaches tier 1 and concluding the JIT is bad at this. The tier-0 listing of the idiomatic loop still has a full bounds check in it, plus instrumentation calls — that is what runs for a method’s first stretch of calls, before it is replaced.
- Assuming a surviving check is a branch-misprediction risk. It is a branch that is never taken, sitting next to a hot loop the predictor sees over and over; the predictor gets it right on essentially every iteration. What you pay is the two instruction slots the check occupies, not a stalled pipeline — see branch prediction for what an actual mispredict costs, which this is not one of.
- Rewriting with
Unsafe.Addand keeping the array parameter public. The check you removed was the one enforcing your method’s contract. Take it out and the validation has to move to the boundary — and it has to actually be there.