All measurements were taken on the same machine. The terminal output comes from a single recorded session and has not been edited. The test-system configuration is listed at the end of the article.
Modern compilers can do an astonishing amount of work. They eliminate redundant checks, unroll loops, reorder instructions, and combine independent operations into SIMD batches. Sometimes all you need to do is write an ordinary Rust loop, enable a release build, and LLVM will turn it into code that processes several values at once.
But it is easy to draw an overly strong conclusion from this: if a loop is simple and the data is contiguous, the compiler will necessarily vectorize it well.
Not necessarily.
In fact, it may initially fail to vectorize the loop at all—and then, after a small restructuring of the source code, vectorize it while spending most of its SIMD instructions not on computation, but on moving data around.
We will examine both cases using the same five-tap filter for 4K video. The complete main.rs sources for both variants are collected in the appendix at the end.
At 60 frames per second, each frame has a budget of 16.67 ms. That is the budget for the entire pipeline: acquiring the image, filtering it, and outputting the result. So even a simple operation that takes twenty milliseconds already makes 60 fps impossible.
Consider the horizontal pass of a five-tap Gaussian blur:
for x in 2..width - 2 {
let acc = srow[x - 2] as i32 * weights[0]
+ srow[x - 1] as i32 * weights[1]
+ srow[x] as i32 * weights[2]
+ srow[x + 1] as i32 * weights[3]
+ srow[x + 2] as i32 * weights[4];
drow[x] = (acc >> 8) as u8;
}
For each pixel, the function reads five neighboring values, multiplies them by the coefficients in the supplied weights variable—[16, 64, 96, 64, 16]—adds them together, and divides the result by 256 using a right shift.
The loop has no complex branches. It performs no allocations. The data is read sequentially, and adjacent iterations perform the same operation.
On paper, this is almost a textbook candidate for auto-vectorization.
Auto-vectorization is an optimization in which the compiler transforms a sequence of scalar operations into SIMD instructions that process several values at once. In Rust, this is usually handled by LLVM when optimizations are enabled, provided that the structure of the code and the dependencies between data allow the computation to be vectorized safely.
The project is built in release mode with LTO and codegen-units = 1. We run the filter on a 3840×2160 frame:
$ cargo run -q --release
kernel: [16, 64, 96, 64, 16] / 256, i32 arithmetic
frame: 3840 × 2160 (8.3 MP)
median: 21.473 ms/frame (30 reps)
60 fps: budget 16.67 ms -> 129% used
sha256: ad1e94b255d78e1231b811ff4d8df3de1b36cf4ad43089aa1063d8718b0d5d63
A single horizontal pass takes 21.473 ms—129% of the entire frame budget.
At this point, a convenient explanation usually presents itself: perhaps the loop is simply expensive. After all, it processes 8.3 million pixels and performs five multiplications per pixel. But before discussing memory bandwidth or SIMD register width, it is worth checking a simpler fact.
Did the compiler vectorize this loop at all?
The Rust source code cannot answer that question. The answer is in the final binary.
First Reveal: There Is No SIMD Here at All
Disassemble the release binary, extract the relevant function, and check two groups of instructions:
$ objdump -d --no-show-raw-insn \
target/release/blur-naive > blur-naive.asm
$ awk '/blur_frame_i32>:/,/^$/' blur-naive.asm > kernel.asm
$ grep -c panic_bounds_check kernel.asm
2
$ grep -cE 'pmul|padd|psub|pmadd' kernel.asm
0
The result leaves no room for interpretation.
There are two calls to panic_bounds_check inside the function.
panic_bounds_checktriggers a panic when an array or slice is accessed out of bounds. Calls like these inside a loop can prevent the compiler from auto-vectorizing it.
The number of vector multiplications and additions is zero.
The compiler did not generate poor SIMD code. It did not vectorize the loop at all.
The disassembly shows five separate scalar movzbl loads for the five elements in the window. Arithmetic is then performed for one pixel, the result is written to the output buffer, and the next iteration begins.
Eight million pixels are processed one at a time.
The reason is right there: the bounds checks.
Every expression such as
srow[x - 2]
or
drow[x]
means more than just a load or store. Safe slice indexing must verify that the index is within the valid range. If it is not, the program must panic.
It is tempting to think of the check as merely one extra comparison and a conditional branch. But for the vectorizer, the important issue is not so much the cost of that branch as its semantics.
A panic must occur during a specific iteration and in a specific program state. If an error is detected while processing the third pixel, writes belonging to the fourth and subsequent pixels must not occur.
A scalar loop naturally preserves this requirement: it checks and processes elements one at a time.
A SIMD instruction may compute and write sixteen pixels at once. If one of the logical iterations in that group is supposed to panic, some later results may already have been written.
That transformation would change the program’s observable behavior. The compiler therefore has no right to perform it until it can prove that the panic path is unreachable.
A human looking at the range
2..width - 2
and the accesses from x - 2 through x + 2 can easily see that the indices should be valid. But LLVM does not receive human intent. It receives an intermediate representation containing specific checks and branches.
If the proof has not been preserved in a form that the optimizer can use, the compiler must remain conservative.
It is not stupid or lazy. It simply cannot violate the program’s contract in exchange for prettier machine code.
The Compiler Needs Proof, Not Promises
The bounds checks could be removed with unsafe and get_unchecked, transferring responsibility for index correctness to the programmer.
But this example does not require unsafe.
The problem is not that Rust’s safety guarantees are fundamentally incompatible with fast code. The problem is the form in which we expressed the traversal.
Rewrite the loop like this:
for (out, win) in drow[2..width - 2].iter_mut().zip(srow.windows(5)) {
let acc = win[0] as i32 * weights[0]
+ win[1] as i32 * weights[1]
+ win[2] as i32 * weights[2]
+ win[3] as i32 * weights[3]
+ win[4] as i32 * weights[4];
*out = (acc >> 8) as u8;
}
The algorithm itself has not changed.
It still uses five neighboring bytes, the same coefficients, the same multiplications, additions, and shift. The output format is unchanged as well.
But the validity of the bounds is no longer expressed through index arithmetic.
windows(5) can return only a complete five-element window. It will never create a slice that extends beyond the input data.
iter_mut() visits only the elements in the selected portion of the output slice.
zip stops when the shorter of the two iterators is exhausted.
Inside the loop, the compiler no longer has to prove repeatedly that x - 2, x + 2, and x are valid. That fact now follows from the structure of the iterators.
We did not disable safety. We moved the proof of correctness from index arithmetic into the traversal structure.
Build the program again:
$ cargo run -q --release
kernel: windows+zip, i32 arithmetic
frame: 3840 × 2160 (8.3 MP)
median: 4.584 ms/frame (30 reps)
60 fps: budget 16.67 ms -> 28% used
sha256: ad1e94b255d78e1231b811ff4d8df3de1b36cf4ad43089aa1063d8718b0d5d63
The runtime drops from 21.473 to 4.584 ms.
Instead of consuming 129% of the frame budget, the filter now uses 28%.
The result hash remains unchanged:
ad1e94b255d78e1231b811ff4d8df3de1b36cf4ad43089aa1063d8718b0d5d63
The output matches byte for byte. We did not change the mathematics or sacrifice accuracy. The only significant change is that the compiler now sees a loop form in which the bounds are guaranteed by construction.
At this point, it is easy to jump to another conclusion: the problem is solved, the compiler vectorized everything, and the machine code must now be close to optimal.
But the presence of SIMD instructions says nothing by itself about the quality of the SIMD code.
So we return to the disassembly.
Second Reveal: SIMD Is Here Now, but It Spends Most of Its Time Moving Data
Check the new binary in the same way:
$ objdump -d --no-show-raw-insn \
target/release/blur-windows > blur-windows.asm
$ awk '/blur_frame_windows>:/,/^$/' blur-windows.asm > kernel.asm
$ grep -c panic_bounds_check kernel.asm
0
$ grep -cE 'pmul|padd|psub|pmadd' kernel.asm
15
$ grep -cE 'punpck|pack|pshuf|psll|psrl|movdq|movd' kernel.asm
45
There are no bounds checks left inside the kernel.
Fifteen vector arithmetic instructions have appeared: multiplications and additions are indeed being performed on groups of pixels.
But they are accompanied by forty-five instructions for unpacking, packing, shifting, shuffling, and moving data.
Fifteen instructions perform calculations.
Forty-five prepare the operands and assemble the result.
Three quarters of the vector work is spent on logistics.
The reason is straightforward. The source pixels are bytes, but they must be widened into larger elements for multiplication. The code then has to form five shifted sequences for the overlapping windows, apply the coefficients, add the results, shift, and pack the values back into bytes.
The auto-vectorizer found a valid SIMD path. But that path requires a large number of in-register rearrangements.
Interestingly, declaring the arithmetic as i32 in the source did not force LLVM to perform every operation on 32-bit elements. The compiler independently narrowed suitable operations to the 16-bit pmullw and paddw instructions.
LLVM was able to prove that the intermediate values fit in 16 bits and automatically narrowed the arithmetic. However, the overlapping five-tap windows require constant rearrangement of data between SIMD registers, so the main cost lies not in the width of the arithmetic but in the large number of shuffle instructions.
An explicit u16 implementation provided no benefit. LLVM had already made that choice on its own.
The problem therefore cannot be solved by mechanically replacing i32 with a narrower type. The main cost comes from the way the overlapping five-tap windows are constructed.
But counting instructions is only indirect evidence. To understand how wasteful the resulting code is, we need measurable reference points.
The first is its response to wider SIMD.
Registers Twice as Wide Did Not Deliver Twice the Speed
The ordinary build targets baseline x86-64 and uses SSE2. Rebuild the same program for x86-64-v3, allowing LLVM to use AVX2. AVX2 provides 256-bit SIMD registers instead of SSE2’s 128-bit registers, so one instruction can process twice as many elements:
$ RUSTFLAGS="-C target-cpu=x86-64-v3" \
cargo run -q --release --target-dir target-v3
kernel: windows+zip, i32 arithmetic
median: 2.953 ms/frame (30 reps)
60 fps: budget 16.67 ms -> 18% used
The runtime falls from 4.584 to 2.953 ms.
That is a speedup of about 1.55×.
This is a good result, but AVX2 registers are twice as wide as SSE2 registers. If the path consisted mostly of arithmetic that scaled cleanly with register width, we might expect a factor closer to two.
But shuffles, unpacking, and packing do not disappear merely because the register is wider. Some of them become more complex, and part of the work is limited by the throughput of particular execution units in the processor.
The incomplete scaling from SSE2 to AVX2 therefore confirms what the disassembly already showed: the vector path spends a significant share of its resources organizing data.
For comparison, it is useful to look at the original scalar version compiled for the same instruction-set target:
| Median ms/frame, 30 runs | Baseline (SSE2) | x86-64-v3 (AVX2) |
|---|---|---|
| Indexed, scalar | 21.473 | 25.037 |
windows+zip, vectorized | 4.584 | 2.953 |
| Plain frame copy | 0.702 | — |
The scalar version compiled for x86-64-v3 took 25.037 ms. Merely enabling AVX2 support did not save it.
This is an important point. The flag
-C target-cpu=x86-64-v3
only permits the compiler to use newer instructions. It does not remove semantic obstacles to vectorization.
If the loop’s structure does not allow iterations to be combined, the wider registers will remain unused.
“Compile for my CPU” is not the same as “make the code vectorizable.”
The fact that the scalar AVX2 run was slower than the baseline run is not caused by the instruction set. It reflects the variability of absolute timings on a laptop processor. We will return to that later.
For now, the more important point is that the vectorized version benefited from AVX2, but did not scale in proportion to the register width.
The second reference point is the cost of pure data movement.
How Much More Expensive Is the Filter Than the Frame Itself?
Any blur implementation must read the input pixels and write the output. That part of the work is unavoidable.
To estimate its cost separately, measure a plain copy of the entire frame:
$ cargo run -q --release -- --copy
kernel: plain copy (traffic reference)
median: 0.702 ms/frame (30 reps)
60 fps: budget 16.67 ms -> 4% used
Moving a 3840×2160 frame takes 0.702 ms.
Of course, a five-tap filter cannot run at exactly the cost of a copy. It has to perform five multiplications and four additions per pixel, construct overlapping windows, and convert the result back into bytes.
So 0.702 ms is not a promise of attainable performance. It is a reference point: the cost of the data traffic itself, without arithmetic.
The vectorized SSE2 version takes 4.584 ms—about six and a half times as long.
Five multiplications and four additions are not free. But together with the disassembly, this gap shows that a substantial amount of time is being spent on more than useful arithmetic.
The loop repeatedly unpacks bytes, constructs shifted sets of values, rearranges them between registers, and packs the result again.
The auto-vectorizer found a correct way to process several pixels at once. But it could not automatically construct the most economical data-movement algorithm.
That is the boundary between two statements:
- the loop is vectorized;
- the loop is vectorized well.
The first can be confirmed by the presence of SIMD instructions.
That is not enough to establish the second.
Why windows Was Faster Even Though It Still Contains Indexing
At first glance, the new version raises a question. We eliminated indexing of the form srow[x ± n], but inside each window we still access:
win[0]
win[1]
win[2]
win[3]
win[4]
Why did these accesses not reintroduce the same problem?
Because the length of win is known from the construction of windows(5). Every item produced by the iterator is guaranteed to be a slice of exactly five elements.
The compiler no longer has to reason about the relationship between an arbitrary x, the row width, and the expressions x - 2 or x + 2. To the compiler, win[4] is access to a fixed element of an object whose size is already known.
This distinction is often lost in discussions of zero-cost abstractions.
What matters is not merely whether the source text contains iterators or indices. What matters is which properties of those constructs are available to the optimizer, and which checks remain in the intermediate representation.
An abstraction can be more convenient for a human and easier for the compiler at the same time, provided that it expresses the constraints directly through types and traversal structure.
In this example, windows and zip were not simply a prettier replacement for a manual loop. They were a way to provide LLVM with the proof it had been missing.
Auto-Vectorization Is Neither Magic nor a Promise
The auto-vectorizer does not operate on the programmer’s intent.
It does not know that we “obviously intended” to visit only valid pixels, or that the panic “will never happen anyway.” It sees operations, dependencies, possible branches, and the rules of observable behavior.
If combining iterations could change the order of writes or the point at which a panic occurs, that transformation is forbidden.
When the code is rewritten with windows and zip, those risks disappear from the loop’s structure, making vectorization legal.
But that is where a different problem begins.
The compiler must choose the element width, the way bytes are loaded, the widening strategy, the shuffles for five overlapping windows, the order of additions, and the packing of the result.
It found a workable scheme. That scheme was almost five times faster than the scalar version and fit comfortably within the frame budget.
That is a good result from auto-vectorization.
But a good result is not the same as an optimal one.
The disassembly shows forty-five data-handling instructions for every fifteen arithmetic instructions. The measurements show incomplete scaling when moving to registers twice as wide. The comparison with a plain frame copy shows a large gap above the cost of pure data traffic.
Three independent observations point in the same direction: there is still performance headroom, and compiler flags alone are unlikely to unlock it.
The next step would be an explicit SIMD implementation with direct control over loads, shuffles, and data reuse.
That would introduce new questions: how to support both SSE2 and AVX2, how to select an implementation at runtime, how to preserve a portable fallback, and how to verify that handwritten SIMD really is better than LLVM’s output.
That is a separate problem.
On Measurement Accuracy
There is one more detail without which the results would look more precise than they really are.
On this machine, the same scalar binary produced results ranging from 17 to 25 milliseconds across different runs. The code did not change, and the input frame remained the same.
The reason is the laptop processor. Its clock frequency depends on temperature, workload duration, power mode, and other factors. As a result, absolute timings can differ by tens of percent between sessions.
That is why you cannot take the best result for one implementation from a morning run, the worst result for another from an evening run, and calculate an impressive ratio between them.
All comparisons above come from a single recorded session. Every row reports the median of thirty repetitions.
This does not eliminate frequency-related variation entirely, but it makes comparisons within the table much more reliable than isolated values taken from different runs.
The median is not there for decoration, either. It reduces the influence of individual outliers: a background process, a system interrupt, or a brief change in clock frequency.
It would therefore be incorrect to claim that “this function always runs in 4.584 ms.”
The accurate conclusion is different: within the same session, changing the loop structure reduced the runtime from 21.473 to 4.584 ms, while enabling AVX2 reduced the vectorized version’s runtime to 2.953 ms.
Performance work requires precision not only from the stopwatch, but also from the wording of the conclusions.
How to Check What the Compiler Did
The lesson from this experiment is not the universal advice to “always use windows.” It is a more useful verification process.
First, determine whether vectorization happened at all.
For a small, isolated kernel, it is enough to disassemble the release binary and look for characteristic instructions:
grep -c panic_bounds_check kernel.asm
grep -cE 'pmul|padd|psub|pmadd' kernel.asm
If a path to a bounds check remains inside the hot loop and there is no SIMD arithmetic, it is premature to discuss register width or the quality of the vector code.
First remove the obstacle that prevents vectorization itself.
unsafe is not the automatic answer. The problem can often be solved by changing the traversal structure: use windows, zip, chunks_exact, or other constructs whose lengths and bounds follow from the iterator’s structure.
Then inspect the binary again.
If SIMD instructions have appeared, the work is not finished. The next question is what those instructions are doing:
grep -cE 'pmul|padd|psub|pmadd' kernel.asm
grep -cE 'punpck|pack|pshuf|psll|psrl|movdq|movd' kernel.asm
This comparison is not a substitute for full microarchitectural analysis, but it quickly reveals the general character of the code: whether it mostly computes or mostly rearranges data.
Two simple experiments are then useful.
First, rebuild for wider SIMD and see how performance scales with register width.
Second, measure pure data traffic over the same amount of data to establish a lower reference point.
Neither test proves inefficiency on its own. Together with the disassembly, however, they provide a fairly clear picture.
Trust the Compiler—but Verify It Twice
The original loop looked so simple that auto-vectorization seemed almost guaranteed.
Yet two bounds checks left a path to a panic inside it. Because of the strict semantics of that panic, LLVM could not safely combine iterations, and eight million pixels were processed one at a time.
We did not disable safety or rewrite the algorithm with intrinsics. We merely moved the proof of correctness into the loop’s structure—from manual index arithmetic to windows, iter_mut, and zip.
The checks then disappeared, SIMD appeared, and the runtime fell from 21.473 to 4.584 ms.
But the second inspection showed that most of the resulting SIMD code was not doing arithmetic. Fifteen computational instructions were accompanied by forty-five unpacking, shuffling, shifting, and packing instructions.
Moving from SSE2 to AVX2 produced a 1.55× speedup rather than a 2× speedup, while the blur remained roughly six and a half times more expensive than a plain frame copy.
That is why the question “Did the compiler vectorize the loop?” is not sufficient.
You need to ask two questions:
Did it vectorize the loop at all?
And only then:
What exactly did it vectorize the loop into?
The first is answered by the presence of SIMD instructions and the absence of obstructive branches in the disassembly.
The second is answered by the ratio of useful arithmetic to data-handling instructions, scaling with wider registers, and measurements of the actual cost.
The auto-vectorizer really can turn safe, fairly ordinary Rust code into fast SIMD. But it cannot read the programmer’s mind, and it does not guarantee an optimal data-movement strategy.
You have to explain the loop’s structure to it.
Then you have to inspect the result.
Only after both checks succeed should you begin to trust the word “vectorized.”
Appendix: Full Source Files
main.rs — the indexed (scalar) variant
//! Scene 01: the naive filter, correct first — and its frame time.
//!
//! A 5-tap horizontal gaussian pass over a synthetic 4K grayscale frame.
//! Kernel weights arrive at runtime (as any configurable filter's would)
//! and are typed `i32` — the "obvious" accumulator type. That choice is
//! the whole story: the autovectorizer must honor this element width.
use anyhow::{Result, ensure};
use clap::Parser;
use sha2::{Digest, Sha256};
use std::time::Instant;
/// Naive i32 blur: median frame time + output digest.
#[derive(Parser)]
struct Args {
/// Frame width in pixels
#[arg(long, default_value_t = 3840)]
width: usize,
/// Frame height in pixels
#[arg(long, default_value_t = 2160)]
height: usize,
/// Timed repetitions; the median is reported
#[arg(long, default_value_t = 30)]
reps: usize,
}
/// [1, 4, 6, 4, 1] × 16 — sums to 256, so `>> 8` renormalizes exactly.
const WEIGHTS: [i32; 5] = [16, 64, 96, 64, 16];
/// Deterministic frame: xorshift-filled, so every binary in this demo
/// blurs the same bytes and digests stay comparable.
fn synth_frame(len: usize) -> Vec<u8> {
let mut state: u64 = 0x2A11_2A11_2A11_2A11;
let mut frame = vec![0_u8; len];
for px in frame.iter_mut() {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
*px = (state >> 56) as u8;
}
frame
}
#[inline(never)]
fn blur_frame_i32(src: &[u8], dst: &mut [u8], width: usize, weights: &[i32; 5]) {
for (srow, drow) in src.chunks_exact(width).zip(dst.chunks_exact_mut(width)) {
// Borders keep the source pixel; the kernel needs two neighbors.
drow[..2].copy_from_slice(&srow[..2]);
drow[width - 2..].copy_from_slice(&srow[width - 2..]);
for x in 2..width - 2 {
let acc = srow[x - 2] as i32 * weights[0]
+ srow[x - 1] as i32 * weights[1]
+ srow[x] as i32 * weights[2]
+ srow[x + 1] as i32 * weights[3]
+ srow[x + 2] as i32 * weights[4];
drow[x] = (acc >> 8) as u8;
}
}
}
fn main() -> Result<()> {
let args = Args::parse();
ensure!(args.width >= 8, "frame too narrow for a 5-tap kernel");
ensure!(args.reps >= 1, "need at least one repetition");
let src = synth_frame(args.width * args.height);
let mut dst = vec![0_u8; src.len()];
for _ in 0..3 {
blur_frame_i32(&src, &mut dst, args.width, &WEIGHTS);
}
let mut samples = Vec::with_capacity(args.reps);
for _ in 0..args.reps {
let started = Instant::now();
blur_frame_i32(&src, &mut dst, args.width, &WEIGHTS);
samples.push(started.elapsed().as_secs_f64() * 1e3);
}
samples.sort_by(|a, b| a.total_cmp(b));
let median = samples[samples.len() / 2];
let megapixels = (args.width * args.height) as f64 / 1e6;
let budget = 1000.0 / 60.0;
println!("kernel: [16, 64, 96, 64, 16] / 256, i32 arithmetic");
println!("frame: {} × {} ({megapixels:.1} MP)", args.width, args.height);
println!("median: {median:8.3} ms/frame ({} reps)", args.reps);
println!("60 fps: budget {budget:.2} ms -> {:.0}% used", median / budget * 100.0);
println!("sha256: {:x}", Sha256::digest(&dst));
Ok(())
}main.rs — the windows+zip (vectorized) variant
//! Scene 03: the same blur, written so the vectorizer is allowed to work.
//!
//! Identical math to scene 01. The only change is HOW pixels are
//! addressed: `windows(5)` + `zip` instead of `srow[x - 2]`-style
//! indexing. Indexing keeps a panic path (`panic_bounds_check`) alive
//! inside the hot loop, and a loop that may bail out mid-iteration is a
//! loop the compiler must not vectorize. Iterators carry the bounds
//! proof in their shape — no unsafe, no hints, same formula.
//!
//! `--copy` times a plain frame copy instead: the price of the memory
//! traffic alone, as a reference floor for the scoreboard.
use anyhow::{Result, ensure};
use clap::Parser;
use sha2::{Digest, Sha256};
use std::time::Instant;
/// Windows/zip blur: median frame time + output digest.
#[derive(Parser)]
struct Args {
/// Frame width in pixels
#[arg(long, default_value_t = 3840)]
width: usize,
/// Frame height in pixels
#[arg(long, default_value_t = 2160)]
height: usize,
/// Timed repetitions; the median is reported
#[arg(long, default_value_t = 30)]
reps: usize,
/// Time a plain frame copy instead of the blur (traffic reference)
#[arg(long)]
copy: bool,
}
/// Same [1, 4, 6, 4, 1] × 16 kernel as scene 01.
const WEIGHTS: [i32; 5] = [16, 64, 96, 64, 16];
/// Deterministic frame: xorshift-filled, identical to scene 01's.
fn synth_frame(len: usize) -> Vec<u8> {
let mut state: u64 = 0x2A11_2A11_2A11_2A11;
let mut frame = vec![0_u8; len];
for px in frame.iter_mut() {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
*px = (state >> 56) as u8;
}
frame
}
#[inline(never)]
fn blur_frame_windows(src: &[u8], dst: &mut [u8], width: usize, weights: &[i32; 5]) {
for (srow, drow) in src.chunks_exact(width).zip(dst.chunks_exact_mut(width)) {
drow[..2].copy_from_slice(&srow[..2]);
drow[width - 2..].copy_from_slice(&srow[width - 2..]);
for (out, win) in drow[2..width - 2].iter_mut().zip(srow.windows(5)) {
let acc = win[0] as i32 * weights[0]
+ win[1] as i32 * weights[1]
+ win[2] as i32 * weights[2]
+ win[3] as i32 * weights[3]
+ win[4] as i32 * weights[4];
*out = (acc >> 8) as u8;
}
}
}
#[inline(never)]
fn copy_frame(src: &[u8], dst: &mut [u8]) {
dst.copy_from_slice(src);
}
fn main() -> Result<()> {
let args = Args::parse();
ensure!(args.width >= 8, "frame too narrow for a 5-tap kernel");
ensure!(args.reps >= 1, "need at least one repetition");
let src = synth_frame(args.width * args.height);
let mut dst = vec![0_u8; src.len()];
let run: &dyn Fn(&[u8], &mut [u8]) = if args.copy {
&|s, d| copy_frame(s, d)
} else {
&|s, d| blur_frame_windows(s, d, args.width, &WEIGHTS)
};
for _ in 0..3 {
run(&src, &mut dst);
}
let mut samples = Vec::with_capacity(args.reps);
for _ in 0..args.reps {
let started = Instant::now();
run(&src, &mut dst);
samples.push(started.elapsed().as_secs_f64() * 1e3);
}
samples.sort_by(|a, b| a.total_cmp(b));
let median = samples[samples.len() / 2];
let megapixels = (args.width * args.height) as f64 / 1e6;
let budget = 1000.0 / 60.0;
let label = if args.copy { "plain copy (traffic reference)" } else { "windows+zip, i32 arithmetic" };
println!("kernel: {label}");
println!("frame: {} × {} ({megapixels:.1} MP)", args.width, args.height);
println!("median: {median:8.3} ms/frame ({} reps)", args.reps);
println!("60 fps: budget {budget:.2} ms -> {:.0}% used", median / budget * 100.0);
println!("sha256: {:x}", Sha256::digest(&dst));
Ok(())
}Test system: Intel i7-10750H (Comet Lake, 6 cores / 12 threads, AVX2), 62 GB RAM, Fedora 43; Rust 1.97.1, release build with LTO and codegen-units = 1; baseline target: x86-64 (SSE2), compared with -C target-cpu=x86-64-v3 (AVX2); frame: synthetic deterministic 3840×2160, 8.3 MP, grayscale. All terminal blocks come from a single recorded session and have not been edited.