📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is bounds checking in .NET arrays? Why does it exist, when does the JIT eliminate it, and how do you write loops that avoid it safely (and when is unsafe code justified)?

Array bounds checking in .NET 10: the check the JIT emits, why it gives memory safety, patterns that let the JIT remove it, patterns that keep it, how to inspect generated code, and the trade-offs of unsafe access.

Senior Theory Expert arrays bounds-checking csharp jit memory-safety performance

🎯 Key points

  • Every array and span index is checked (index < Length as an unsigned compare); failure throws IndexOutOfRangeException
  • Bounds checks are what give .NET memory safety: no buffer overruns or reading other objects' memory
  • The JIT removes checks it can prove redundant, e.g. for (int i = 0; i < arr.Length; i++) arr[i] and foreach over arrays and spans
  • Checks stay when the limit is not the array's own Length, when indexing with computed indices, or when reading a field that could change
  • Techniques: loop to arr.Length or span.Length, copy fields to locals, slice to a span first, one up-front check like (uint)i < (uint)arr.Length or touching the highest index first
  • Inspect with DOTNET_JitDisasm, BenchmarkDotNet DisassemblyDiagnoser, Disasmo or sharplab.io
  • Unsafe.Add with MemoryMarshal.GetArrayDataReference removes checks but gives up safety; use only in measured, reviewed, well-tested hot paths
  • The cost of a check is usually tiny; vectorisation and cache effects matter more, so measure first

⏱ 30-second answer

Every array or span access is guarded by a bounds check that compares the index against Length and throws IndexOutOfRangeException. That's what makes .NET memory-safe: no buffer overruns. The JIT removes checks it can prove are redundant, for example for (int i = 0; i < arr.Length; i++) or foreach over arrays and spans. They stay when you loop to some other variable, index with computed values, or read the array from a field that might change. I write loops against the array's or span's own Length, copy fields to locals, slice to spans, and check the generated assembly with DOTNET_JitDisasm or BenchmarkDotNet. Unsafe/MemoryMarshal tricks are a last resort for proven hot paths.

🗣 Full answer (senior)

How I would answer this in an interview

Bounds checking means every array (and span, string, List<T>) access verifies the index is within 0..Length-1 and throws IndexOutOfRangeException otherwise. It is a core part of .NET's memory safety: without it, a bad index would read or overwrite arbitrary memory — the source of buffer overflow vulnerabilities in C and C++. The JIT removes the check whenever it can prove the index is safe, so the senior skill is writing loops the JIT recognises, measuring when it matters, and reaching for unsafe only rarely.

1. Why it exists

  • Security: buffer overflows are among the most exploited vulnerability classes (out-of-bounds writes are consistently in the CWE Top 25). Managed bounds checks eliminate them in safe code — one reason governments, including Australia's ACSC guidance, encourage memory-safe languages.
  • Reliability: a bug produces a clear exception at the faulty access, not silent corruption discovered hours later.
  • GC correctness: the garbage collector relies on the heap not being corrupted.

2. What it costs

A bounds check is a compare and a conditional branch:

cmp   edx, dword ptr [rcx+8]   ; index vs Length
jae   THROW                    ; unsigned compare also catches negative indexes

The branch is almost always predicted correctly, so the cost is typically ~1 cycle per access — but in tight numeric loops it adds instructions, can block vectorisation, and prevents some loop optimisations. In most business code it is irrelevant; in parsers, image processing, compression and maths kernels it can matter by 10–30%.

3. When the JIT eliminates it

The JIT (RyuJIT) uses range analysis and pattern recognition. Common patterns that are check-free:

for (int i = 0; i < array.Length; i++) sum += array[i];   // canonical loop: no check
foreach (var x in array) sum += x;                         // compiled to the same loop
for (int i = 0; i < span.Length; i++) sum += span[i];     // spans too

if ((uint)i < (uint)array.Length) return array[i];         // explicit unsigned range check -> no second check

