可向量化的 CLZ 与 CTZ

Lobsters Hottest 论文

摘要

一篇技术博客,介绍如何利用浮点数指数相当于对数这一特性,用 AVX2 浮点指令实现可向量化的 clz(前导零计数)和 ctz(尾随零计数)多播(polyfill),并在 Haswell/Alder Lake 上给出优于标量版本的性能数据。

<p><a href="https://lobste.rs/s/9xcc2g/vectorized_clz_ctz">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/10/09 18:27

# 向量化 CLZ 与 CTZ 来源:https://purplesyringa.moe/blog/vectorized-clz-and-ctz/ 2026年10月9日 `clz` 和 `ctz` 是用于计算定长整数中前导(尾随)零位数量的指令。现代 CPU 原生支持这些指令,但它们并不总是很快,例如在 Arrow Lake 上,`tzcnt` 的延迟为 `3`。 我在一个正在开发的 FPU 模拟器中使用了 `ctz`,但后来发现可以借助浮点技巧来避免它,并且我刚刚意识到这可以以一种迂回的方式推广为一个可向量化的 `ctz` polyfill。为了完整性,我还实现了 `clz`。 ### clz 我们先从较简单的 `clz` 开始: ``` 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 } ``` 基本思路如下: 浮点数的指数是其数值的带偏移对数。将一个 32 位数字 x 替换到某个值 2^k 的尾数中,我们得到一个表示 2^k(1 + 2^-52·x) 的双精度浮点数。然后以双精度浮点数减去 2^k,得到 2^(k-52)·x。提取指数可得 k-52+31-clz(x),从中通过一次按位减法即可算出 `clz`。k 的取值需确保 `clz` 在 x=0 时也能正确工作。 我们需要 64 位双精度浮点数来处理 32 位输入;不幸的是,这意味着该技巧不能用于任意 64 位输入,最多只能处理 52 位。 假设输入和输出存储在 `u64x4` 中,编译结果如下: ``` 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 ``` 在我的 Haswell 上,这以每次迭代 0.45ns 运行,而标量版本为 1ns。当受延迟限制时,数字变为 2ns 对比 1ns(但如果你在向量化 `ctz` 上受延迟限制,那你大概哪里做错了)。 Ian Qvist 在 Alder Lake 上测试了这一点(感谢!),结果为每次迭代 0.29ns,而标量版本为 0.85ns;受延迟限制时为 1.3ns 对比 0.85ns。在现代 Intel CPU 上,结果应该相同或更好。 AMD CPU 使得 `lzcnt` 非常廉价,标量版本可能会胜出。但请记住 Zen 系列 CPU 支持 AVX-512,其中包含 `vplzcntd`,这也是一个选项。 ### ctz 接下来是 `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 } ``` 我们从 x ⊕ (x-1) 开始,以隔离最低的置位比特。`ctz` 等于该值的对数,我们通过按位加法加上 2^k 再以双精度浮点数减去 2^k 来确定它,然后检查指数——在精心选择的 k 下,指数中包含未偏移的 `ctz`。我们预先将 2^32 混入尾数,并用 x+2^32-1 代替 x-1 以正确处理 x=0 的情况,还预先将 1 混入尾数以确保奇数 x 产生 a=0 而非一个缓慢的非规格化数。(你能*想象*我花了多少时间来安排这一切吗?) 该函数编译为: ``` 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 ``` 在 Haswell 上,这以每次迭代 0.49ns 运行,受延迟限制时为 2.3ns。在 Alder Lake 上,为每次迭代 0.35ns,受延迟限制时为 1.3ns。与 `clz` 相比的减速是由于多用了一条指令。如果存在 AVX-512,可以通过使用 `vpternlogq` 来避免,但到了那个地步,你还不如直接对 `(x-1) & !x` 运行 `vpopcntd`。标量版本的表现与 `clz` 无异。 > **后补:** Nikolay Malkovsky (https://t.me/a_zachem_eto_nuzhno) 指出 de Bruijn 序列 (https://en.wikipedia.org/wiki/De_Bruijn_sequence) 提供了另一种可向量化的方法。经过一些测试,我得出了以下代码: ``` 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); ``` 我们无法使用真正的 32 字节查找表,因为 `vpshufb` 不能跨 16 字节 lane。我所采用的方法很难解释,但本质上我们使用一个重复两次的 16 位 de Bruijn 序列来计算 `ctz` 的第 0-3 位,然后根据哪一半全为零来加上 16 或 32。`0xf0a6f0a7` 是仅有的四个能使此方法有效的魔法常数之一。 在 Haswell 上这需要 1ns(在 Alder Lake 上 0.7ns),但吞吐量是其两倍,因此如果它有助于避免数据洗牌,可能比基于浮点的方法稍微快一些。

相似文章

Accelerating the Zig Tokenizer (with AVX-512)

Lobsters Hottest

Niles Salter 的演讲介绍如何利用 SIMD 数据并行与位运算原语(CTZ、TZCNT 等)加速 Zig 分词器,实现 820 MB/s 的吞吐量,并探讨 AVX-512 带来的进一步提速空间。

SAGE:推动 2 比特向量量化大语言模型走向实际应用

arXiv cs.AI

SAGE 提出一种面向 2 比特权重向量量化的方法,通过能量分层分组(ESG)与自适应比特分配(ABS),在 Qwen3-30B-A3B 上保留 BF16 平均性能的 95.3%,并借助自定义 CUDA 内核实现最高 2.55 倍的端到端加速。

LittleBit:通过潜在分解实现超低比特量化

Reddit r/LocalLLaMA

LittleBit 提出了一种通过低秩潜在矩阵分解与二值化实现超低比特(低至0.1 BPW)LLM 量化的框架,配合多尺度补偿机制,在 Llama2-7B 上以 0.1 BPW 超越了现有 0.7 BPW 方法,实现约 31 倍内存压缩与最高 11.6 倍推理加速。