为什么Python中的字符串比较如此快速?

49
我在解决以下算法问题时变得好奇了,想要理解 Python 中字符串比较的内部工作原理:
给定两个字符串,返回最长公共前缀的长度。
解法1:逐字符比较
我的直觉告诉我,最优解法应该是从两个单词的开头开始用一个光标迭代向前,直到前缀不再匹配。大概像这样:
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)

为了简化代码,该函数假设第一个字符串的长度总是小于或等于第二个字符串的长度。

解决方法2:二分查找

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

也称为二分查找。

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. 二分查找算法中字符串比较是由解释器/CPU在幕后进行优化的。
  2. charByChar实际上会为每个访问的字符创建新的字符串,这会产生显着的开销。
为了验证这一点,我对比了字符串比较和切片操作的性能,分别标记为cmp和slice如下所示: 图B

cmp

这张图表显示了两个重要的事情:
  1. 如预期所示,比较和切片随着长度呈线性增长。
  2. 与算法性能相比,比较和切片的成本随着长度的增加增长得非常缓慢,如图A所示。请注意,这两个图形都展示了长度为10亿个字符的字符串。因此,将一个字符进行10亿次比较和切片的成本远远大于一次比较1亿个字符。但这仍然没有回答为什么...

Cpython

为了找出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

我在cpthon代码中找到了以下两个 片段,但我不确定这是字符串比较发生的地方。

问题

  • 在cpython的哪里进行字符串比较?
  • 是否有CPU优化?是否有一种特殊的x86指令可以进行字符串比较?如何查看cpython生成的汇编指令?您可以假设我正在使用最新的python3、Intel Core i5、OS X 10.11.6。
  • 为什么比较长字符串要比比较每个字符快得多?

奖励问题:何时charByChar更高效?

如果与字符串的其余部分相比,前缀足够小,则在某些情况下,在 charByChar 中创建子字符串的成本会变得小于在 binarySearch 中比较子字符串的成本。

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

运行时分析

为了简化以下方程,让我们假设 smaller bigger 大小相同,并将它们称为 s1 s2

charByChar

charByChar(s1, s2) = costOfOneChar * prefixLen

这句话的意思是:“在哪里”。
costOfOneChar = cmp(1) + slice(s1Len, 1) + slice(s2Len, 1)

“cmp(1)”是比较长度为1个字符的两个字符串的成本。
“slice”是访问一个字符的成本,相当于“charAt(i)”。Python具有不可变字符串,访问一个字符实际上会创建一个新的长度为1的字符串。“slice(string_len,slice_len)”是将长度为“string_len”的字符串切片到大小为“slice_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)

所以binarySearch的大O符号将会随着增长。
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

所以当s1和s2被逐个字符比较时,O(charByChar(s1, s2)) > O(binarySearch(s1, s2))。
2 ** prefixLen = s1Len
使用上述公式插入,我重新生成了 Figure A 的测试,但字符串的总长度为 2 ** prefixLen,预期这两个算法的性能大致相等。

img

然而,显然charByChar的性能要好得多。经过一些试验,当s1Len = 200 * prefixLen时,这两种算法的性能大致相等。

img

这个句子的意思是:“为什么这个关系是200倍?”

比较整个字符串是在 C 级别进行的,这会使它更快。不知道这是否完全解释了你的观察结果。 - Mark Ransom
为了回答这个问题,我们必须确定CPython如何解释smaller[:mid] == bigger[:mid]和它如何解释smaller[p] != bigger[p] - Hadi Brais
你的时间复杂度分析太粗略了。但即使更加严谨的分析也无法得出答案。 - Hadi Brais
了解您的输入将有助于更好地理解情节--这两个字符串通常非常相似还是非常不同?ASCII还是Unicode?这些字符串是否已经在内存中(可能刚刚创建并被CPU缓存),或者它们在文件中?还是在内存映射文件中?我的直觉告诉我,最好的解决方案是从比较长度 1、4、16、... 开始,直到比较失败,然后切换到轻微倾向于较短分裂的二进制搜索。实现将使用 MemoryView 或缓冲区。 - Dima Tisnek
3
反编译Python字节码无法提供太多信息,因为Python字节码运算符是相当高级的结构。要回答你的子问题“在cpython中字符串比较发生在哪里?”,请参见Python运行时中的if子字符串在字符串中的匹配 - Martijn Pieters
2个回答

