Note that all these arre just surface-level observations and I haven't dug deep enough (yet).
It seems like reducing the amount of branching in a loop gives the compiler a better chance to perform loop unrolling. Consider a loop that checks the value of an index prior to performing any other operation:
for (index, x) in xs.iter().enumerate() {
if index != mid {
if *x < pivot {
left += x;
} else {
right += x;
}
}
}The following code is functionally equivalent but is written in such a way that it is not necessary to check the value of the indexes during each loop. The compiler can do loop unrolling in this case:
for x in xs.iter().take(mid) {
if *x < pivot {
left += x;
} else {
right += x;
}
}
for x in xs.iter().skip(mid + 1) {
if *x < pivot {
left += x;
} else {
right += x;
}
}This example already is unrolled by the compiler:
#[no_mangle]
pub fn sort_around_pivot(xs: &[i32]) -> i32 {
let mid = xs.len() / 2;
let pivot = xs[mid];
let left: i32 = xs.iter().filter(|i| **i < pivot).sum();
let right: i32 = xs
.iter()
.enumerate()
.filter(|(index, i)| *index != mid && **i >= pivot)
.map(|(_, i)| i)
.sum();
left + right
}At a glance, it doesn't seem like this is much more optimized:
#[no_mangle]
pub fn sort_around_pivot(xs: &[i32]) -> i32 {
let mid = xs.len() / 2;
let pivot = xs[mid];
let left: i32 = xs.iter().filter(|i| **i < pivot).sum();
let right_till_mid: i32 = xs.iter().take(mid).filter(|i| **i >= pivot).sum();
let right_from_mid: i32 = xs.iter().skip(mid + 1).filter(|i| **i >= pivot).sum();
left + right_till_mid + right_from_mid
}Since Compiler Explorer requires to work on non-mangled functions, and generics must be mangled, it is necessary to work with non-generic function. Furthermore, it is much easier to read and analyze code that is not operating on complicated types and, thus, it is easier to have a function that returns an i32 rather than a Vec<i32>.
The benchmark results show that it is better to use less iterators (i.e., left and right) rather than the implementation with right_till_mid and right_from_mid.
The benchmarks show that whatever additional cost is associated with using additional threads is not worth it. The dissassembly of a simpler example shows that there is a significant amount of extra code whenever working with multiple threads:
use std::thread;
#[no_mangle]
pub fn sort_around_pivot(xs: &[i32]) -> (i32, i32) {
let mid = xs.len() / 2;
let pivot = xs[mid];
let mut before_mid_left = 0;
let mut before_mid_right = 0;
let mut after_mid_left = 0;
let mut after_mid_right = 0;
let (before_mid, after_mid) = xs.split_at(mid);
let after_mid = &after_mid[1..];
thread::scope(|s| {
s.spawn(|| {
for x in before_mid {
if *x < pivot {
before_mid_left += x;
} else {
before_mid_right += x;
}
}
});
s.spawn(|| {
for x in after_mid {
if *x < pivot {
after_mid_left += x;
} else {
after_mid_right += x;
}
}
});
});
(
before_mid_left + after_mid_left,
before_mid_right + after_mid_right,
)
}