快速 strlen 函数与别名规则

Fast strlen function vs aliasing rules

我发现 this "fast strlen function" 实现:

// for x86 only
size_t my_strlen(const char *s) {
    size_t len = 0;
    for(;;) {
        unsigned x = *(unsigned*)s;
        if((x & 0xFF) == 0) return len;
        if((x & 0xFF00) == 0) return len + 1;
        if((x & 0xFF0000) == 0) return len + 2;
        if((x & 0xFF000000) == 0) return len + 3;
        s += 4, len += 4;
    }
}

此处使用的优化技术显然很简单:通过自然 CPU 字读取内存(代码较旧并假定 x32 CPU),而不是简单的字节。

但此代码违反了别名规则,因此导致未定义的行为,编译器可以自由优化这些行为(使代码更快,但不正确)。

我现在也看到它不可移植,因为它与小端字节序有关。

或者我完全错了,上面的代码是正确的? C 是否正确?对于 C++?

这是个坏主意,会让你的表现更差。现代编译器提供的标准 strlen 函数已经高度优化,并且会比上面的更好。例如,在支持 SSE 的 CPU 上(即几乎所有的 CPU),它已经使用 SSE/AVX 指令对空终止符进行矢量化搜索,并且会像上面那样一次考虑超过 4 个字节,而更少与比较相关的指令,以及更少的可能被错误预测的分支。

这是非常糟糕的代码。甚至代码的作者也警告说:

  • 如果不可读的内存页位于字符串末尾之后,此函数将崩溃。 防止这种情况的最简单方法是在字符串末尾分配 3 个额外的字节。

  • 双字可能未对齐,但 x86 架构允许访问未对齐的数据。对于小字符串,对齐比未对齐读取的惩罚花费更多时间

  • 代码不可移植:如果您使用64位处理器,您将不得不添加另外4个条件。对于大端架构,条件的顺序应该颠倒。

即使这没有违反别名规则,但让编码人员完成 my_strlen 工作的负担是完全不合理的。已经多次声明 strlen 已经超越了普通编码人员可以完成的任何事情。

但是对于 C++ 应该做一个额外的声明:Bjarne Stroustrup, the creator of C++, in the last page of chapter 4 in his book:"The C++ Programming Language" 说:

Prefer strings over C-style strings

您会发现获取 string 的大小比查找 C 字符串的大小更高效。

编辑:

you say you are working with StaticallyBufferedString 中旨在解决 string 的 "pooling memory model" 导致:

  • 多线程上下文中不必要的堆锁
  • 来自实时大小控制的碎片

我想推荐 C++17 的 string_view which like all of C++17 was constructed with multi-threading in mind. It provides the functionality of a string backed by heap and constexpr friendly C-Strings. You can even get a jump start on learning about it with namespace experimental: http://en.cppreference.com/w/cpp/experimental/basic_string_view 与您花在 StaticallyBufferedStrings 上的时间不同,您获得的知识将完全可移植并适用于您将来所做的任何 C++ 工作!

所写的功能既不是最优的也不是可移植的。话虽如此,C89 的作者包含了一些写得不好的规则,如果严格解释这些规则,C89 将成为比许多平台上存在的早期 C 方言更不强大的语言。该规则的既定目的是避免要求 C 编译器给出如下代码:

float *fp;
int i;

int foo(void)
{
  i++;
  *fp=1.0f;
  return i;
}

不必悲观地假设写入 *fp 可能会影响 i 尽管完全没有任何迹象表明 int 类型的东西可能会影响受到影响。在编写 C89 时,出于各种目的(包括分块优化)使用类型双关的代码很普遍,但大多数此类代码都会向编译器明确指示将要发生别名。通常,如果一个对象将在两次 "normal" 访问之间被外部类型指针修改,则在两次正常访问之间将发生以下一种或两种情况:

  1. 对象的地址将被占用。

  2. 指针将从对象的类型转换为另一种类型。

除了使用指针访问对象的明显情况 它的精确类型,该标准主要确定它不会的情况 很明显编译器应该假定别名是可能的(例如在 一个 "int" 和一个 "unsigned*" 类型的指针,或者介于任何东西和指针之间 "char*" 类型)。鉴于理由,我认为作者打算 专注于确保编译器编写者处理存在的情况 没有理由期待别名,但认为他们不需要 讲述了如何识别明显可能发生的情况。

分块优化在编译器上是安全的 address-of 和 casting 运算符暗示可能存在跨类型别名, 前提是执行转换产生的指针的所有使用 在使用非强制转换指针进行下一次访问之前——大多数人的要求 分块代码通常会满足。不幸的是,没有标准 对于 "sane-compiler C",而 gcc 使用的事实是 标准不要求编译器处理明显的别名情况 忽略它们的理由。

不过,分块优化带来的性能优势可能会超过 -fno-strict-aliasing 的性能成本,特别是如果代码使用 restrict 适当的限定符。有几种情况,主要是 涉及全局变量,其中 restrict 不足以启用 有用的优化;这些可以通过限制的别名模式来处理 分析静态或自动持续时间的对象(如 基本原理的例子)但是 gcc 没有提供这样的模式。

顺便说一句,我不确定现代 x86 处理器的指令时序如何,但在某些 ARM 变体上,编译器有机会从类似以下内容生成最佳长字符串代码:

uint32_t x01000000 = 0x01000000;
uint64_t *search(uint64_t *p)
{
  uint64_t n;
  uint32_t a,b;
  uint32_t v = x01000000; // See note
  do
  {
    n=*p++;
    a=(uint32_t)n;
    b=n>>32;
    if (a >= v  || (a << 8) >= v || (a << 16) >= v || (a << 24) >= v ||
        b >= v  || (b << 8) >= v || (b << 16) >= v || (b << 24) >= v) return p;
  } while(1);      
}

找出哪个比较导致循环跳出会花费额外的时间,但将此类考虑因素排除在循环之外可能会使循环本身更有效率。

许多 ARM 处理器都有一条指令可以将移位后的值与寄存器进行比较;编译器有时需要一点帮助才能意识到 0x01000000 应该保存在寄存器中(存在与常量比较的指令,但是 不包括被比较的寄存器的 "free" 移位),但在帮助下他们可以找到与移位比较。我还没有找到说服编译器为 ARM7-TDMI 生成最佳代码的方法,这相当于:

search:
    mov    r1,#0x010000000
lp:
    ldrmia r0,{r2,r3}
    cmp    r1,r2
    cmplt  r1,r2,asl #8
    cmplt  r1,r2,asl #16
    cmplt  r1,r2,asl #24
    cmplt  r1,r3
    cmplt  r1,r3,asl #8
    cmplt  r1,r3,asl #16
    cmplt  r1,r3,asl #24
    blt    lp
    bx     lr

每八个字节需要 15 个周期;它可以调整为每 16 个字节需要 25 个周期。一个单独处理八个字节的循环需要 42 个周期;展开为 16 个字节将是 82 个周期。我见过编译器为基于 uint64_t 的代码生成的最佳循环是 22 个循环,用于 8 个字节——几乎是最佳代码的一半,但仍然是使用字节的版本的两倍。