clz and ctz are instructions that compute the number of leading (trailing) zero bits in a fixed-size integer. They are natively supported by modern CPUs, though they are not always fast, e.g. tzcnt has a latency of 3 on Arrow Lake.
I used ctz in an FPU emulator I’m working on, but figured out how to avoid it with floating-point trickery, and I just realized that this generalizes to a vectorizable ctz polyfill in a round-about way. I also implemented clz for completeness.
clz
Let’s start with clz, the easier of the two:
fn clz(x: u32) -> u32 {
let a = 2.0f64.powi(-970);
32 - ((f64::from_bits(a.to_bits() | x as u64) - a).to_bits() >> 52) as u32
}
The general idea goes like this:
Floating-point exponents are biased logarithms of their values. By substituting a 32-bit number clz can be computed with a bitwise subtraction. From that, clz behaves correctly for
We need
Assuming the inputs and outputs are stored in u64x4, this compiles to:
vpbroadcastq ymm1, [rip + bias]
vorpd ymm0, ymm0, [rip + a]
vsubpd ymm0, ymm0, [rip + a]
vpsrlq ymm0, ymm0, 52
vpsubq ymm0, ymm1, ymm0
a:
.quad 0x350000000000000, 0x350000000000000, 0x350000000000000, 0x350000000000000
bias:
.quad 32
On my Haswell, this runs at ctz, you’re probably doing something wrong).
Ian Qvist tested this on Alder Lake (thanks!) and got
AMD CPUs make lzcnt so cheap that a scalar version will likely win. Though keep in mind that Zen CPUs support AVX-512, which has vplzcntd, so that’s an option, too.
Added on October 12:
I found a full 8-lane clz implementation by aqrit and optimized it further. The idea is to use i32 f32 conversion to produce a float instead of building one by hand, so that integers wider than f32.
This is tricky due to rounding, e.g. x &= !(x >> 1) prior to conversion, which clears the bit below the topmost one to ensure rounding doesn’t change the exponent. But I found a better way:
__m256i cleared = _mm256_min_epu8(x, _mm256_set1_epi8(0x80));
__m256i exp = _mm256_srli_epi32(_mm256_castps_si256(_mm256_cvtepi32_ps(cleared)), 23);
return _mm256_min_epi16(_mm256_subs_epu16(_mm256_set1_epi32(0x9e), exp), _mm256_set1_epi32(32));
All we really need to do is avoid having max with 0x80, which reduces 0xff to 0x80 without affecting the clz.
There are some other tricky details that the original algorithm already handles in a clever manner, like sign-extension, but I won’t focus on them here.
This algorithm runs at min for more perf.
I’m still not sure how to handle clz, since x86 doesn’t support packed i64 f64 conversion without AVX-512.
ctz
Now for ctz:
fn ctz(x: u32) -> u32 {
let a = f64::from_bits((0x340000100000001 ^ x as u64) ^ (x as u64 + u32::MAX as u64))
- f64::from_bits(0x340000000000000);
(a.to_bits() >> 52) as u32
}
We start with ctz equals the logarithm of that value, which we determine by adding ctz. We pre-mix
This function compiles to:
vpxor ymm1, ymm0, [rip + c1]
vpaddq ymm0, ymm0, [rip + u32_max]
vpxor ymm0, ymm1, ymm0
vsubpd ymm0, ymm0, [rip + c2]
vpsrlq ymm0, ymm0, 52
c1:
.qword 0x340000100000001, 0x340000100000001, 0x340000100000001, 0x340000100000001
u32_max:
.qword 0xffffffff, 0xffffffff, 0xffffffff, 0xffffffff
c2:
.qword 0x340000000000000, 0x340000000000000, 0x340000000000000, 0x340000000000000
On Haswell, this runs at clz is due to using one more instruction. It can be avoided by using vpternlogq if AVX-512 is present, but at that point you might as well run vpopcntd on (x - 1) & !x. The scalar version behaves no differently from clz.
Added on October 10:
Nikolay Malkovsky pointed out that de Bruijn sequences offer another vectorizable approach. After some testing, I arrived at the following code:
const char table[32] = {
0, 4, 5, 6, 11, 9, 7, 12, 15, 3, 10, 8, 14, 2, 13, 1,
0, 4, 5, 6, 11, 9, 7, 12, 15, 3, 10, 8, 14, 2, 13, 1,
};
__m256i bit = _mm256_andnot_si256(x, _mm256_sub_epi32(x, _mm256_set1_epi32(1)));
__m256i high = _mm256_madd_epi16(
_mm256_cmpeq_epi16(bit, _mm256_set1_epi16(-1)),
_mm256_set1_epi16(-16)
);
__m256i index = _mm256_srli_epi32(_mm256_mullo_epi32(bit, _mm256_set1_epi32(0xf0a6f0a7)), 28);
__m256i low = _mm256_shuffle_epi8(_mm256_loadu_si256((__m256i*)table), index);
return _mm256_add_epi32(low, high);
We can’t use a true 32-byte LUT because vpshufb cannot cross 16-byte lanes. The approach I used instead is tricky to explain, but essentially we use a 16-bit de Bruijn sequence repeated twice to compute bits 0-3 of the ctz, and then add Six seven 0xf0a6f0a7 is one of only four magic constants that make this work.
This takes
If you don’t need to deal with
__m256i high = _mm256_and_si256(
_mm256_cmpgt_epi32(bit, _mm256_set1_epi32(0x7fff)),
_mm256_set1_epi32(16)
);
brings the time down to
Added on October 11:
arya dradjica and I brainstormed a 64-bit version of the algorithm and managed to find what I think is the optimal algorithm:
const char table[32] = { // x -> 4 - popcnt(x)
4, 3, 3, 2, 3, 2, 2, 1, 3, 2, 2, 1, 2, 1, 1, 0,
4, 3, 3, 2, 3, 2, 2, 1, 3, 2, 2, 1, 2, 1, 1, 0,
};
__m256i lut = _mm256_loadu_si256((__m256i*)table);
__m256i bit = _mm256_andnot_si256(x, _mm256_sub_epi64(x, _mm256_set1_epi64x(1)));
__m256i low = _mm256_shuffle_epi8(lut, bit);
__m256i high = _mm256_shuffle_epi8(lut, _mm256_srli_epi16(bit, 4));
return _mm256_sad_epu8(_mm256_add_epi8(low, high), _mm256_set1_epi8(8));
This correctly returns (x - 1) & !x separately (inverted because of some idiosyncrasies of pshufb) and then uses vpsadbw to perform the horizontal sum and correct the inversion in one go.
vpsadbw can only horizontally sum exactly
Added on October 12:
More progress! u/UnalignedAxis111 mentioned that floating point-based approaches can be extended to the full 32/64-bit lengths by using int-to-float conversion instructions instead of bit twiddling. This can be slower, but it might be faster than de Bruijn. There are quite a few nuances:
- It introduces rounding issues, but for
ctzthat’s not a problem, becausex & -xhas only one bit set. - x86 supports signed-int-to-float casts but not unsigned-int-to-float, which causes problems for
. - It doesn’t allow tweaking the exponent bias.
After some brainstorming, I got this for 32-bit ctz:
__m256i bit = _mm256_andnot_si256(_mm256_sub_epi32(x, _mm256_set1_epi32(1)), x);
__m256i exp = _mm256_srli_epi32(_mm256_castps_si256(_mm256_cvtepi32_ps(bit)), 23);
return _mm256_min_epu8(_mm256_sub_epi32(exp, _mm256_set1_epi32(0x7f)), _mm256_set1_epi32(32));
This starts by extracting the biased exponent exp of !x & (x - 1), which is:
0x7fwhen0x80when0x81when- …
0x9dwhen0x19ewhen(since is interpreted as !) 0x00when(because that corresponds to )
I managed to map this to ctz in just two instructions:
exp - 0x7fmaps most inputs to the correctctz, but gives0x11finstead of0x1fand-0x7f(0xffffff81) instead of. - Per-byte
minwith= 00 00 00 1fclears the second byte of0x11fand maps the bottom byte of-0x7ftoat the same time.
On my Haswell, this takes
This can’t be extended to the i64 f64 conversions. For that, use the vpsadbw approach from yesterday.
P.S.: I find it funny that the