Skip to content

← Performance and Data Layout step 12 of 20

Hard End-to-End

Bounds checks: five safe ways to express access

Safe slice access checks that an index or range is valid. The optimizer can remove a runtime check when it can establish that the access stays in bounds. This exercise uses a sliding-window calculation to explore how to express those relationships clearly.

Your task

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

Slide the kernel over the signal without padding. At each position, multiply matching elements and add them from left to right:

out[i] = signal[i] * kernel[0]
       + signal[i + 1] * kernel[1]
       + ...

Start each accumulator at 0.0, visit kernel indices in increasing order, and perform a multiplication followed by an addition for each term. Do not reverse the kernel. This is cross-correlation, the convention commonly used by neural-network convolution layers; the required function name remains convolve1d.

Return an empty vector if the kernel is empty or longer than the signal. Otherwise, return signal.len() - kernel.len() + 1 outputs. For example, signal [1, 2, 3, 4, 5] and kernel [1, 0, -1] produce [-2, -2, -2]. Inputs are finite floating-point values sized to fit in memory and complete within the time limit.

Floating-point addition is not associative. Preserve the specified order rather than replacing it with a reordered reduction or explicit fused multiply-add. An operation order defines the calculation; it is not a blanket guarantee of identical bits across every compiler and target.

Validate lengths before subtracting

The starter computes signal.len() - k + 1 before checking k. Subtracting a larger kernel length underflows usize.

With overflow checks enabled, that subtraction panics. With them disabled, unsigned arithmetic wraps. If the kernel is exactly one element longer, the subtraction wraps to usize::MAX and the following addition wraps to zero. That happens to give an empty output. A kernel two elements longer instead produces a huge loop bound and the first window's indexing panics. Neither behavior is a valid way to handle the input.

The daemon builds with -O, whose usual defaults disable overflow checks. They can still be explicitly enabled with -C overflow-checks=yes. Safe out-of-bounds indexing still panics in an optimized build. See the rustc code-generation options.

Check for an empty or oversized kernel first. This also fixes the starter's empty-kernel behavior, which produces a vector of zeros rather than an empty vector.

Five useful patterns

These patterns can make valid access easier to see. They do not promise a particular instruction count or speedup.

  1. Iterate over elements. A slice iterator yields its elements without a user-supplied index. The loop still needs to detect when iteration ends.
  2. Create a subslice. A window such as &signal[i..i + k] validates a range and gives later code a smaller slice to work with. Arbitrary indexing into that slice is still checked unless proven safe.
  3. Check a relationship once. If an operation requires equally sized inputs, assert_eq!(a.len(), b.len()) documents and checks that contract. The optimizer may use the fact to simplify later accesses.
  4. Pair iterators with zip. It stops at the shorter iterator, so it does not establish equal lengths. In this exercise, each window and the kernel have the same length by construction.
  5. Use chunks_exact for complete blocks. Its chunk size can be a runtime value. The final incomplete block is available through remainder(); it is not yielded as a full chunk. A zero chunk size panics. It does not produce the overlapping windows needed for this task.

This complete example shows the block/remainder distinction:

fn check_blocks(size: usize) {
    let values = [1, 2, 3, 4, 5];
    let mut blocks = values.chunks_exact(size);
    assert_eq!(blocks.next(), Some(&[1, 2][..]));
    assert_eq!(blocks.next(), Some(&[3, 4][..]));
    assert_eq!(blocks.next(), None);
    assert_eq!(blocks.remainder(), &[5]);
}

fn main() {
    check_blocks(2);
}

See the slice methods for these APIs. For the exercise, a window plus zip is enough: validate the lengths, construct each window, then accumulate its paired products.

Check correctness before measuring

Removing a bounds check does not automatically enable vectorization or improve elapsed time. Floating-point rules, loop structure, memory access, target hardware and compiler settings all matter. Compare equivalent implementations on a stated workload and inspect generated code when you need to know whether a check remains. Similar timings alone do not prove that two implementations generated the same instructions.

The harness reports output length, the first five values and a checksum. Implement the full output vector: those observations are not a proof that every remaining element is correct.

get_unchecked requires its index to be in bounds whenever it is called. Violating that requirement causes undefined behavior, even if the returned reference is never read. It is unnecessary here. A safe implementation lets the compiler optimize access without making correctness depend on an unchecked index.