35
TL:DR: 切片比较是一些Python开销加上高度优化的memcmp(除非有UTF-8处理?)。理想情况下,使用切片比较找到小于128个字节的第一个不匹配,然后逐个字符循环。或者,如果可以并且问题很重要,则制作修改过的汇编优化的memcmp副本,该副本返回第一个差异的位置,而不是相等/不相等。它将像整个字符串的单个==一样快地运行。Python有方法在库中调用本机C / asm函数。这是一个令人沮丧的限制,因为CPU可以以惊人的速度完成此操作,但Python(据我所知)没有提供优化的比较循环让你知道不匹配的位置,而不仅仅是相等/大于/小于。
使用CPython,解释器开销支配着简单Python循环中真正工作的成本是完全正常的。即使意味着做更多的总工作,将优化的构建块组合成算法也是值得的。这就是为什么NumPy很好,但逐个元素地循环矩阵是可怕的原因。对于比较每个字节的编译C(asm)循环和CPython之间的速度差异可能是20到100倍左右(虚构的数字,但可能在一个数量级内正确)。比较相等性的内存块可能是Python循环与操作整个列表/切片之间最大的不匹配之一。这是高度优化解决方案的常见问题(例如,大多数libc实现(包括OS X)具有手动向量化的汇编memcmp,使用SIMD并行比较16或32个字节,并且运行速度比C或汇编字节循环快得多)。因此,在Python和C循环之间的20到100倍速度差异之间乘以16到32的另一个因素(如果内存带宽不是瓶颈)。或者根据您的memcmp进行了多少优化,可能每个周期只有6或8个字节。对于中等大小的缓冲区中的热数据,可以合理地预期Haswell或更高版本CPU上的memcmp每个周期为16或32个字节。(i3 / i5 / i7命名始于Nehalem;仅使用i5无法告诉我们有关您的CPU的信息。)
我不确定你的比较操作是否需要处理UTF-8并检查等效类或不同的字符编码方式。最糟糕的情况是,如果Python的逐字符循环必须检查潜在的多字节字符,但是切片比较可以使用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的一半。当循环发现不匹配时,它将重新扫描最后的8kiB,每次以1k为单位进行比较,这些比较将循环遍历仍然在L1d中热的数据。(请注意,如果==找到不匹配,它可能只触及到那一点之前的数据,而不是整个8k。但是硬件预取将继续进行一定程度的操作。)
8的倍数应该是快速本地化和不需要多次通过相同数据之间的良好平衡。当然,这是一个可调参数,与块大小一起使用。 Python和asm之间的差异越大,这个因子就应该越小,以减少Python循环迭代。
希望8k足够大,可以隐藏Python循环/片段开销;在解释器调用之间的Python开销期间,硬件预取仍然应该在工作,因此我们不需要粒度非常大。但对于非常大的字符串,如果8k没有饱和内存带宽,则可以将其设置为64k(您的L2缓存为256kiB; i5确实告诉我们这一点)。 memcmp 如何如此快速:
即使在C语言中,为什么memcmp比for循环检查快得多?。这是因为即使C编译器并不擅长(或完全不能)自动向量化搜索循环。
即使没有硬件SIMD支持,经过优化的memcmp也可以一次检查4或8个字节(字大小/寄存器宽度),即使在没有16字节或32字节SIMD的简单CPU上也是如此。
但是大多数现代CPU,以及所有x86-64 CPU都具有SIMD指令。 SSE2是x86-64的基线, 并作为32位模式的扩展可用。
使用SSE2或AVX2的memcmp可以使用pcmpeqb/ pmovmskb并行比较16或32个字节。不会详细介绍如何在x86 asm或C intrinsics中编写memcmp。请单独谷歌,或在x86指令集参考中查找这些asm指令,例如http://felixcloutier.com/x86/index.html。有关asm和性能链接,请参见the x86 tag wiki。例如,Why is Skylake so much better than Broadwell-E for single-threaded memory throughput?提供了有关单核内存带宽限制的一些信息。
我在苹果公司开源网站上找到了2005年的旧版x86-64 memcmp(使用AT&T语法汇编语言)。它肯定可以更好;对于大缓冲区,它应该对齐一个源指针,并仅对另一个指针使用movdqu,即使字符串相对于彼此不对齐,也允许使用movdqu然后与内存操作数一起使用pcmpeqb,而不是2个movdqu。在可将cmp/jcc宏融合但不能将xor / jcc宏融合的CPU上,xorl $0xFFFF,%eax / jnz 也不是最优选择。
一次展开以检查整个64字节的缓存行也可以隐藏循环开销。(这是一个大块并在找到命中时回头循环的相同想法)。Glibc的AVX2-movbe版本使用vpand将主要的大缓冲区循环中的比较结果组合起来,最终的组合是一个vptest,它还从结果设置标志。(代码大小更小,但uops数量不少于vpand/vpmovmskb/cmp/jcc; 但没有缺点,可能具有更低的延迟,以减少循环退出时的分支预测惩罚)。Glibc在动态链接时进行动态CPU调度; 它选择支持它的CPU上的此版本。
希望苹果的memcmp现在更好了; 虽然我在最近的Libc目录中根本找不到它的源代码。希望他们在运行时分派给Haswell和更高版本的CPU的AVX2版本。
我链接的版本中的LLoopOverChunks循环每次迭代(从每个输入中取16个字节)需要约2.5个周期; 10个融合域uops。但这仍然比单纯的C循环每个周期1个字节要快得多,或者比Python循环更糟糕。

