43

当我解决以下示例算法问题时,我很想了解字符串比较在 python 中的工作原理:

给定两个字符串,返回最长公共前缀的长度

解决方案 1:charByChar

我的直觉告诉我,最佳解决方案是从两个单词开头的一个光标开始,然后向前迭代,直到前缀不再匹配。就像是

def charByChar(smaller, bigger):
  assert len(smaller) <= len(bigger)
  for p in range(len(smaller)):
    if smaller[p] != bigger[p]:
      return p
  return len(smaller)

为了简化代码,该函数假定第一个字符串的长度smaller始终小于或等于第二个字符串的长度bigger

解决方案 2:binarySearch

另一种方法是将两个字符串平分以创建两个前缀子字符串。如果前缀相等,我们知道公共前缀点至少和中点一样长。否则,公共前缀点至少不大于中点。然后我们可以递归找到前缀长度。

又称二分查找。

def binarySearch(smaller, bigger):
  assert len(smaller) <= len(bigger)
  lo = 0
  hi = len(smaller)

  # binary search for prefix
  while lo < hi:
    # +1 for even lengths
    mid = ((hi - lo + 1) // 2) + lo

    if smaller[:mid] == bigger[:mid]:
      # prefixes equal
      lo = mid
    else:
      # prefixes not equal
      hi = mid - 1

  return lo

起初我认为这binarySearch会更慢,因为字符串比较会比较所有字符多次,而不是像charByChar.

令人惊讶的binarySearch是,经过一些初步的基准测试,结果证明速度要快得多。

图A

lcp_fixed_suffix

上面显示了随着前缀长度的增加,性能如何受到影响。后缀长度保持不变,为 50 个字符。

这张图显示了两件事:

  1. 正如预期的那样,随着前缀长度的增加,两种算法的线性表现都会变差。
  2. 性能以charByChar更快的速度下降。

为什么binarySearch好多了?我认为这是因为

  1. 中的字符串比较binarySearch大概是由幕后的解释器/CPU优化的。
  2. charByChar实际上为每个访问的字符创建新字符串,这会产生很大的开销。

为了验证这一点,我对比较和切片字符串的性能进行了基准测试,分别在下面标记cmp和标记。slice

图B

CMP

这张图显示了两个重要的事情:

  1. 正如预期的那样,比较和切片随长度线性增加。
  2. 相对于算法性能,比较和切片的成本随着长度的增长非常缓慢,图 A。请注意,这两个数字都上升到长度为 10 亿个字符的字符串。因此,比较 1 个字符 10 亿次的成本要比比较 10 亿个字符一次要大得多。但这仍然没有回答为什么......

蟒蛇

为了找出 cpython 解释器如何优化字符串比较,我为以下函数生成了字节码。

In [9]: def slice_cmp(a, b): return a[0] == b[0]

In [10]: dis.dis(slice_cmp)
            0 LOAD_FAST                0 (a)
            2 LOAD_CONST               1 (0)
            4 BINARY_SUBSCR
            6 LOAD_FAST                1 (b)
            8 LOAD_CONST               1 (0)
           10 BINARY_SUBSCR
           12 COMPARE_OP               2 (==)
           14 RETURN_VALUE

我浏览了 cpython 代码,发现了以下两段代码 但我不确定这是发生字符串比较的地方。

问题

  • 字符串比较在 cpython 中的哪个位置发生?
  • 有CPU优化吗?是否有特殊的 x86 指令可以进行字符串比较?如何查看 cpython 生成了哪些汇编指令?您可能会认为我使用的是最新的 python3、Intel Core i5、OS X 10.11.6。
  • 为什么比较长字符串比比较每个字符要快得多?

额外问题:charByChar 什么时候性能更高?

如果前缀与字符串的其余长度相比足够小,则在某些时候创建子字符串的成本charByChar会小于比较 in 的子字符串的成本binarySearch

为了描述这种关系,我深入研究了运行时分析。

运行时分析

为了简化下面的等式,我们假设smallerbigger大小相同,我将它们称为s1s2

逐字符

charByChar(s1, s2) = costOfOneChar * prefixLen

在哪里

costOfOneChar = cmp(1) + slice(s1Len, 1) + slice(s2Len, 1)

cmp(1)比较两个长度为 1 字符的字符串的成本在哪里?

slice是访问 char 的成本,相当于charAt(i). Python 具有不可变的字符串,访问 char 实际上会创建一个长度为 1 的新字符串。这是将长度字符串切片为 sizeslice(string_len, slice_len)切片的成本。string_lenslice_len

所以

charByChar(s1, s2) = O((cmp(1) + slice(s1Len, 1)) * prefixLen)

二进制搜索

binarySearch(s1, s2) = costOfHalfOfEachString * log_2(s1Len)

log_2是将字符串分成两半直到达到长度为 1 的字符串的次数。

costOfHalfOfEachString = slice(s1Len, s1Len / 2) + slice(s2Len, s1Len / 2) + cmp(s1Len / 2)

所以 big-O 的binarySearch会根据

binarySearch(s1, s2) = O((slice(s2Len, s1Len) + cmp(s1Len)) * log_2(s1Len))

根据我们之前对成本的分析

如果我们假设costOfHalfOfEachString大约是 ,costOfComparingOneChar那么我们可以将它们都称为x

charByChar(s1, s2) = O(x * prefixLen)
binarySearch(s1, s2) = O(x * log_2(s1Len))

如果我们把它们等同起来

O(charByChar(s1, s2)) = O(binarySearch(s1, s2))
x * prefixLen = x * log_2(s1Len)
prefixLen = log_2(s1Len)
2 ** prefixLen = s1Len

所以O(charByChar(s1, s2)) > O(binarySearch(s1, s2)

2 ** prefixLen = s1Len

因此,插入上面的公式,我为图 A 重新生成了测试,但字符串的总长度2 ** prefixLen预计两种算法的性能大致相等。

图像

但是,显然charByChar性能要好得多。通过一些试验和错误,两种算法的性能大致相等s1Len = 200 * prefixLen

图像

为什么关系是 200 倍?

4

2 回答 2

26

TL:DR:切片比较是一些 Python 开销 + 高度优化memcmp的(除非有 UTF-8 处理?)。理想情况下,使用切片比较来找到小于 128 字节或其他内容的第一个不匹配,然后一次循环一个字符。

或者,如果它是一个选项并且问题很重要,则制作一个 asm-optimized 的修改副本,memcmp它返回第一个差异的位置,而不是相等/不相等;==它的运行速度与整个字符串中的单个一样快。Python 可以在库中调用本机 C / asm 函数。

CPU 可以以极快的速度执行此操作,这是一个令人沮丧的限制,但 Python (AFAIK) 无法让您访问优化的比较循环,该循环告诉您不匹配位置,而不仅仅是等于/大于/小于。


使用 CPython 的简单 Python 循环中,解释器开销占实际工作的成本是完全正常的。用优化的构建块构建算法是值得的,即使这意味着做更多的工作。这就是为什么 NumPy 很好,但逐个元素循环遍历矩阵是很糟糕的。对于 CPython 与用于一次比较一个字节的编译 C (asm) 循环(由数字组成,但可能在一个数量级之内),速度差异可能是 20 到 100 倍。

比较内存块是否相等可能是 Python 循环与在整个列表/切片上操作之间最大的不匹配之一。这是高度优化的解决方案的一个常见问题(例如,大多数 libc 实现(包括 OS X)都有一个手动矢量化的手工编码 asm memcmp,它使用 SIMD 来并行比较 16 或 32 个字节,并且运行速度比 byte-at- 快得多 C 或汇编中的时间循环)。因此,还有另一个 16 到 32 的因数(如果内存带宽不是瓶颈)将 Python 和 C 循环之间的速度差异乘以 20 到 100 的因数。或者取决于您的优化memcmp程度,每个周期可能“仅”6 或 8 个字节。

对于中型缓冲区的 L2 或 L1d 高速缓存中的热数据,memcmp在 Haswell 或更高版本的 CPU 上预期每个周期 16 或 32 字节是合理的。(i3/i5/i7 的命名始于 Nehalem;仅 i​​5 不足以告诉我们有关您的 CPU 的很多信息。)

我不确定您的比较中的一个或两个是否必须处理 UTF-8 并检查等效类或编码相同字符的不同方法。最坏的情况是,如果您的 Python 一次 char 循环必须检查潜在的多字节字符,但您的切片比较只能使用memcmp.


用 Python 编写一个高效的版本:

我们只是为了提高效率而完全与语言作斗争:您的问题几乎与 C 标准库函数完全相同memcmp,只是您想要第一个差异的位置而不是 - / 0 / + 结果告诉您哪个字符串是更大。搜索循环是相同的,只是在找到结果后函数的作用有所不同。

您的二进制搜索不是使用快速比较构建块的最佳方式。切片比较仍然具有O(n)成本,而不是O(1),只是具有更小的常数因子。您可以而且应该避免重复比较缓冲区的开头,方法是使用切片比较大块,直到发现不匹配,然后用较小的块大小返回最后一个块。

# I don't actually know Python; consider this pseudo-code
# or leave an edit if I got this wrong :P
chunksize = min(8192, len(smaller))
# possibly round chunksize down to the next lowest power of 2?
start = 0
while start+chunksize < len(smaller):
    if smaller[start:start+chunksize] == bigger[start:start+chunksize]:
        start += chunksize
    else:
        if chunksize <= 128:
            return char_at_a_time(smaller[start:start+chunksize],  bigger[start:start+chunksize])
        else:
            chunksize /= 8        # from the same start

# TODO: verify this logic for corner cases like string length not a power of 2
# and/or a difference only in the last character: make sure it does check to the end

我选择 8192 是因为你的 CPU 有一个 32kiB L1d 缓存,所以两个 8k 切片的总缓存占用空间是 16k,是 L1d 的一半。当循环发现不匹配时,它将重新扫描 1k 块中的最后 8kiB,这些比较将循环遍历 L1d 中仍然很热的数据。(请注意,如果==发现不匹配,它可能只触及到该点的数据,而不是整个 8k。但硬件预取将继续超出此范围。)

8 倍应该是使用大切片快速定位与不需要多次遍历相同数据之间的良好平衡。这当然是一个可调参数,还有块大小。Python 和 asm 之间的不匹配越大,这个因素应该越小,以减少 Python 循环迭代。)

希望 8k 足够大,可以隐藏 Python 循环/切片开销;在来自解释器的调用之间的 Python 开销期间,硬件预取应该仍然有效,memcmp因此我们不需要很大的粒度。但是对于非常大的字符串,如果 8k 不会使内存带宽饱和,那么可能会使其达到 64k(您的 L2 缓存是 256kiB;i5 确实告诉我们很多)。

究竟有多memcmp快:

我在 Intel Core i5 上运行它,但我想我会在大多数现代 CPU 上得到相同的结果。

即使在 C 中,为什么 memcmp 比 for 循环检查要快得多? memcmp比一次一个字节的比较循环更快,因为即使 C 编译器也不擅长(或完全不能)自动矢量化搜索循环。

即使没有硬件 SIMD 支持,memcmp即使在没有 16 字节或 32 字节 SIMD 的简单 CPU 上,优化程序也可以一次检查 4 或 8 个字节(字大小/寄存器宽度)。

但是大多数现代 CPU 以及所有 x86-64 都具有 SIMD 指令。 SSE2 是 x86-64 的基线,可作为 32 位模式的扩展。

SSE2 或 AVX2memcmp可以使用pcmpeqb/pmovmskb来并行比较 16 或 32 个字节。(我不打算详细介绍如何在 x86 asm 或 C 内部函数中编写 memcmp。单独谷歌,和/或在 x86 指令集参考中查找这些 asm 指令。比如http://felixcloutier。 com/x86/index.html。有关 asm 和性能链接,另请参阅x86 标签 wiki。例如,为什么 Skylake 在单线程内存吞吐量方面比 Broadwell-E 好得多?有一些关于单核内存带宽限制的信息。)

我在他们的开源网站上找到了 2005 年 Apple 的 x86-64 的旧版本(使用 AT&T 语法汇编语言)。memcmp肯定会更好;对于大型缓冲区,它应该对齐一个源指针并仅用于另一个源指针,然后movdqu允许使用内存操作数而不是 2x ,即使字符串相对于彼此未对齐。 /在可以宏融合但不能融合的 CPU 上也不是最佳选择。movdqupcmpeqbmovdquxorl $0xFFFF,%eaxjnzcmp/jccxor / jcc

展开同时检查整个 64 字节缓存行也会隐藏循环开销。(这与大块的想法相同,然后当您找到命中时循环返回它)。 Glibc 的 AVX2-movbe版本将比较结果合并vpand到主大缓冲区循环中,最后的合并是 a vptest,它也从结果中设置标志。(更小的代码大小,但不低于vpand/ vpmovmskb/ cmp/的 uops jcc;但没有缺点,并且可能降低延迟以减少循环退出时的分支错误预测惩罚)。Glibc 在动态链接时进行动态 CPU 调度;它会在支持它的 CPU 上选择这个版本。

希望苹果memcmp现在更好;Libc不过,我在最近的目录中根本看不到它的来源。希望他们在运行时分派给 Haswell 和更高版本 CPU 的 AVX2 版本。

我链接的版本中的LLoopOverChunks循环只会在 Haswell 上每 ~2.5 个周期运行 1 次迭代(每个输入 16 个字节);10 个融合域微指令。但这仍然比简单的 C 循环每周期 1 个字节快得多,或者比 Python 循环差得多。

Glibc 的L(loop_4x_vec):循环是 18 个融合域微指令,因此当 L1d 高速缓存中的数据很热时,每个时钟周期(来自每个输入)的运行速度略低于 32 字节。否则它将成为 L2 带宽的瓶颈。如果他们没有在循环内使用额外的指令来递减一个单独的循环计数器,并在循环外计算一个终点指针,则可能是 17 微秒。


在 Python 解释器自己的代码中查找指令/热点

如何深入查找我的代码调用的 C 指令和 CPU 指令?

在 Linux 上,您可以运行perf record python ...然后perf report -Mintel查看 CPU 在哪些函数上花费的时间最多,以及这些函数中的哪些指令最热。你会得到类似于我在这里发布的结果:为什么 float() 比 int() 快?. (深入研究任何函数以查看运行的实际机器指令,显示为汇编语言,因为perf内置了反汇编程序。)

有关对每个事件的调用图进行采样的更细致的视图,请参阅linux perf:如何解释和查找热点

(当您希望实际优化程序时,您想知道哪些函数调用是昂贵的,因此您可以首先尝试避免它们。仅“自我”时间分析会发现热点,但您不会总是知道哪些不同的调用者导致给定循环运行大部分迭代。请参阅 Mike Dunlavey 对那个 perf 问题的回答。)

但是对于这种特定情况,分析在大字符串上运行切片比较版本的解释器应该有望找到memcmp我认为它将花费大部分时间的循环。 (或者对于 char-at-a-time 版本,找到“热”的解释器代码。)

然后就可以直接看到循环中有哪些asm指令了。从函数名称中,假设您的二进制文件有任何符号,您可能可以找到源代码。或者,如果您有使用调试信息构建的 Python 版本,则可以直接从配置文件信息获取源代码。(不是禁用优化的调试版本,只有完整的符号)。

于 2018-04-24T00:56:41.127 回答
4

这既依赖于实现,也依赖于硬件。在不知道您的目标机器和特定分布的情况下,我无法确定。但是,我强烈怀疑底层硬件和大多数硬件一样,都有内存块指令。除其他外,这可以以并行和流水线方式比较一对任意长的字符串(直到寻址限制)。例如,它可以在每个时钟周期一个片上比较 8 字节片。这比摆弄字节级索引要快得多

于 2018-04-20T23:10:47.277 回答