【问题标题】:Missed optimization with string_view::find_first_of错过了 string_view::find_first_of 的优化
【发布时间】:2022-01-22 17:44:43
【问题描述】:

更新: 相关 GCC 错误报告:https://gcc.gnu.org/bugzilla/show_bug.cgi?id=103798

我测试了以下代码:

#include <string_view>

size_t findFirstE_slow(std::string_view sv) {
  return sv.find_first_of("eE");
}

size_t findFirstE_fast(std::string_view sv) {
  auto it{sv.begin()};
  for (; it != sv.end() && *it != 'e' && *it != 'E'; ++it)
    ;
  return it == sv.end() ? std::string_view::npos : size_t(it - sv.begin());
}

快速台架测试:https://quick-bench.com/q/dSU3EBzI8MtGOFn_WLpK3ErT3ok

编译器资源管理器输出:https://godbolt.org/z/eW3sx61vz

findFirstE_slow()firstFirstE_fast() 函数都打算做同样的事情,但 findFirstE_slow() 运行速度明显慢(在快速基准测试中至少慢 5 倍)。

这是x86-64 gcc (trunk) -std=c++20 -O3 的程序集输出。

findFirstE_slow():

.LC0:
        .string "eE"
findFirstE_slow(std::basic_string_view<char, std::char_traits<char> >):
        push    r12
        push    rbp
        push    rbx
        test    rdi, rdi
        je      .L4
        mov     rbx, rdi
        mov     rbp, rsi
        xor     r12d, r12d
        jmp     .L3
.L8:
        add     r12, 1
        cmp     rbx, r12
        je      .L4
.L3:
        movsx   esi, BYTE PTR [rbp+0+r12]
        mov     edx, 2
        mov     edi, OFFSET FLAT:.LC0
        call    memchr
        test    rax, rax
        je      .L8
        mov     rax, r12
        pop     rbx
        pop     rbp
        pop     r12
        ret
.L4:
        mov     r12, -1
        pop     rbx
        pop     rbp
        mov     rax, r12
        pop     r12
        ret

findFirstE_fast():

findFirstE_fast(std::basic_string_view<char, std::char_traits<char> >):
        add     rdi, rsi
        cmp     rdi, rsi
        je      .L13
        mov     rax, rsi
        jmp     .L12
.L15:
        add     rax, 1
        cmp     rdi, rax
        je      .L13
.L12:
        movzx   edx, BYTE PTR [rax]
        and     edx, -33
        cmp     dl, 69
        jne     .L15
        sub     rax, rsi
        ret
.L13:
        mov     rax, -1
        ret

有趣的是,findFirstE_slow()sv 中的每个字符调用memchr("eE", *current_char, 2)。 另一方面,findFirstE_fast() 通过将 sv 中的每个字符与 'e' 和 'E' 进行比较,实现了我们的合理预期。

Clang 生成类似的输出。

问题:这里有没有像我测试中的短字符串那样错过优化?我是否缺少让 GCC 生成更快代码的东西?

【问题讨论】:

    标签: c++ assembly gcc clang compiler-optimization


    【解决方案1】:

    libstdc++ 的 std::string_view::find_first_of 看起来像:

    size_type find_first_of(std::string_view v, std::size_t pos = 0) {
        if (v.empty()) return npos;
        for (; pos < size(); ++pos) {
            const char_type* p = traits_type::find(v.data(), v.size(), this->data()[pos]);
            if (p) return pos;
        }
        return npos;
    }
    

    您可以看到traits_type::find 是如何转换为memchr 的。

    问题的症结在于memchr("eE", this-&gt;data()[pos], 2) != nullptr 的编译方式与this-&gt;data()[pos] == 'e' || this-&gt;data()[pos] == 'E' 不同,尽管后者效率更高。

    您可以通过尝试编译来检查:

    constexpr unsigned char characters[] = "eE";
    
    bool a(unsigned char* p) {
        return __builtin_memchr(characters, *p, 2);
    }
    
    bool b(unsigned char* p) {
        return *p == characters[0] || *p == characters[1];
    }
    

    这是一个错过的优化,但您可以提示编译器不要将 memchr 与自定义特征类型一起使用:

    struct char_traits : std::char_traits<char> {
        static constexpr const char_type* find(const char_type* p, std::size_t count, const char_type& ch) {
            if (__builtin_constant_p(count) && count < 5) {
                switch (count) {
                    case 0: return nullptr;
                    case 1: return ch == *p ? p : nullptr;
                    case 2: return ch == *p ? p : ch == *++p ? p : nullptr;
                    case 3: return ch == *p ? p : ch == *++p ? p : ch == *++p ? p : nullptr;
                    case 4: return ch == *p ? p : ch == *++p ? p : ch == *++p ? p : ch == *++p ? p : nullptr;
                }
            }
            return std::char_traits<char>::find(p, count, ch);
        }
    };
    
    using string_view = std::basic_string_view<char, char_traits>;
    
    size_t findFirstE_slow(string_view sv) {
      return sv.find_first_of(characters);
    }
    
    // Also your "fast" version needs to return
    //    return it == sv.end() ? string_view::npos : size_t(it - sv.begin());
    // to be equivalent
    

    (https://godbolt.org/z/bhPPxjboE)

    https://quick-bench.com/q/QVxVTxGEagUUCPuhFi9T8wjI1qQ 说慢版本现在只慢 1.3 倍。使用更大的字符串('e' 之前的https://quick-bench.com/q/el0ukDywBNMoGsEb33PM_g4WUaY; 8000 个字符),差异几乎不明显。

    现在的主要区别是一个迭代索引,另一个迭代指针(最后返回差异)。汇编中的两个不同指令是 movzx edx, BYTE PTR [rsi+rax]movzx edx, BYTE PTR [rax] sub rax, rsi,您应该会发现第二个版本的速度稍快(尤其是渐近的,因为减法发生在循环之外)

    【讨论】:

    • 有点遗憾,没有与 strpbrk 等效的 mempbrk,这正是这个函数的本意,但它不适用于字符串视图。
    • 感谢findFirstE_fast()的修改;我忘记了。
    • 期望 GCC 将 findFirstE_fast_index()findFirstE_fast() 优化为相同的汇编代码是否合理?
    • @zwliew 可能不会。尽管它们在语义上是等效的,但我已经阅读了一些内容,实际上并不简单,哪个更高效:ptr[idx++] 有一个常量基指针,这在可以快速偏移和取消引用的机器上可能更快,但在其他情况(与*ptr++ 相比)。因此编译器可能不会将一个更改为另一个,由代码编写者选择是否需要索引或指针。而且性能差异可能只是一个周期的几分之一,因此它可能不会成为真正的瓶颈。
    猜你喜欢
    • 2021-06-17
    • 1970-01-01
    • 2019-02-18
    • 2020-01-06
    • 2011-12-26
    • 2017-03-02
    • 2019-10-18
    • 2019-03-02
    • 2011-03-27
    相关资源
    最近更新 更多