循环优化的经验案例

循环优化的经验案例

本文我将通过一个故事形式逐步展示优化过程,讲述如何从原始版本逐步演进到你最终的优化版本,并进一步讨论潜在的改进。


第一阶段:原始代码的故事

一天,程序员小明接到了一个任务,要求他写一个函数来设置位集中的前 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 / 64count % 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 指令集来一次性操作更多的数据块……


性能结果与加速系数

  1. 8位:

    • 预计算掩码版耗时:900 ns
    • 循环版耗时:7300 ns
    • 加速系数 = 7300 / 900 ≈ 8.11
  2. 16位:

    • 预计算掩码版耗时:1500 ns
    • 循环版耗时:23900 ns
    • 加速系数 = 23900 / 1500 ≈ 15.93
  3. 32位:

    • 预计算掩码版耗时:1600 ns
    • 循环版耗时:48800 ns
    • 加速系数 = 48800 / 1600 ≈ 30.50
  4. 64位:

    • 预计算掩码版耗时:1600 ns
    • 循环版耗时:101600 ns
    • 加速系数 = 101600 / 1600 ≈ 63.50
  5. 128位:

    • 预计算掩码版耗时:1700 ns
    • 循环版耗时:216400 ns
    • 加速系数 = 216400 / 1700 ≈ 127.29
  6. 256位:

    • 预计算掩码版耗时:1700 ns
    • 循环版耗时:546500 ns
    • 加速系数 = 546500 / 1700 ≈ 321.47

随着需要设置的位数增加,预计算掩码版本的优化效果变得更加显著。尤其在 64 位及以上时,加速系数呈现显著提升。例如在处理 256 位时,预计算掩码版的速度是循环版的 321 倍

结语

在这一路优化的过程中,小明逐步学习并应用了许多编程技巧和优化策略:

  1. 减少循环次数:从逐位操作到批量处理 64 位,减少了循环的开销。
  2. 使用位操作:通过位移和按位与替代除法和取余,提升了计算效率。
  3. 预计算掩码:使用查表法避免重复计算,进一步优化了性能。
  4. 编译期计算:通过编译期计算,将部分运行时计算转移到编译时,减少了运行时开销。
  5. 硬件加速(SIMD):充分利用现代处理器的向量化能力,进一步压榨性能潜力。

小明的故事展示了优化的思路和方法,他通过一点点积累的优化技巧,最终写出了高效、快速的代码。在他的工作中,编程不仅是解决问题的工具,更是一门不断精进的艺术。

  • 15
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值