提供 maxsplit 时短字符串的 string.split 行为

The behavior of string.split for short strings when maxsplit is supplied

我最近 运行 发现了 python2.7 中 string.split 方法的一些有趣行为,特别是关于短字符串(少于大约 25 个字符,见下文),这涉及对比行为:

# Without maxplsit
my_string.split('A')

# With maxsplit=1
my_string.split('A', 1)

第二种方法对于短字符串实际上更慢,我很好奇为什么。

测试

这首先是我的同事发现的一个对 timeit 的小调用:

# Without maxsplit
$ python -m timeit -s "json_line='a|b|c'" "part_one='|'.split(json_line)[0]"
1000000 loops, best of 3: 0.274 usec per loop
# With maxsplit
$ python -m timeit -s "json_line='a|b|c'" "part_one='|'.split(json_line,1)[0]"
1000000 loops, best of 3: 0.461 usec per loop

我觉得这肯定很好奇,所以我整理了一个更详细的测试。首先,我编写了以下小函数,它生成由前十个大写字母组成的指定长度的 运行dom 字符串:

from random import choice

# 'A' through 'J'
choices = map(chr, range(65, 75))

def make_random_string(length):
    return ''.join(choice(choices) for i in xrange(length))

然后我写了几个测试函数来重复拆分和计时 运行domly 生成指定长度的字符串:

from timeit import timeit

def time_split_of_size(str_length, n_strs_to_split):
    times = []
    data = [make_random_string(str_length) for i in xrange(n_strs_to_split)]
    for s in data:
        t = timeit("'{s}'.split('A')".format(s=s),
                   setup="from __main__ import make_random_string",
                   number=1000)
        times.append(t)
    return times

def time_split_of_size_with_maxcount(str_length, n_strs_to_split):
    times = []
    data = [make_random_string(str_length) for i in xrange(n_strs_to_split)]
    for s in data:
        t = timeit("'{s}'.split('A', 1)".format(s=s),
                   setup="from __main__ import make_random_string",
                   number=1000)
        times.append(t)
    return times

然后我 运行 这些测试方法对不同大小的字符串:

from collections import OrderedDict
d = OrderedDict({})
for str_length in xrange(10, 10*1000, 25):
    no_maxcount = mean(time_split_of_size(str_length, 20))
    with_maxcount = mean(time_split_of_size_with_maxcount(str_length, 20))
    d[str_length] = [no_maxcount, with_maxcount]

这为您提供了您所期望的行为,对于 maxsplit=1 的方法,O(1),对于一路拆分,O(n)。这是按字符串长度绘制的时间图,几乎看不见的绿色曲线带有 maxsplit=1,蓝色曲线没有:

None少,我的同事发现的小刺的行为是真实的。这里有一些代码可以多次拆分短刺:

from collections import OrderedDict
d = OrderedDict({})
for str_length in xrange(1, 50, 2):
    no_maxcount = mean(time_split_of_size(str_length, 500))
    with_maxcount = mean(time_split_of_size_with_maxcount(str_length, 500))
    d[str_length] = [no_maxcount, with_maxcount]

结果如下:

长度小于 25 个字符左右的字符串似乎有一些开销。绿色曲线的形状也很好奇,它是如何平行于蓝色增加然后永久趋于平稳的。

我查看了源代码,您可以在这里找到它:

stringobject.c(第 1449 行) stringlib/split.h(第 105 行)

但没有什么明显的东西跳出来。

知道在为短字符串传递 maxsplit 时导致开销的原因是什么吗?

差异实际上与内部发生的事情无关string_split。事实上,默认拆分在该函数内花费的时间总是比 maxsplit=1 稍长,即使没有拆分要完成也是如此。这不是 PyArg_ParseTuple 的区别(我在没有对解释器进行检测的情况下得到的最好的报告说无论哪种方式都需要 0ns,所以无论有什么区别,都无关紧要)。

不同之处在于它需要额外的字节码来传递额外的参数。

正如 Stefan Pochmann 所建议的,您可以通过显式 maxsplit=-1:

测试来判断这一点
In [212]: %timeit ''.split('|')
1000000 loops, best of 3: 267 ns per loop
In [213]: %timeit ''.split('|', 1)
1000000 loops, best of 3: 303 ns per loop
In [214]: %timeit ''.split('|', -1)
1000000 loops, best of 3: 307 ns per loop

因此,即使在这个最小的示例中,-1 也比 1 稍慢。但我们谈论的是 4ns 的额外工作。 (我很确定这个 4ns 是因为 preallocating a list of size 12 instead of size 2,但我不想 运行 通过分析器来确定。)

与此同时,NOP 字节码需要 32ns 才能在我的系统上进行评估(来自另一个答案,我仍在努力寻找……)。我无法想象 LOAD_CONSTNOP.

因此,在您完成足够的工作以克服 32ns+ 之前,不传递 maxsplit 参数将节省您的时间。

如果不是很明显,下面是两种情况的反汇编:

  1           0 LOAD_CONST               0 ('')
              3 LOAD_ATTR                0 (split)
              6 LOAD_CONST               1 ('|')
              9 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             12 RETURN_VALUE

  1           0 LOAD_CONST               0 ('')
              3 LOAD_ATTR                0 (split)
              6 LOAD_CONST               1 ('|')
              9 LOAD_CONST               3 (-1)
             12 CALL_FUNCTION            2 (2 positional, 0 keyword pair)
             15 RETURN_VALUE

类似的例子:

In [217]: %timeit int()
10000000 loops, best of 3: 94 ns per loop
In [218]: %timeit int(0)
10000000 loops, best of 3: 134 ns per loop
In [235]: %timeit [0].pop()
1000000 loops, best of 3: 229 ns per loop
In [236]: %timeit [0].pop(0)
1000000 loops, best of 3: 270 ns per loop

所以 LOAD_CONST 在这两种情况下都需要大约 40ns,就像传递 -1 而不是 split.

没有参数一样

Python 3.4 有点难测试,因为它缓存了一些 2.7 没有的东西,但看起来传递一个额外的参数大约需要 33 纳秒——如果是关键字参数,则需要 533 纳秒。因此,如果您需要在 Python 3 中将微小的字符串拆分十亿次,请使用 s.split('|', 10),而不是 s.split('|', maxsplit=10)

正确的初始测试(原始测试有 json_line and '|' 混淆)

python -m timeit -s "json_line='a|b|c'" "part_one=json_line.split('|')[0]"
1000000 loops, best of 3: 0.239 usec per loop
python -m timeit -s "json_line='a|b|c'" "part_one=json_line.split('|',1)[0]"
1000000 loops, best of 3: 0.267 usec per loop

时差小了。