为啥 Collections.counter 这么慢?

Posted

技术标签:

【中文标题】为啥 Collections.counter 这么慢?【英文标题】:Why is Collections.counter so slow?为什么 Collections.counter 这么慢? 【发布时间】:2017-05-26 11:36:42 【问题描述】:

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

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

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

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

长序列运行几次 多次运行短序列。

这是我的代码:

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)

结果:

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 是怎么回事?

【问题讨论】:

这确实很有趣。您可以稍微修改第一个方法而不计算最后一个(“T”),因为它们应该是序列的len 减去“A”、“C”和“G”。我也要运行它 测试1:方法1:0.24,方法2:19.73,方法3:4.63测试2:方法1:0.26,方法2:19.35,方法3:4.30。至少 counter 比方法 2 更快,这是没有冒犯的错误代码。 没什么好奇怪的。方法 1 使用 C 代码,甚至是非常简单的 C 代码。而你只做了四次。难怪它要快得多。 考虑到您的最后一个问题,想象有 100 个核苷酸而不是 4 个。要对方法 1 进行编码,您必须将 sequence 转换为 set 并为集合中的元素运行循环。它可能会开始变得越来越低效 【参考方案1】:

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

另一方面,str.count 只计算字符串中的字符,并且高度针对其唯一的任务进行了优化。

这意味着str.count 可以在底层 C-char 数组上工作,同时它可以避免在迭代期间创建新的(或查找现有的)length-1-python-strings(这就是 forCounter 做)。


只是为了给这个语句添加更多上下文。

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

另一方面,forCounter 使用 python-iteration-protocol。字符串的每个字符都将被包装为 python-object,然后它(散列并)在 python 中比较它们。

所以减速是因为:

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

注意减速的原因类似于Why are Python's arrays slow?的问题。


我做了一些额外的基准测试来找出collections.Counterstr.count 更受欢迎。为此,我创建了包含不同数量的唯一字符的随机字符串并绘制了性能:

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绘制的:

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 的结果非常相似,所以我没有明确列出。

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

Python 2.7 的结果

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

【讨论】:

虽然可以理解用户制作的循环,但人们仍然想知道为什么Counter 也不使用 C 代码。看起来很傻。 Counter 确实使用了 C 代码,至少在 python 3.6 中是这样,这使得 in 的性能优于 for 循环,但仍然比 str.count 差。 请同时使用 Python 2 和 Python 3。在 Python 3 中,Counter 用 C 重写。 @dawg 好点。我忘了提到时间是用 python-3.x 完成的。我现在已经包含了它以及 python-2.x 的时间安排【参考方案2】:

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

考虑一下,像在你感兴趣的字符上拼出循环这样微不足道的事情已经给你带来了 2 个因素,即

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()

%%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)
%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

【讨论】:

【参考方案3】:

这里的时差很容易解释。这一切都归结为在 Python 中运行的内容以及作为本机代码运行的内容。后者总是会更快,因为它不会带来大量的评估开销。

这就是为什么调用str.count() 四次比其他任何方法都快的原因。尽管这会迭代字符串四次,但这些循环在本机代码中运行。 str.count 是用 C 实现的,因此开销很小,因此速度非常快。真的很难打败它,尤其是当任务如此简单时(只寻找简单的字符相等)。

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

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 也有同样的问题。它是implemented in Python,因此尽管它非常高效和灵活,但它也存在同样的问题,即它在速度方面永远不会接近原生。

【讨论】:

以上是关于为啥 Collections.counter 这么慢?的主要内容,如果未能解决你的问题,请参考以下文章

如何处理名称冲突 collections.Counter 和 typing.Counter?

python collections模块 计数器(counter)

Python_collections_Counter计数器部分功能介绍

以降序遍历 collections.Counter() 实例的 Pythonic 方式?

collections.Counter用法

collections.Counter用法