循环优化的经验案例
本文我将通过一个故事形式逐步展示优化过程,讲述如何从原始版本逐步演进到你最终的优化版本,并进一步讨论潜在的改进。
第一阶段:原始代码的故事
一天,程序员小明接到了一个任务,要求他写一个函数来设置位集中的前 count
位。他马上想到了 std::bitset
,一个能够存储和操作位的方便工具。于是,小明写了一个简单的循环,逐个设置位:
void loop_bitset_set(std::bitset<N>& bits, int count) {
for (int i = 0; i < count; ++i) {
bits.set(i);
}
}
小明满意地运行了这段代码,发现它在处理小规模的数据时表现良好。但是当他需要处理大规模的 std::bitset
,例如 256 位甚至更大时,程序的运行速度明显下降。每次都要一个一个地设置位,开销很大,效率低下。
第二阶段:减少循环的故事
有一天,小明突然灵光一闪:“既然我可以一位一位地设置,那为什么不一次性设置更多位呢?比如一次设置 64 位!”小明知道,现代计算机处理数据时通常以 64 位为单位,所以他决定批量处理这些位。
于是,他写了一个改进版代码,每次处理 64 位:
void optimized_bitset_set(std::bitset<N>& bits, int count) {
int full_blocks = count / 64; // 完整的64位块数
int remaining_bits = count % 64; // 剩余不足64位的位数
// 设置完整的64位块
for (int i = 0; i < full_blocks; ++i) {
bits |= std::bitset<N>(~0ULL) << (i * 64);
}
// 设置剩余的位
for (int i = 0; i < remaining_bits; ++i) {
bits.set(full_blocks * 64 + i);
}
}
这样,小明发现循环次数大幅减少了,性能也得到了显著提升。因为他只需要循环 count / 64
次,而不是 count
次。不过,他还觉得能更进一步优化。
第三阶段:移位与按位操作的故事
随着项目的进展,小明学到了位运算的妙用。他意识到,之前的除法和取余操作(count / 64
和 count % 64
)其实可以通过更快速的位移(>>
)和按位与(&
)来实现。除法和取余通常开销较大,而位运算则非常快。
于是他将代码改成了:
void optimized_bitset_set_v2(std::bitset<N>& bits, int count) {
int full_blocks = count >> 6; // 等价于 count / 64
int remaining_bits = count & 63; // 等价于 count % 64
// 设置完整的64位块
for (int i = 0; i < full_blocks; ++i) {
bits |= std::bitset<N>(~0ULL) << (i * 64);
}
// 设置剩余的位
for (int i = 0; i < remaining_bits; ++i) {
bits.set(full_blocks * 64 + i);
}
}
这次优化让小明惊喜万分,程序运行速度进一步提升。他已经使用了位操作来优化算法的计算部分,减少了每次循环中的复杂操作。
第四阶段:预计算掩码的故事
接下来的任务让小明遇到了一些瓶颈。当需要设置剩余不足 64 位的那些位时,每次都要动态计算 (1ULL << remaining_bits) - 1
生成掩码。这是一种比较耗时的操作。
小明想到:“我能不能在程序开始时预先计算好这些掩码,然后在需要时直接查表呢?” 于是他预计算了 1 到 64 位的掩码,存放在一个数组中:
std::array<std::bitset<N>, 65> precomputed_masks = []() {
std::array<std::bitset<N>, 65> masks = {};
for (int i = 1; i <= 64; ++i) {
masks[i] = std::bitset<N>((1ULL << i) - 1); // 生成i位的掩码
}
return masks;
}();
接着,他修改了代码,让其使用预计算的掩码:
void optimized_bitset_set_v3(std::bitset<N>& bits, int count) {
int full_blocks = count >> 6; // 等价于 count / 64
int remaining_bits = count & 63; // 等价于 count % 64
// 设置完整的64位块
for (int i = 0; i < full_blocks; ++i) {
bits |= std::bitset<N>(~0ULL) << (i * 64);
}
// 使用预计算的掩码设置剩余的位
if (remaining_bits > 0) {
bits |= precomputed_masks[remaining_bits] << (full_blocks * 64);
}
}
这次优化不仅让剩余位的处理变得更加高效,而且大大简化了代码逻辑。小明发现,程序现在运行得飞快,特别是在需要频繁设置位的情况下,预计算掩码节省了大量时间。
第五阶段:编译期预计算的故事
小明的优化之路并没有停下。经过了循环优化、批量处理、位运算和掩码的预计算后,他开始思考,是否能进一步减少运行时的计算成本。他想到了C++中的constexpr
,这是一种能够在编译期进行计算的强大工具。通过constexpr
,程序在编译时就可以完成某些计算,避免了运行时的额外开销。
小明决定将之前的掩码生成过程改为编译期计算。这样,掩码在程序运行前就已经准备好,完全不需要在运行时生成。于是,他重构了代码,使用了constexpr
进行掩码的编译期预计算:
constexpr uint64_t create_mask(int bits) {
return (bits == 0) ? 0 : (create_mask(bits - 1) << 1) | 1;
}
// 预先计算1-64位掩码
constexpr std::array<uint64_t, 65> precompute_masks() {
std::array<uint64_t, 65> masks = {};
for (int i = 1; i <= 64; ++i) {
masks[i] = create_mask(i); // 生成 i 位的掩码
}
return masks;
}
// 编译期计算掩码数组
constexpr std::array<uint64_t, 65> precomputed_masks = precompute_masks();
// 优化版:使用预计算的掩码来设置位
void optimized_bitset_set_v4(std::bitset<N> &bits, int count) {
int full_blocks = count >> 6; // 等价于 count / 64
int remaining_bits = count & 63; // 等价于 count % 64
// 提前生成一个64位的掩码值
std::bitset<N> full_mask(~0ULL);
// 设置完整的64位块
for (int i = 0; i < full_blocks; ++i) {
bits |= full_mask << (i * 64);
}
// 如果有剩余位,直接使用预计算的掩码
if (remaining_bits > 0) {
bits |= precomputed_masks[remaining_bits] << (full_blocks * 64);
}
}
这样,掩码的生成在编译期就已经完成了。运行时,程序只需要直接使用这些已经生成的掩码,彻底避免了任何运行时的计算。
最后,小明开始研究如何利用现代处理器的 SIMD 指令进一步加速处理位块,使用如 AVX 指令集来一次性操作更多的数据块……
性能结果与加速系数
-
8位:
- 预计算掩码版耗时:900 ns
- 循环版耗时:7300 ns
- 加速系数 = 7300 / 900 ≈ 8.11
-
16位:
- 预计算掩码版耗时:1500 ns
- 循环版耗时:23900 ns
- 加速系数 = 23900 / 1500 ≈ 15.93
-
32位:
- 预计算掩码版耗时:1600 ns
- 循环版耗时:48800 ns
- 加速系数 = 48800 / 1600 ≈ 30.50
-
64位:
- 预计算掩码版耗时:1600 ns
- 循环版耗时:101600 ns
- 加速系数 = 101600 / 1600 ≈ 63.50
-
128位:
- 预计算掩码版耗时:1700 ns
- 循环版耗时:216400 ns
- 加速系数 = 216400 / 1700 ≈ 127.29
-
256位:
- 预计算掩码版耗时:1700 ns
- 循环版耗时:546500 ns
- 加速系数 = 546500 / 1700 ≈ 321.47
随着需要设置的位数增加,预计算掩码版本的优化效果变得更加显著。尤其在 64 位及以上时,加速系数呈现显著提升。例如在处理 256 位时,预计算掩码版的速度是循环版的 321 倍!
结语
在这一路优化的过程中,小明逐步学习并应用了许多编程技巧和优化策略:
- 减少循环次数:从逐位操作到批量处理 64 位,减少了循环的开销。
- 使用位操作:通过位移和按位与替代除法和取余,提升了计算效率。
- 预计算掩码:使用查表法避免重复计算,进一步优化了性能。
- 编译期计算:通过编译期计算,将部分运行时计算转移到编译时,减少了运行时开销。
- 硬件加速(SIMD):充分利用现代处理器的向量化能力,进一步压榨性能潜力。
小明的故事展示了优化的思路和方法,他通过一点点积累的优化技巧,最终写出了高效、快速的代码。在他的工作中,编程不仅是解决问题的工具,更是一门不断精进的艺术。