【问题标题】:String length function is unstable字符串长度函数不稳定
【发布时间】:2021-04-22 20:28:34
【问题描述】:

所以我不久前做了这个 strlen,一切看起来都很好。但是我开始注意到我的代码库中的错误,过了一段时间我将其追踪到这个 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);
            }
        }
    }

【问题讨论】:

  • 如果 *(data - 1) == '\0' 并且数据未对齐,那么我认为您的循环会立即终止,但字符串可能会更长。
  • 我认为解决此问题的最简单方法是仅使用未对齐的加载(这实际上是否慢得多?)或对第一个 32 个字符块执行单个未对齐的加载并测试终止字符,然后在循环中移动到对齐的负载,但从数据之后的对齐位置开始。
  • 这适用于 x86_64 吗?
  • @JamesGriffin:除非您检查是否接近页面末尾,否则您不能使用未对齐的加载。如果您将一个指针传递给 C strlen,则它必须正确工作,该指针指向距离页面末尾仅 5 个字节的 3 字节字符串,并且下一页未映射。 Is it safe to read past the end of a buffer within the same page on x86 and x64?。正确处理这一切是诀窍的一部分。例如,参见 glibc 的 strlen 中的手写 asm。在第一个未对齐的向量可以工作之前进行页面交叉检查,然后对齐(重叠很好)
  • 什么样的错误?代码又好又短,但我们要寻找什么症状?错误的结果?段错误? minimal reproducible example 的一个重要部分是一个特定的问题描述,比如一个失败的测试用例,但如果你没有它,你至少可以说它是否在这段代码中出现了段错误。 (如果是这样,请使用调试器来查找详细信息。)虽然由于您正在对齐指针(我认为是正确的),但这是不可能的,因为我认为您也会找到一个终止符(如果有的话)。

标签: c simd memory-alignment avx strlen


【解决方案1】:

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

  • 如果对齐的加载找到任何零字节,则退出循环,即使它们来自字符串的正确开始之前。 (@James Griffin 在 cmets 中发现了这一点)。您需要执行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 微指令,不能与分支指令进行宏融合。但是vpmovmskb eax, ymm0 是 1 uop,test eax,eax / jz .loop 是另一个宏融合的 uop。更好的是,您实际上需要循环后的整数移动掩码结果,所以您已经有了它。


相关

  • 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。和 cmets 进行基准测试。

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

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

【讨论】:

    【解决方案2】:

    没有冒犯,但是

    size_t strlen(char *p)
    {
        size_t ret_val = 0;
    
        while (*p++) ret_val++;
    
        retirn ret_val;
    }
    

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

    【讨论】:

    • 不幸的是 today's optimizing compilers 为它生成了非常天真的代码,一次检查一个字节。
    • 向我解释如何在内存中找到空字节而不检查指针参数和第一个找到的\0 字符之间的完整字节集。如果您必须查看所有这些,那么除了使用所有内核并并行化操作之外别无选择,但仅此而已。
    • SIMD 算法(如一个 OP 正在研究)以及标准库实现中使用的 SIMD 算法的全部要点是,您可以使用几条 AVX 指令一次检查 32 个字节,仍然只使用一个核心。这比循环中的 32 个cmpb 快​​得多。但编译器不会为您执行此操作,可能是因为需要对齐游戏。
    • 但是你可以!这是这里的另一个重要技巧。在低级别上,从\0 之后的内存加载是安全的,前提是您不跨越页面边界 - 段错误仅在触摸未映射的页面时发生,因此请确保您不要访问任何您不会访问的页面。对齐是确保这里的原因。一个 32 字节对齐的加载永远不会跨越页面边界,我们知道这 32 个字节中的 第一个 字节是可以触摸的(因为之前没有看到 \0),所以整个32 字节可以安全加载。
    • 这样做的行为在 C 级别是未定义的,但是 x86-64 架构完美地定义了它:如果访问包含不存在的页面,则会出现页面错误,否则您将成功读取物理内存中发生的任何内容。所以我们只需要避免前者,而且我们知道页面是 4K 对齐的。例如,如果b 位于地址0x1234fffe,则OP 的代码将屏蔽低位并从地址0x1234ffe0 - 0x1234ffff 加载32 个字节,所有这些都与b 本身位于同一可访问页面上 - 没有页面错误.
    猜你喜欢
    • 2011-05-17
    • 2011-04-28
    • 2016-05-12
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2017-08-09
    • 2019-03-11
    相关资源
    最近更新 更多