Improving on std:count_if()'s auto-vectorization
nicula.xyz
nicula.xyz
But the overly specific constraint means this is not a general count_if algorithm.
For this to be useful I have to: - know there are only 255 true values - but have a large dataset so it’s worth optimizing - not want to stop early when some threshold is met
This is so specialized it’s not even worth having a generic predicate argument for.
Many programming languages/frameworks expose this operation as `reduce()`.
let arr = [ 1, 3, 4, 6,7, 0, 9, -4];
let n_evens = arr.iter().fold(0, |acc, i| acc + (i & 1 == 0) as usize);
assert_eq!(n_evens, 4);
Or more generally, fn count_if<T>(it: impl Iterator<Item=T>, pred: impl Fn(&T) -> bool) -> usize {
it.fold(0, |acc, t| acc + pred(&t) as usize)
}This is the same argument as why have count_if if I can write a for loop.
The wrapping version uses vpandn + vpaddb (i.e. `acc += 1 &~ elt`). On Intel since Haswell (2013) on ymm inputs that can manage 1.5 iterations per cycle, if unroll 2x to reduce the dependency chain.
Whereas vpsadbw would limit it to 1 iteration per cycle on Intel.
On AMD Zen≤2, vpsadbw is still worse, but Zen≥3 manages to have the two approaches be equal.
On AVX-512 the two approaches are equivalent everywhere as far as uops.info data goes.
I've looked at the generated assembly for such a solution and it doesn't look great. I'm expecting a significant speed penalty, but I haven't had the time to test it today. Will probably do so tomorrow.
This is still lower than optimal because the compiler will reduce to a uint8. Both SSE2 and NEON support reducing to a wider value by _mm_sad_epu8 and vpadal_u8, respectively. This allows for 255 iterations in the inner loop instead of 15 or 7.
I wrote the code that you suggested (LMK if I understood your points): https://godbolt.org/z/jW4o3cnh3
And here's the benchmark output, on my machine: https://0x0.st/8SsG.txt (v1 is std::count_if(), v2 is the optimization from my blog post, and v3 is what you suggested).
v2 is faster, but v3 is still quite fast.
"It's also debatable whether or not Clang's 'optimization' results in better codegen in most cases that you care about. The same optimization pass can backfire pretty easily, because it can go the other way around too. For example, if you assigned the `std::count_if()` result to a local `uint8_t` value, but then returned that value as a `uint64_t` from the function, then Clang will assume that you wanted a `uint64_t` accumulator all along, and thus generates the poor vectorization, not the efficient one."
Interestingly, if the local variable is "volatile uint8_t", the optimisation is applied. Perhaps with an uint8_t local variable and size_t return value, an earlier optimisation removes the cast to uint8_t, because it only has an effect when undefined behaviour has been triggered? It would certainly be interesting to investigate further.
In general I agree that being more explicit is better if you really care about performance. It would be great if languages provided more ways to specify this kind of thing. I tried using __builtin_expect to trigger this optimisation too, but no dice.
Anyway, thanks for the interesting article.
So the case that you described has 2 layers. The internal std::count_if() layer, which has a 64-bit counter, and the 'return' layer of the count_even_values_v1() function, which has an 8-bit type. In this case, Clang propagates the 8-bit type from the 'return' layer all the way to the inner std::count_if() layer, which effectively means that you're requesting an 8-bit counter, and thus Clang generates the efficient vectorization.
However, say that you have the following 3 layers: (1) internal std::count_if() layer with a 64-bit counter; (2) local 8-bit variable layer, to which the std::count_if() result gets assigned; (3) 'return' layer with a 64-bit type. In this case the 64-bit type from layer 3 gets propagated to the inner std::count_if() layer, which will lead to a poor vectorization. Demo: https://godbolt.org/z/Eo13WKrK4 . So this downwards type-propagation from the outmost layer into the innermost layer doesn't guarantee optimality. In this case, the optimal propagation would've been from layer 2 down to layer 1 and up to layer 3.
Note: I'm not familiar with how the LLVM optimization pass does this exactly, so take this with a huge grain of salt. Perhaps it does indeed 'propagate' the outmost type to the innermost layer. Or perhaps the mere fact that there are more than 2 layers makes the optimization pass not happen at all. Either way, the end result is that the vectorization is poor.
This optimisation is applied by AggressiveInstCombinePass, after the function has been completely inlined. In cases where it is applied, the i64 result of the count is truncated to i8, and this gets propagated to the counter.
In the case where the result is assigned to a local variable, an earlier pass (before inlining) turns a truncate (for the cast) followed by a zero extend (for the return) into an and with 0xff. This persists, and AggressiveInstCombinePass then doesn't propagate this to the counter.
I've posted some selected bits of LLVM IR here:
https://gist.github.com/tomjnixon/d205a56ffc18af499418965ab7...
These come from running clang with "-mllvm=-print-after-all" and grepping for "^define.*_Z20count_even_values_v1RKSt6vectorIhSaIhEE"
This is why i don't see this as an optimisation pass "backfiring" or "go[ing] the other way around" (well, except for the "trunc,extend->and" one which we weren't talking about). Rather, it's just an optimisation not being applied. That might just be a language thing.
I modified the footnote to get rid of the misleading statements regarding the 'backfiring' of the optimization. :)
> with an uint8_t local variable and size_t return value, an earlier optimisation removes the cast to uint8_t, because it only has an effect when undefined behaviour has been triggered
In this case, there is no undefined behaviour, because a narrowing cast to an unsigned type is well-defined. So, this could never have been a good explanation.
After the loop it will do a horizontal add across the vector register to produce the final scalar result.
Should be ++it. Post-increment is generally more expensive, especially when you don't know the exact type you're applying it to.
Then again, it doesn't hurt to be pedantic.
std::thing::foo.bar + (std::baz.::std::other::quux)
And if you're stupid enough to just write foo.bar + baz.quux well that's nonsense and the compiler diagnostics won't have any suggestions for how to fix it, what a buffoon you are.I really don't enjoy this, like the trend for "narrative" cookery recipe style documentation where we're shown how to Quux a Baz [often with fragments that don't compile] in the library but given no indication whether we can Quux a Doodad or even whether that's a feature of the library's Baz or Quux or really what's going on, but hey, the author got to tell us about their trip to Tuscany so that's nice. Javadoc isn't perfect, but it's so much better than this.