为什么在python中字符串比较如此之快?

时间:2018-04-20 23:03:40

标签: python x86 interpreter cpython strncmp

当我解决以下示例算法问题时,我很好奇理解字符串比较在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

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

Aka二元搜索。

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的效果会以更快的速度降低。
  3. 为什么binarySearch好多了?我认为这是因为

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

    为了验证这一点,我对比较和切片字符串的效果进行了基准测试,分别标记为cmpslice

    图B

    cmp

    此图表显示了两个重要的事项:

    1. 正如预期的那样,比较和切片随长度线性增加。
    2. 相对于算法性能,比较和切片的成本与长度的增长非常缓慢,如图A所示。请注意,这两个数字都会达到长度为10亿个字符的字符串。因此,比较1个字符10亿次的成本比一次比较10亿个字符要大得多。但这仍然无法回答为什么......
    3. 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
      

      我查了一下cpython代码,发现了以下two pieces代码,但我不确定这是字符串比较发生的地方。

      问题

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

      奖金问题:charByChar何时更具性能?

      如果前缀与字符串的长度相比足够小,那么在charByChar中创建子字符串的成本会低于比较binarySearch中的子字符串的成本。< / p>

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

      运行时分析

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

      charByChar

      charByChar(s1, s2) = costOfOneChar * prefixLen
      

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

      其中cmp(1)是比较两个长度为1 char的字符串的成本。

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

      所以

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

      的binarySearch

      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
      

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

      2 ** prefixLen = s1Len
      

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

      img

      然而,显然charByChar表现得更好。通过一些试验和错误,当s1Len = 200 * prefixLen

      时,两种算法的性能大致相等

      img

      为什么关系是200x?

2 个答案:

答案 0 :(得分:21)

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

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

令人沮丧的限制是CPU可以非常快速地执行此操作,但Python不会(AFAIK)让您访问优化的比较循环,该循环告诉您不匹配的位置而不仅仅是等于/更大/以下。

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

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

对于中等大小的缓冲区,L2或L1d缓存中的数据很热,对于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的一半。当循环发现不匹配时,它将以1k块重新扫描最后的8kiB,并且这些比较将循环在L1d中仍然很热的数据。 (注意,如果==发现不匹配,它可能只触及到那一点的数据,而不是整个8k。但是HW预取将继续超出这个范围。)

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

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

memcmp到底有多快:

  

我在英特尔酷睿i5上运行它,但我想我会在大多数现代CPU上获得相同的结果。

即使在C语言中,Why is memcmp so much faster than a for loop check? memcmp比一次​​一个字节的比较循环更快,因为即使是C编译器也不是很好(或完全无法)自动 - 矢量化搜索循环。

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

但是大多数现代CPU和所有x86-64都有SIMD指令。 SSE2 is baseline for x86-64,可在32位模式下作为扩展名使用。

SSE2或AVX2 memcmp可以使用pcmpeqb / pmovmskb并行比较16或32个字节。 (我不打算详细介绍如何在x86 asm或C intrinsics中编写memcmp。谷歌分别和/或在x86指令集引用中查找那些asm指令。如{{3}另请参阅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?(在AT&amp; T语法汇编语言中)。它肯定会更好;对于大缓冲区,它应该对齐一个源指针,而只使用movdqu用于另一个源指针,允许movdqu然后pcmpeqb使用内存操作数而不是2x movdqu,即使字符串相对于彼此未对齐。 xorl $0xFFFF,%eax / jnzcmp/jcc可以宏融合而xor / jcc无法融合的CPU上也不是最佳的。

一次性展开检查整个64字节高速缓存行也会隐藏循环开销。 (这与大块相同,然后在找到命中时循环回来)。 an old version from 2005 of Apple's x86-64 memcmp使用vpand执行此操作以在主大缓冲区循环中组合比较结果,最终组合为vptest,同时也从结果中设置标记。 (代码大小较小,但不超过vpand / vpmovmskb / cmp / jcc;但没有下行可能会降低延迟以减少循环退出时的分支错误预测惩罚)。 Glibc在动态链接时进行动态CPU调度;它在支持它的CPU上选择此版本。

希望Apple的memcmp这些日子更好;但是,在最近的Libc目录中,我根本看不到它的来源。希望他们在运行时发送到Haswell和后来的CPU的AVX2版本。

我链接的版本中的LLoopOverChunks循环仅在Haswell上每~2.5个循环运行1次迭代(每个输入16个字节); 10个融合域uops。但对于一个天真的C循环来说,这仍然比每个周期的1个字节快得多,或者比Python循环的情况要差得多。

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

在Python解释器自己的代码

中查找指令/热点
  

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

在Linux上,您可以运行perf record python ...然后perf report -Mintel来查看CPU花费最多的功能,以及这些功能中哪些指令最热门。您将获得我在此处发布的结果:Glibc's AVX2-movbe version。 (深入查看任何函数以查看运行的实际机器指令,显示为汇编语言,因为perf内置了反汇编程序。)

有关在每个事件中对调用图进行采样的细微差别视图,请参阅Why is float() faster than int()?

(当您正在寻找实际优化程序时,您想知道哪些函数调用很昂贵,因此您可以首先尝试避免它们。仅仅&#的分析34;自我&#34;时间会找到热点,但你不会总是知道哪些不同的呼叫者导致一个给定的循环运行大部分迭代。请参阅Mike Dunlavey关于该问题的答案。)< / p>

但是对于这个特定的情况,分析运行切片比较版本而不是大字符串的解释器应该有希望找到我认为它将花费大部分时间的memcmp循环。 (或者对于char-at-a-time版本,找到解释器代码&#34; hot&#34 ;.)

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

答案 1 :(得分:4)

这取决于实现和硬件。在不知道您的目标机器和特定分布的情况下,我无法肯定地说。但是,我强烈怀疑底层硬件和大多数硬件都有内存块指令。除此之外,这可以并行和流水线方式比较一对任意长的字符串(直到寻址限制)。例如,它可以在每个时钟周期比较一个切片处的8字节切片。这比使用字节级索引更快地 lot