关于python:为什么Collections.counter这么慢?

Why is Collections.counter so slow?

我正在尝试解决Rosalind的基本问题,即计算给定序列中的核苷酸,并将结果返回到列表中。对于那些不熟悉生物信息学的人来说,它只是计算字符串中4个不同字符('A','C','G','T')的出现次数。

我期望collections.Counter是最快的方法(首先是因为他们声称自己具有高性能,其次是因为我看到很多人在使用它来解决这个特定问题)。

但令我惊讶的是,这种方法是最慢的!

我比较了三种不同的方法,分别使用timeit和运行两种类型的实验:

  • 长时间运行几次
  • 短时间运行很多次。

这是我的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import timeit
from collections import Counter

# Method1: using count
def method1(seq):
    return [seq.count('A'), seq.count('C'), seq.count('G'), seq.count('T')]

# method 2: using a loop
def method2(seq):
    r = [0, 0, 0, 0]
    for i in seq:
        if i == 'A':
            r[0] += 1
        elif i == 'C':
            r[1] += 1
        elif i == 'G':
            r[2] += 1
        else:
            r[3] += 1
    return r

# method 3: using Collections.counter
def method3(seq):
    counter = Counter(seq)
    return [counter['A'], counter['C'], counter['G'], counter['T']]


if __name__ == '__main__':

    # Long dummy sequence
    long_seq = 'ACAGCATGCA' * 10000000
    # Short dummy sequence
    short_seq = 'ACAGCATGCA' * 1000

    # Test 1: Running a long sequence once
    print timeit.timeit("method1(long_seq)", setup='from __main__ import method1, long_seq', number=1)
    print timeit.timeit("method2(long_seq)", setup='from __main__ import method2, long_seq', number=1)
    print timeit.timeit("method3(long_seq)", setup='from __main__ import method3, long_seq', number=1)

    # Test2: Running a short sequence lots of times
    print timeit.timeit("method1(short_seq)", setup='from __main__ import method1, short_seq', number=10000)
    print timeit.timeit("method2(short_seq)", setup='from __main__ import method2, short_seq', number=10000)
    print timeit.timeit("method3(short_seq)", setup='from __main__ import method3, short_seq', number=10000)

结果:

1
2
3
4
5
6
7
8
9
Test1:
Method1: 0.224009990692
Method2: 13.7929501534
Method3: 18.9483819008

Test2:
Method1: 0.224207878113
Method2: 13.8520510197
Method3: 18.9861831665

在两个实验中,方法1比方法2和3快得多!

所以我有一系列问题:

  • 我是在做错什么,还是确实比其他两种方法慢?有人可以运行相同的代码并共享结果吗?

  • 如果我的结果正确,(也许应该是另一个问题),有没有比使用方法1更快的方法来解决此问题?

  • 如果count更快,那么collections.Counter怎么处理?


这不是因为collections.Counter很慢,实际上是相当快,但是它是一个通用工具,对字符进行计数只是许多应用程序之一。

另一方面,str.count仅对字符串中的字符进行计数,并且对其进行了严格的优化以使其成为唯一任务。

这意味着str.count可以在基础C- char数组上工作,同时可以避免在迭代过程中创建新的(或查找现有的)length-1-python字符串(这是forCounter做)。

只是为该语句添加更多上下文。

字符串存储为包装为python对象的C数组。 str.count知道该字符串是一个连续的数组,因此将要转换为co的字符转换为C-"字符",然后在本机C代码中遍历该数组并检查是否相等,最后包装并返回发现的事件。

另一方面,forCounter使用python-iteration-protocol。字符串的每个字符都将包装为python-object,然后将其(哈希和)在python中进行比较。

因此,减速是因为:

  • 每个字符都必须转换为Python对象(这是性能下降的主要原因)
  • 循环是在Python中完成的(不适用于python 3.x中的Counter,因为它是用C重写的)
  • 每个比较都必须在Python中完成(而不仅仅是比较C中的数字-字符由数字表示)
  • 计数器需要散列值,而循环需要索引列表。

请注意,速度降低的原因类似于关于Python的阵列为什么速度慢的问题。

我做了一些其他的基准测试,以找出在什么时候collections.Counter应该优先于str.count。为此,我创建了包含不同数量的唯一字符的随机字符串,并绘制了性能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from collections import Counter
import random
import string

characters = string.printable  # 100 different printable characters

results_counter = []
results_count = []
nchars = []

for i in range(1, 110, 10):
    chars = characters[:i]
    string = ''.join(random.choice(chars) for _ in range(10000))
    res1 = %timeit -o Counter(string)
    res2 = %timeit -o {char: string.count(char) for char in chars}
    nchars.append(len(chars))
    results_counter.append(res1)
    results_count.append(res2)

