Performance question if let Some(Option) vs. is_empty()

Hi,

why this code

let path: Vec<(u8, u8)>;
let x: &mut u8;

if let Some(path) = path.last() {
    *x = path.1;
} else {
    *x = 0;
}

becomes faster than a len-check like

if !path.is_empty() {
    *x = path.last().unwrap().1;
} else {
    *x = 0;
}

The is_empty() is a little, mini bit slower...
Becomes the Option-syntax precompiled code (however this should work) while there is a function-call for is_empty()?

I've to say both code ran through default cargo test settings.

Do you mean cargo test? How do you measure times? By default, cargo test uses a debug build without optimization; measuring the performance of non-optimized code if of course pointless.

Also single runs can be unpredictable – consider using a benchmarking library such as criterion.

And in any case, small changes can also “randomly” appear due to effects of code-layout on caching and such. So if we would assume that the two versions of the code ought to be the same efficiency, there can still appear be a small difference.


In fact for this concrete case I would indeed assume that both versions are as fast as the other.

Function calls are usually irrelevant at run-time. Short functions such as last or is_empty on Vec will most likely always be inlined (if optimizations are turned on). Both is_empty and last check if the vector is empty, and the unwrap conditionally panics, so the compiler does have to do some reasoning about the length of the vector staying the same to remove redundant checks and the panic path, otherwise the is_emtpy version will be slightly slower.

Note that the first version with if let Some(path) = is more idiomatic/readable, since you avoid the need for an unwrap (though you could consider not naming both the whole Vec and its last item as “path” to make the code even more readable).

1 Like

Not really, the question is why this code has different run times in non-optimized code. Or better why the if let is faster...

Well, it is reproducable on every run, around 0.01-0.03 seconds difference in every run (it loops over this code the same few hundred times) and always the same (is_empty()) variant slower.

Could be the explanation...

Or this...
Although I would expect that the non-optimized built would also know about the vec length (is_empty check) and there is no chance to panic. The borrow checker knows that there is no modification of the vec in the meantime...
Or does the compiler put in this non-callable panic checks although it makes absolutely no sense?
I thought the unwrap() would be syntactic sugar here.

It does, because without optimizations it doesn't even check if they make sense or not. There's an unwrap - well, there must be panicking branch. Some optimization has proved it to be unreachable? Very well, we'll remove it - but not earlier.

4 Likes

Why would you expect them to be the same in non-optimized code? Non-optimized code is just that.

1 Like

Hi there,

If the optimiser changes nothing, I would guess the is is_empty version to be a little slower because it does more work than the Some version. The check to see whether the path is empty is presumably already included in the implementation of last(), so the code is making a redundant call.

Optimization is a complex subject, and I am no expert. Perhaps someone could expect the optimiser to spot this redundancy, and it may (under certain conditions) do some magic here. But I think non-redundant code is more likely to be optimised. In your case, the Some version is cleaner.

If you are doing benchmarks, another alternative you can consider is a map_or approach:

 *x = path.last().map_or(0,|e| e.1);
1 Like

Don't guess, just see. All three produces identical machine code on release build at x86_64.

5 Likes

This is excellent news and a cool online tool!