字符串长度函数不稳定

问题描述

所以我不久前做了这个,一切看起来都很好。但是我开始注意到我的代码库中的错误,一段时间后我将其追溯到这个 strlen 函数。我使用 SIMD 指令来编写它,而且我是编写内在函数的新手,所以代码可能也不是最好的。

功能如下:

inline size_t strlen(const char* data) {
        const __m256i terminationCharacters = _mm256_setzero_si256();
        const size_t shiftAmount = ((size_t)&data) & 31;
        const __m256i* pointer = (const __m256i*) (data - shiftAmount);

        size_t length = 0;

        for (;; length += 32,++pointer) {
            const __m256i comparingData = _mm256_load_si256(pointer);
            const __m256i comparison = _mm256_cmpeq_epi8(comparingData,terminationCharacters);

            if (!_mm256_testc_si256(terminationCharacters,comparison)) {
                const auto mask = _mm256_movemask_epi8(comparison);

                return length + _tzcnt_u32(mask >> shiftAmount);
            }
        }
    }

解决方法

您尝试将启动处理合并到对齐向量循环中至少有 2 个显示错误:

  • 如果对齐的加载找到任何零字节,则退出循环,即使它们来自字符串的正确开头之前。 (@James Griffin 在评论中发现了这一点)。您需要执行 mask >>= shiftAmount 并检查非零值,以查看在字符串开头之后的加载部分中是否有任何匹配项。 (不要使用 _mm256_testc_si256,只需移动掩码并检查)。

  • _tzcnt_u32(mask >> shiftAmount); 对于 之后的任何向量都是错误的。整个向量来自字符串开头之后的字节,因此您需要 tzcnt 来查看所有位。相反,我认为您想要 _tzcnt_u32(mask) - shiftAmount

在实际字符串之前但在第一个对齐的向量内使用 0 字节制作一些测试用例。测试用例的最终 0 位于相对于向量的不同位置,并且非零并针对 libc strlen 测试您的版本。 (甚至可能是前 32 个字节内的一些随机 0 位置,然后在此后的前 64 个字节内。)

如果您将其与循环分开,那么您处理未对齐启动的策略应该有效。 (Is it safe to read past the end of a buffer within the same page on x86 and x64?)。

另一个选项是在从字符串的实际开始加载第一个未对齐的向量之前进行页面交叉检查。 (但是你需要回退到别的东西)。然后对齐:重叠很好;只要正确计算最终长度,两次检查同一个字节是否为零都没有关系。


(您也真的不希望编译器在循环内浪费指令来增加一个指针一个单独的长度,因此请检查生成的 asm。循环后的指针减法应该做诀窍。甚至投射到uintptr_t
此外,您可以从初始函数 arg 中减去最终的零位置,而不是从对齐的指针中减去,因此不是减去 shiftAmount 两次,而是除了初始对齐之外根本不使用它。)

根本不要使用 vptest 内部函数 (_mm256_testc_si256),即使在您应该检查所有字节的主循环中; _mm_cmp* 结果并不是更好。 vptest 是 2 uop,不能与分支指令进行宏融合。但是 vpmovmskb eax,ymm0 是 1 uop,而 test eax,eax / jz .loop 是另一个宏融合的 uop。更妙的是,您实际上需要循环后的整数 movemask 结果,所以您已经有了它。


相关

  • Is it safe to read past the end of a buffer within the same page on x86 and x64?

  • Why does glibc's strlen need to be so complicated to run quickly?(包括指向 glibc 的 strlen 实现的手写 x86-64 asm 的链接。)除非您使用的平台具有较差的 C 库,否则通常您应该使用它,因为 glibc 使用动态链接期间的 CPU 检测,为您的 CPU 选择一个好的 strlen(和 memcpy 等)版本。 strlen 的未对齐启动有点棘手,我认为 glibc 做出了合理的选择,除非函数调用开销是一个大问题。它还具有针对大字符串的良好循环展开技术(例如 _mm256_min_epu8,如果 2 个输入向量中的任何一个具有零,则在向量元素中获得零,因此它可以在整个缓存中分摊实际的移动掩码/分支工作- 数据行)。不过,对于中等长度的字符串来说,它可能过于激进。

    请注意,glibc 的许可证是 LGPL,因此除非您的许可证兼容,否则您不能将代码从 glibc 复制到您的项目中。甚至编写与其 asm 等效的内在函数也可能存在问题。

  • Why is this code using strlen heavily 6.5x slower with GCC optimizations enabled? - 一个简单的 SSE2 strlen,它在手写 asm 中处理错位。以及对基准测试的评论。

  • https://agner.org/optimize/ - 指南和指令表,他的子程序库(手写 asm)包括一个 strlen。 (但请注意,它已获得 GPL 许可。)

我假设某些 BSD 和 MacOS 在更宽松的许可下具有 asm strlen,如果您的项目不兼容 GPL,您可以使用/查看。

,

无意冒犯

size_t strlen(char *p)
{
    size_t ret_val = 0;

    while (*p++) ret_val++;

    retirn ret_val;
}

很久以前就做得很好。此外,今天的优化编译器为它提供了非常紧凑的代码,您的代码无法阅读。