Techniques the JIT uses:

  • Loop bound recognition: i starts at 0, increments by 1, and the loop condition is i < arr.Length on the same array.
  • Range check hoisting / loop cloning: for loops with other bounds (e.g. i < n), the JIT may create a fast clone guarded by n <= arr.Length and a slow clone with checks.
  • Assertion propagation: after if (i < arr.Length) or a previous access at a higher index, later accesses are proven safe. Accessing the highest index first (_ = arr[3]; then arr[0..3]) lets the JIT drop the remaining checks.
  • Constant indexes on arrays/spans of known length (e.g. stackalloc with constant size, u8 literals, static readonly arrays after tier-1).

Things that defeat it: using a different array's length, caching length in a field, indexes computed with non-trivial maths, loops counting down from a non-length value, accessing through IList<T>, and reading Length from an array that might change (fields that could be reassigned).

4. Writing loops that avoid checks safely

  • Iterate with for (i = 0; i < span.Length; i++) or foreach.
  • Slice first, then loop: var slice = data.AsSpan(start, count); for (int i = 0; i < slice.Length; i++) ... — one check at slicing, none in the loop.
  • Copy fields into locals (var arr = _array;) so the JIT knows the array won't change during the loop.
  • Use vectorised helpers that are already optimised: span.IndexOf, SequenceEqual, Fill, CopyTo, TensorPrimitives.Sum, SearchValues.
  • Check with the JIT disassembly (Disasmo extension, DOTNET_JitDisasm, SharpLab, BenchmarkDotNet's [DisassemblyDiagnoser]) rather than guessing.

5. When unsafe code is justified

MemoryMarshal.GetReference + Unsafe.Add, or fixed pointers, remove checks entirely — and with them, all safety. I consider it only when:

  1. A profiler shows the loop is hot and bounds checks are a meaningful part of the time.
  2. Safe idioms (slicing, spans, vectorised APIs, Vector<T>) have been tried.
  3. The index logic is simple, reviewed and covered by tests (including fuzzing for parsers).
  4. The code is isolated in a small, well-documented helper.

The BCL itself uses unsafe techniques internally so application code doesn't have to. Note that <AllowUnsafeBlocks> also raises the bar in security reviews.

6. A production example

In an image-thumbnail service, a resize loop indexed pixels[y * width + x] with y and x from nested loops and width from an object field. A disassembly showed a bounds check on every pixel and no vectorisation. Rewriting it to take Span<Rgba32> row = pixels.AsSpan(y * width, width) per row and loop for (int x = 0; x < row.Length; x++) removed the inner checks; switching the inner operation to Vector<T> arithmetic gave another big gain. No unsafe was needed. The overall p95 latency for thumbnail generation dropped by about a third.

🔍 What the interviewer is checking

Probes runtime and JIT knowledge and performance judgement.

Green flags

  • Explains what the check is and why it gives memory safety.
  • Knows patterns where the JIT removes or keeps checks.
  • Uses spans and locals to help the JIT, and inspects disassembly.
  • Treats unsafe code as a measured last resort.

Red flags

  • Suggests disabling safety by default for speed.
  • Unaware that foreach over arrays is check-free.
  • Uses exceptions from bounds checks for control flow.

📚 Learn in depth

## 1. Where bounds checks happen

| Access | Checked? |
|---|---|
| `array[i]` (SZ array) | Yes — IL `ldelem`/`stelem`, JIT may remove |
| `array[i, j]` (MD array) | Yes, per dimension |
| `span[i]`, `readOnlySpan[i]` | Yes — JIT intrinsic, removable like arrays |
| `str[i]` | Yes — `string` indexer is an intrinsic |
| `list[i]` | Yes — `List

💻 Code

using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

namespace Arrays;

public sealed class FrameDecoder(byte[] buffer)
{
    private readonly byte[] _buffer = buffer;

    // Checks likely remain: field access and offset arithmetic
    public int ChecksumSlow(int pos, int length)
    {
        int sum = 0;
        for (int k = 0; k < length; k++)
            sum += _buffer[pos + k];
        return sum;
    }

    // Safe and fast: validate once by slicing, then loop over the span's own length
    public int ChecksumFast(int pos, int length)
    {
        ReadOnlySpan<byte> frame = _buffer.AsSpan(pos, length);   // throws once if out of range
        int sum = 0;
        for (int k = 0; k < frame.Length; k++)
            sum += frame[k];                                       // no bounds check
        return sum;
    }

    // Last resort: no checks at all. Only after measuring, with explicit validation outside.
    public int ChecksumUnsafe(int pos, int length)
    {
        if ((uint)pos > (uint)_buffer.Length || (uint)length > (uint)(_buffer.Length - pos))
            throw new ArgumentOutOfRangeException(nameof(length));

        ref byte start = ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(_buffer), pos);
        int sum = 0;
        for (int k = 0; k < length; k++)
            sum += Unsafe.Add(ref start, k);
        return sum;
    }
}

public static class BoundsDemo
{
    public static void Main()
    {
        var decoder = new FrameDecoder([1, 2, 3, 4, 5, 6]);
        Console.WriteLine(decoder.ChecksumSlow(1, 3));    // 9
        Console.WriteLine(decoder.ChecksumFast(1, 3));    // 9
        Console.WriteLine(decoder.ChecksumUnsafe(1, 3));  // 9

        try { decoder.ChecksumFast(4, 5); }
        catch (ArgumentOutOfRangeException) { Console.WriteLine("Rejected before reading memory"); }

        int[] data = [3, 1, 4, 1, 5];
        int total = 0;
        foreach (var d in data) total += d;                // check-free
        Console.WriteLine(total);
    }
}

⚠ Common mistakes

1. Removing bounds checks with unsafe by default

What happens: pointer code added "for performance" without profiling. Why it is wrong: removes memory safety, risks corruption and vulnerabilities, and often gains nothing because the JIT already eliminated the checks. Do instead: measure, check disassembly, try safe idioms first.

2. Caching Length in a variable or field

What happens: int len = arr.Length; for (int i = 0; i < len; i++) or using a field for the length. Why it is wrong: older advice; it can prevent the JIT recognising the canonical pattern, keeping the checks. Do instead: write i < arr.Length (or loop over a span).

3. Looping over a field array that might change

What happens: for (int i = 0; i < _items.Length; i++) _items[i]... where _items is a mutable field. Why it is wrong: the JIT can't assume _items is the same array each iteration, so checks remain (and another thread could swap it). Do instead: copy to a local first: var items = _items;.

4. Catching IndexOutOfRangeException for control flow

What happens: reading until an exception signals the end of data. Why it is wrong: exceptions are expensive and hide real bugs. Do instead: check lengths explicitly or use TryXxx patterns.

5. Benchmarking in Debug or without warm-up

What happens: measuring loops with a Stopwatch in Debug builds. Why it is wrong: Debug and tier-0 code keep all checks; results don't reflect production. Do instead: use BenchmarkDotNet in Release.

6. Accessing elements through interfaces in hot loops

What happens: IList<int> or IReadOnlyList<int> indexed in tight loops. Why it is wrong: interface calls and per-call checks prevent elimination and inlining. Do instead: use arrays/spans in hot internal code.

7. Writing manual SIMD before using built-ins

What happens: hand-written Vector<T> loops for sums, searches and copies. Why it is wrong: duplicates heavily optimised BCL code with more risk of off-by-one errors in remainder handling. Do instead: use TensorPrimitives, MemoryExtensions and SearchValues first.

8. Assuming spans have no checks

What happens: switching to spans and expecting checks to disappear everywhere. Why it is wrong: spans are bounds-checked like arrays; the benefit comes from slicing and canonical loops. Do instead: slice once, loop canonically, verify with disassembly.

9. Using fixed broadly

What happens: large buffers pinned for long periods. Why it is wrong: pinned objects prevent heap compaction, causing fragmentation and GC overhead. Do instead: keep pinning short, or use MemoryMarshal/Unsafe refs which don't pin; use the Pinned Object Heap for long-lived pinned buffers.

10. Not testing boundary conditions in unsafe code

What happens: unsafe parsers tested only with well-formed input. Why it is wrong: malformed or malicious input causes out-of-bounds reads/writes — security vulnerabilities. Do instead: fuzz test (e.g. SharpFuzz), test empty/short/huge inputs, and isolate unsafe code in small reviewed helpers.

🏛 Senior insight: trade-offs

Seniors treat bounds checks as part of the safety model and optimise them only with evidence.

Situation Approach
Normal business code ignore; checks are negligible
Hot numeric or parsing loop slice to spans, loop over own length, prefer vectorised BCL APIs
Still hot after that inspect disassembly, try loop restructuring
Proven bottleneck, reviewed small Unsafe helper with explicit validation and fuzz tests
  • Being able to read a few lines of JIT assembly (cmp/jae/RNGCHKFAIL) is a strong senior performance skill.
  • Each .NET release improves bounds-check elimination, so re-measure old "optimisations" when upgrading to .NET 10; some unsafe code can be deleted.
  • Security reviews (and the ACSC's guidance on memory-safe languages) favour keeping managed safety; document any unsafe code and why it is needed.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions Span<T> slicing Why: Checks safe zero-copy alternatives to unsafe code
    How to answer

    ReadOnlySpan<char> is a view over contiguous characters, which can be a string, an array or a stack buffer, made of a reference plus a length. Slicing it is free (no new string), and almost every parsing API now takes spans, so I can split a CSV line, trim fields and parse numbers and dates with zero allocations until I really need a string. It's a ref struct, so it can't be stored in a class field, boxed, captured by a lambda or held across an await. For those cases I use ReadOnlyMemory<char> and call .Span when processing. I reach for it in measured hot paths such as parsers, protocol code and high-volume ingestion.

  2. When: Candidate talks about hot loops Why: Broadens to array performance as a whole
    How to answer

    First I measure: dotnet-counters for allocation rate and LOH size, then an allocation profiler to see which arrays dominate. Short-lived large arrays are the worst case, because anything of 85,000 bytes or more lands on the Large Object Heap, which is only collected in gen 2. The fixes are to reuse buffers with ArrayPool<T>.Shared (always return them, use only the requested length, clear sensitive data), use spans instead of copying slices, and stream data instead of building giant arrays. For speed I keep data in contiguous arrays of structs for cache locality and use the vectorised CopyTo/Fill/TensorPrimitives APIs instead of hand-written loops. Then I prove it with BenchmarkDotNet and compare production counters.

🛠 Real-world problems: how you handle them

Protocol decoder sped up by slicing instead of offset indexing
Situation
A market-data gateway decoded binary frames at about 500,000 messages per second using a decoder that indexed a byte[] field with pos + k offsets.
Signals
CPU was the bottleneck at peak; the profiler showed the decode loop as the hottest method, and throughput plateaued below the exchange's peak rate.
Investigation
Used BenchmarkDotNet with DisassemblyDiagnoser and DOTNET_JitDisasm to see a bounds check and a field reload per byte; compared alternative loop shapes.
Resolution
Sliced each frame once into a ReadOnlySpan<byte>, looped over the span's length and used BinaryPrimitives for multi-byte fields; no unsafe code needed.
Prevention
Kept the decode benchmark in CI with a regression threshold and added fuzz tests for malformed frames.

How to tell it (STAR)

**S:** Our market-data gateway [at company] could not keep up with peak message rates. **T:** I was asked to improve decoding throughput safely. **A:** I benchmarked the decoder, read the JIT assembly to find a bounds check and field reload per byte, and restructured it to slice each frame into a span and loop over its length. **R:** Throughput rose by about [35%] with no unsafe code, and the benchmark guards it in CI.

↩ Usually asked after

More in Arrays

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me