【问题标题】:Why is string comparison so fast in python?为什么python中的字符串比较如此之快?
【发布时间】:2018-10-01 17:00:18
【问题描述】:

当我解决以下示例算法问题时,我很想了解字符串比较在 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:二进制搜索

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

又称二分查找。

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

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

这张图显示了两件事:

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

为什么binarySearch 这么好?我想是因为

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

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

图B

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

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

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 中的子字符串的成本。

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

运行时分析

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

charByChar

charByChar(s1, s2) = costOfOneChar * prefixLen

在哪里

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

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

slice 是访问一个字符的成本,相当于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(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)) &gt; O(binarySearch(s1, s2)

2 ** prefixLen = s1Len

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

但是,显然charByChar 的性能要好得多。经过一番反复试验,s1Len = 200 * prefixLen时两种算法的性能大致相当

为什么关系是 200 倍?

【问题讨论】:

  • 比较整个字符串发生在 C 级别,这将使其更快。不知道这是否解释了你的整个观察。
  • 要回答这个问题,我们必须确切地确定 CPython 如何解释 smaller[:mid] == bigger[:mid] 以及它如何解释 smaller[p] != bigger[p]
  • 你的时间复杂度分析太草率了。但即使是更严格的问题也不会得出答案。
  • 了解您的输入将有助于更好地理解情节——这两个字符串通常是非常相似还是非常不同? ASCII 还是 Unicode?内存中的字符串是否已经存在(可能是刚刚创建并由 CPU 缓存)还是在文件中?内存映射文件?我的直觉告诉我最好的解决方案是开始比较 len 1, 4, 16, ... 直到比较失败,然后切换到对更短的拆分稍微加权的二分搜索。实现将使用 MemoryView 或缓冲区。
  • 反编译 Python 字节码不会告诉你那么多,Python 字节码运算符是相当高级的构造。要回答您的子问题字符串比较发生在 cpython 的哪个位置?,请参阅Runtime of python's if substring in string

标签: python x86 interpreter cpython strncmp


【解决方案1】:

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

或者,如果它是一个选项并且问题很重要,则制作一个经过 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 个字节,并运行 much 比 C 或程序集中的一次字节循环更快)。因此,还有另一个 16 到 32 的因数(如果内存带宽不是瓶颈)将 Python 和 C 循环之间的速度差异乘以 20 到 100 的因数。或者取决于您的 memcmp 的优化程度,每个周期可能“仅”6 或 8 个字节。

对于中型缓冲区的 L2 或 L1d 高速缓存中的数据很热,在 Haswell 或更高版本的 CPU 上,memcmp 的每个周期预期 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 循环/切片开销;在来自解释器的memcmp 调用之间的 Python 开销期间,硬件预取应该仍然有效,因此我们不需要很大的粒度。但是对于非常大的字符串,如果 8k 不会使内存带宽饱和,那么可能会使其达到 64k(您的 L2 缓存是 256kiB;i5 确实告诉我们这么多)。

memcmp到底是怎么这么快的:

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

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

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

但大多数现代 CPU 以及所有 x86-64 处理器都有 SIMD 指令。 SSE2 is baseline for x86-64,可作为 32 位模式的扩展。

SSE2 或 AVX2 memcmp 可以使用 pcmpeqb / pmovmskb 并行比较 16 或 32 字节。 (我不打算详细介绍如何在 x86 asm 或使用 C 内部函数中编写 memcmp。单独谷歌,和/或在 x86 指令集参考中查找这些 asm 指令。如 http://felixcloutier.com/x86/index.html。另请参阅the x86 tag wiki 用于 asm 和性能链接。例如,Why is Skylake so much better than Broadwell-E for single-threaded memory throughput? 有一些关于单核内存带宽限制的信息。)

我在他们的开源网站上找到了an old version from 2005 of Apple's x86-64 memcmp(使用 AT&T 语法汇编语言)。肯定会更好;对于大缓冲区,它应该对齐一个源指针,并且只对另一个源指针使用movdqu,允许movdqu 然后pcmpeqb 使用内存操作数而不是2x movdqu,即使字符串相对于彼此未对齐。 xorl $0xFFFF,%eax / jnzcmp/jcc 可以宏融合但 xor / jcc 不能融合的 CPU 上也不是最优的。

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

希望 Apple 的 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 在哪些功能上花费的时间最多,以及这些功能中的哪些指令最热。您将获得类似于我在此处发布的结果:Why is float() faster than int()?。 (深入任何函数以查看实际运行的机器指令,显示为汇编语言,因为perf 内置了反汇编程序。)

有关对每个事件的调用图进行采样的更细致的视图,请参阅linux perf: how to interpret and find hotspots

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

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

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

【讨论】:

  • 非常感谢彼得,我会跟进链接并在批准您的答案之前做一些额外的阅读。再次感谢?
  • 如果有人可以检查我的挥手并猜测 memcmp 与 UTF-8 处理成本,那就太好了。切片比较肯定比 Char 比较更有效,它的成本有一些固定开销 + 一些按大小的比例因子,但我不知道这些常量在任何特定的 Python 解释器中是什么样的,如果它的 IDK每字符缩放是否与memcmp 一样便宜。
【解决方案2】:

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

【讨论】:

  • 感谢@Prune 我在 Intel Core i5 上运行它,但我想我会在大多数现代 CPU 上得到相同的结果。底线是您声称性能提升来自 CPU 而不是解释器或 C 级别。如何深入查找我的代码调用的 C 指令和 CPU 指令?
  • 您需要阅读解释器的文档以生成底层 C 代码。然后您需要继续该过程以找到该 C 代码上使用的编译标志,添加 -a 选项(我认为这仍然是正确的)以生成汇编代码。您是否已经阅读过英特尔架构汇编?
  • 在字节级操作中,将 C 语言与另一种语言进行比较是错误的。如果一个字符至少包含 1 个字节,则过滤器将在需要编码时增长。此外,字符组(String)在 C 语言中不被视为数据类型(为此,每次比较过程中都必须重新分析字符组)。仅当函数之间的数据传输是固定类型(可接受)时才提供性能。简而言之,“C”语言中不存在称为“String”的标准数据类型。
猜你喜欢
  • 1970-01-01
  • 2011-06-21
  • 2012-02-09
  • 2022-03-30
  • 1970-01-01
  • 1970-01-01
  • 2013-05-06
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多