Skip to content

← Performance and Data Layout step 12 of 20

Hard End-to-End

Bounds checks: when they cost, and five safe ways to remove them

“Rust is slower than C because of bounds checking” is the most repeated criticism of the language and one of the least examined. The honest picture is more interesting than either the criticism or the defence.

First, the check you cannot turn off

-O turns overflow checks off. It does not turn bounds checks off, and nothing does in safe Rust. The starter for this problem demonstrates the difference in one line:

let n = signal.len() - k + 1;   // usize underflow, silent at -O

When kernel is longer than signal that subtraction wraps to something near usize::MAX, no check fires, and the program marches on with a nonsense loop bound. What finally stops it is the bounds check inside the loop, which is still there and still costs a comparison and a branch — and which is the only reason this is a clean panic rather than a memory-safety bug.

Run the starter and read that panic before you fix anything.

When bounds checks actually cost

Measured on this toolchain, a dot product over a million f32s:

version time
for i in 0..a.len() { s += a[i] * b[i] } 0.743 ms
the same, after adding assert_eq!(a.len(), b.len()) 0.501 ms
a.iter().zip(b).map(|(x, y)| x * y).sum() 0.484 ms

1.48× from one assertion, which is the surprising one — the assert looks like it adds work. It does, once. What it buys is a fact the optimiser can use for the rest of the function: with two slices of provably equal length, LLVM can prove i < b.len() from i < a.len(), delete the second check, and then — crucially — vectorise the loop, which it cannot do while a branch might exit early.

And the honest counterweight

Two other measurements from the same session:

for i in 0..v.len() { s += v[i] } 0.0669 ms
v.iter().sum() 0.0663 ms
byte histogram with h[b as usize] += 1 0.931 ms
the same via get_mut 0.928 ms

Identical. The check was already gone — LLVM proved the index in range and deleted it without help. This is the common case. Most bounds checks in most code cost nothing, because most of them are provably redundant.

So the rule is not “bounds checks are slow”. It is: a bounds check costs when the compiler cannot prove it redundant, and the fix is to give it the proof — not to remove the check.

The five safe recipes

  1. Iterate instead of indexing. for x in slice cannot go out of range, so there is no check to emit.
  2. Bind a subslice first. let w = &signal[i..i + k]; pays one check for the whole window, then indexes w freely.
  3. Assert the relationship up front. assert_eq!(a.len(), b.len()) — one branch, taken once, that informs every access afterwards.
  4. Use zip. Two iterators, one length governing both, no index at all.
  5. Use chunks_exact. Every chunk has a compile-time-constant length, so indexing inside it is checked against a constant the optimiser folds away — and it unrolls.

None of these is unsafe. All of them are more readable than the version they replace, which is the part worth noticing: in Rust the fast shape is usually also the clear shape.

What to write

pub fn convolve1d(signal: &[f64], kernel: &[f64]) -> Vec<f64>

A valid (no padding) 1-D convolution. Output length is signal.len() - kernel.len() + 1, and

out[i] = sum over j of signal[i + j] * kernel[j]

accumulated from 0.0 with j increasing. That order is part of the specification: floating-point addition is not associative, so an unspecified order would not be reproducible.

If kernel is empty, or longer than signal, return an empty vector. Handle that before you compute the length, for the reason above.

The reference is recipe 2 plus recipe 4: bind the window, then zip it with the kernel.

About get_unchecked

It exists, it removes the check, and in a graded course it is a genuine hazard: it will look faster and be wrong. slice.get_unchecked(i) is unsafe, and its precondition — i is in bounds — is a whole-program obligation. Break it and you do not get a panic; you get undefined behaviour, which may manifest as a wrong answer in a completely different function six months later.

Reach for it only after you have profiled, tried all five safe recipes, and can write down the invariant that makes it sound. And write that invariant in a // SAFETY: comment, because the next reader cannot reconstruct it.

One more warning: bounds-check elision depends on the optimisation level and can regress between LLVM versions. Never write a test that only passes because a check was removed.

Related lints

needless_range_loop, manual_memcpy (an index loop copying between slices — copy_from_slice), and, in the restriction group and worth turning on deliberately for kernel code: indexing_slicing, get_unwrap, missing_asserts_for_indexing and undocumented_unsafe_blocks.

Remember the grade is compile + tests + clippy -D warnings.

Loading visualization…