Glibc的L(loop_4x_vec):循环包含18个融合域uop,因此当数据在L1d缓存中热时,每个时钟周期可以运行略少于32字节(从每个输入);否则它将在L2带宽上成为瓶颈。如果他们没有在循环内使用额外的指令来递减一个单独的循环计数器并在循环外计算结束指针,那么它本可以只有17个uop。


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

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

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

对于更细致的视图,每个事件都会采样呼叫图,请参见linux perf: how to interpret and find hotspots

(当您要实际优化程序时,您需要知道哪些函数调用是昂贵的,以便首先尝试避免它们。仅针对“self”时间进行分析将找到热点,但您并不总是知道哪些不同的调用者导致给定循环运行大多数迭代。请参见那个perf问题上Mike Dunlavey的答案。)

但是对于这个特定情况,运行一个片段比较大字符串的解释器进行剖析,希望能够找到我认为它将花费大部分时间的memcmp循环。(或者对于每个字符逐一比较的版本,找到“热点”的解释器代码。)
然后您就可以直接看到循环中的汇编指令。从函数名称来看,假设您的二进制文件有任何符号,您可能可以找到源代码。或者如果您有一个使用调试信息构建的Python版本,则可以直接从剖析信息中获取源代码。(不是禁用优化的调试构建,只是具有完整符号的构建)。

非常感谢你,彼得。在批准你的回答之前,我会跟进这些链接并进行一些额外的阅读。再次感谢。 - david_adler
2
希望有人能够检查一下我的手势和关于memcmp与UTF-8处理成本的猜测。切片比较肯定比字符比较更有效率,它的成本有一些固定的开销+一些每个大小的比例因子,但我不知道这些常数在任何特定的Python解释器中是什么样子的,也不知道它的每个字符缩放是否像memcmp那样便宜。 - Peter Cordes

4

这既与实现相关,也与硬件相关。如果不知道你的目标机器和具体分发版本,我无法确定。但是,我强烈怀疑底层硬件像大多数硬件一样具有内存块指令。其中,可以以并行和流水线方式比较一对任意长的字符串(最多达到寻址限制)。例如,它可以在一个时钟周期内比较8字节切片。这比玩弄字节级索引要快得多。


谢谢@Prune。我正在Intel Core i5上运行此程序,但我想我会在大多数现代CPU上获得相同的结果。关键是你声称性能提升来自于CPU而不是解释器或C级别。我该如何深入挖掘以找到我的代码调用的C指令和CPU指令? - david_adler
你需要阅读解释器的文档来生成底层的C代码。然后,你需要继续这个过程,找到用于编译该C代码的编译标志,在生成汇编代码时添加-a选项(我认为这仍然是正确的选项)。你已经阅读过Intel架构的汇编语言了吗? - Prune
1
在字节级操作中,将C语言与其他语言进行比较是错误的。如果一个字符至少由1个字节组成,则在需要编码时过滤器会增长。此外,在C语言中,字符组(字符串)不被视为数据类型(因此,每次比较都必须重新分析字符组)。只有在函数之间的数据传输是固定类型(可接受的)时才提供性能。简而言之,在“C”语言中没有称为“String”的标准数据类型。 - dsgdfg

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接