用一个“没用”的 if 语句将代码性能提升至原来的四倍
Quadrupling code performance with a "useless" if

原始链接: https://purplesyringa.moe/blog/quadrupling-code-performance-with-a-useless-if/

作者描述了一种针对特定领域压缩器的优化方法,重点在于通过重构最优编码序列的后处理循环来提升性能。 原始循环 `j = next_j[i][j]` 看起来是最优的,但实际上受限于延迟。由于每次迭代都依赖于上一次的结果,CPU 无法并行执行该循环。 为了克服这一限制,作者引入了一种“分支”方法,利用了 CPU 的分支预测功能。通过将赋值操作包裹在 `if (j != next_j[i][j])` 语句中,CPU 会推测性地认为 `j` 保持不变,从而实现吞吐量受限的执行。当条件很少满足时,处理器运行速度会快得多;如果预测失败,它只需回滚状态即可。 为了防止编译器将这个看似多余的条件优化掉,作者使用了 `volatile` 类型转换,强制编译器将该分支视为有意义的代码。基准测试表明,这种技术显著减少了执行时间(从 320µs 缩短至 80µs),证明了通过解锁指令级并行,强制使用分支代码有时可以优于理论上最优的无分支代码。

Hacker News 最新 | 过往 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 通过一个“无用”的 if 语句将代码性能提升至四倍 (purplesyringa.moe) 23 分,发布者:birdculture,56 分钟前 | 隐藏 | 过往 | 收藏 | 2 条评论 帮助 anirudhak47 11 分钟前 | 下一条 [–] 延迟优化是一项技能。我很喜欢你深入到公共子表达式消除(CSE)优化的过程。我自己也曾编写过多个编译优化通道,以实现尽可能低的延迟。回复 anematode 25 分钟前 | 上一条 [–] 太棒了!之前从没见过这种技术。回复 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系 搜索:
相关文章

原文

So I was optimizing a domain-specific compressor the other day, as one does.

One important problem was chunking the input string and optimally choosing the most compact encoding for each chunk (different encodings compress different characters better, so where to split is not immediately obvious). The previous post describes the algorithm if you’re interested, but it boils down to finding the shortest path on a grid. For each cell, the algorithm computes the best cell following it. Following references from the first cell to the last one gives the optimal coding order.

uint8_t next_j[n_symbols][8]; 



__m128i best_path_length = _mm_setzero_epi16();
for (int i = n_symbols - 1; i >= 0; i--) {
    __m128i tmp = _mm_add_epi16(cost[i], best_path_length);
    __m128i minpos = _mm_minpos_epu16(tmp);
    __m128i cost_without_switching = _mm_sub_epi16(tmp, _mm_broadcastw_epi16(minpos));
    __m128i cost_with_switching = _mm_set1_epi16(switch_cost);
    best_path_length = _mm_min_epu16(cost_without_switching, cost_with_switching);
    __m128i choice = _mm_blendv_epi8(
        _mm_set1_epi16(_mm_extract_epi16(minpos, 1)),
        _mm_set_epi16(7, 6, 5, 4, 3, 2, 1, 0),
        _mm_cmpeq_epi16(best_path_length, cost_without_switching)
    );
    _mm_storeu_si64(&next_j[i], _mm_packs_epi16(choice, choice));
}



uint8_t encoding[n_symbols];
uint8_t j = 0; 
for (int i = 0; i 

That long loop is not the topic of this post, it’s well-optimized. We’re here to talk about the second loop, which at first glance looks much simpler.

Latency

Excluding the write, the body of the loop is just j = next_j[i][j], which compiles to a single mov instruction. How could this possibly not be optimal?

If we were programming in 1984, it would be, but modern processors have instruction-level parallelism – that is, they can execute multiple instructions in parallel. This works even across iterations of a loop, and it’s one reason why we usually don’t pay attention to instructions for i and i++ when evaluating loop performance – they don’t usually prevent the CPU from doing more work.

Crucially, though, you cannot run two dependent instructions at the same time. In our case, each iteration of the loop cannot begin before the previous iteration ends because j is threaded through the loop, so we’re limited by the latency of memory access, which is pretty noticeable even with cache.

Can this be fixed? In this specific case, yes! We don’t expect too many chunks, so next_j[i][j] is quite likely to just be equal to j. If we could tell the CPU to predict that j stays intact, the loop would become throughput-bound rather than latency-bound.

While we don’t have direct control over address prediction, we can simulate this with branch prediction:

for (int i = 0; i if (j != next_j[i][j]) {
        j = next_j[i][j];
    }
    encoding[i] = j;
}

If the CPU predicts the if body as unlikely, it will ignore it and thus not see any dependency between different iterations. When the condition eventually evaluates to true, branch misprediction resolution will kick in, undo wrong speculative writes, and restart with the right j. That’s exactly what we want!

Lies to compilers

The only issue is that from the perspective of the compiler, this if is completely useless. If j was in memory, it would avoid possibly writing to read-only memory, but it’s in a register. Unlike most other cases where we’d reach for compiler hints, we want to convert branchless code to branchy, not the other way round – and no compiler supports that, least of all for code that any CSE pass will remove without a second thought! Stupid compiler doesn’t realize integers have hardware provenance.

The only way to implement this that I’m aware of is with a cast to volatile to make it seem like the condition and the assignment are independent:

for (int i = 0; i if (j != next_j[i][j]) {
        j = *(uint8_t volatile *)&next_j[i][j];
    }
    encoding[i] = j;
}

In an synthetic benchmark, this change has sped up the loop from 320 us to 80 us on my data. (This doesn’t look like much, but the loop runs many times during compression, so it adds up.)

In a more realistic experiment, I only witnessed a 2× increase, most likely due to suboptimal codegen by LLVM. Still worthwhile, though!

Sidenote

Interestingly, in this algorithm specifically, each next_j[i][j] can only be one of two values – either j (most often), or some value dependent only on i, but not j. So I could replace each 8-element array next_j[i] with that value paired with a bitmask, which would automatically make the if semantically important and remove the need for volatile shenanigans. But that would likely slow down the code, since testing a variable bit is slower than a comparison (at least on x86).

联系我们 contact @ memedata.com