并使用matplotlib绘制结果:

1
2
3
4
5
6
7
8
9
import matplotlib.pyplot as plt

plt.figure()

plt.plot(nchars, [i.best * 1000 for i in results_counter], label="Counter",   c='black')
plt.plot(nchars, [i.best * 1000 for i in results_count],   label="str.count", c='red')
plt.xlabel('number of different characters')
plt.ylabel('time to count the chars in a string of length 10000 [ms]')
plt.legend()

Python 3.5的结果

Python 3.6的结果非常相似,因此我没有明确列出它们。

enter image description here

因此,如果您要计算80个不同的字符,Counter将变得更快/可比,因为它仅遍历字符串一次,而不像str.count遍历字符串多次。这将弱地依赖于字符串的长度(但测试仅显示出非常弱的差异+/- 2%)。

Python 2.7的结果

enter image description here

在Python-2.7中,collections.Counter是使用python(而不是C)实现的,并且速度慢得多。 str.countCounter的收支平衡点只能通过外推法估算,因为即使使用100个不同的字符,str.count仍然快6倍。


这里的时差很容易解释。这一切都归结为在Python中运行的内容以及以本机代码运行的内容。后者将始终更快,因为它没有很多评估开销。

现在,这已经是为什么四次调用str.count()的速度比其他任何方法都要快的原因。尽管这将字符串重复四次,但这些循环仍以本机代码运行。 str.count是用C实现的,因此开销很小,因此非常快。做到这一点真的很难,尤其是当任务如此简单(仅寻找简单的字符相等性)时。

第二种收集数组中计数的方法实际上是以下方法的性能较低的版本:

1
2
3
4
5
6
7
8
9
10
11
12
def method4 (seq):
    a, c, g, t = 0, 0, 0, 0
    for i in seq:
        if i == 'A':
            a += 1
        elif i == 'C':
            c += 1
        elif i == 'G':
            g += 1
        else:
            t += 1
    return [a, c, g, t]

这里,所有四个值都是单独的变量,因此更新它们非常快。实际上,这比更改列表项要快一点。

但是,这里的总体性能"问题"是这会在Python中迭代字符串。因此,这将创建一个字符串迭代器,然后将每个字符分别生成为实际的字符串对象。这会产生很大的开销,这也是每个通过迭代Python中的字符串而起作用的解决方案都会变慢的主要原因。

collection.Counter也存在相同的问题。它是用Python实现的,因此即使它非常高效和灵活,也遭受着同样的问题,即就速度而言,它永远不会接近原生。


正如其他人已经指出的那样,您正在将相当具体的代码与相当一般的代码进行比较。

考虑一下,将您感兴趣的字符拼出一个循环这样的琐碎小事已经为您买到了因子2,即

1
2
3
4
5
6
7
8
9
10
11
12
13
def char_counter(text, chars='ACGT'):
    return [text.count(char) for char in chars]


%timeit method1(short_seq)
# 100000 loops, best of 3: 18.8 μs per loop
%timeit char_counter(short_seq)
# 10000 loops, best of 3: 40.8 μs per loop

%timeit method1(long_seq)
# 10 loops, best of 3: 172 ms per loop
%timeit char_counter(long_seq)
# 1 loop, best of 3: 374 ms per loop

您的method1()是最快但不是最有效的,因为输入会针对您要检查的每个字符完全循环通过,因此无法利用这样的事实,即一旦分配了字符,您就可以轻松地缩短循环 角色类别之一。

不幸的是,Python没有提供一种快速的方法来利用问题的特定条件。
但是,您可以为此使用Cython,然后您就可以胜过method1()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
%%cython -c-O3 -c-march=native -a
#cython: language_level=3, boundscheck=False, wraparound=False, initializedcheck=False, cdivision=True, infer_types=True

import numpy as np


cdef void _count_acgt(
        const unsigned char[::1] text,
        unsigned long len_text,
        unsigned long[::1] counts):
    for i in range(len_text):
        if text[i] == b'A':
            counts[0] += 1
        elif text[i] == b'C':
            counts[1] += 1
        elif text[i] == b'G':
            counts[2] += 1
        else:
            counts[3] += 1


cpdef ascii_count_acgt(text):
    counts = np.zeros(4, dtype=np.uint64)
    bin_text = text.encode()
    return _count_acgt(bin_text, len(bin_text), counts)
1
2
3
4
%timeit ascii_count_acgt(short_seq)
# 100000 loops, best of 3: 12.6 μs per loop
%timeit ascii_count_acgt(long_seq)
# 10 loops, best of 3: 140 ms per loop