Почему Collections.counter так медленно?

Я пытаюсь решить основную задачу Rosalind о подсчете нуклеотидов в заданной последовательности и возвращать результаты в списке. Для тех, кто не знаком с биоинформатикой, он просто подсчитывает количество вхождений четырех разных символов ( "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?

Ответ 1

Это не потому, что collections.Counter работает медленно, но на самом деле довольно быстро, но это инструмент общего назначения, подсчет символов - это лишь одно из многих приложений.

С другой стороны, str.count просто подсчитывает символы в строках и сильно оптимизирует для него одну и только задачу.

Это означает, что str.count может работать с базовым массивом C- char, в то время как он может избежать создания новых (или поиска существующих) строк длины-1-python во время итерации (что является тем, что for и Counter do).


Просто добавим еще несколько контекстов в этот оператор.

Строка хранится как массив C, завернутый как объект python. str.count знает, что строка является непрерывным массивом и, таким образом, преобразует символ, который вы хотите использовать, к символу C-, затем выполняет итерацию по массиву в собственном C-коде и проверяет равенство и, наконец, обертывает и возвращает число найденные вхождения.

С другой стороны, for и Counter используют протокол python-итерации. Каждый символ вашей строки будет обернут как объект python, а затем он (хеши и) сравнивает их внутри python.

Итак, замедление происходит потому, что:

  • Каждый символ должен быть преобразован в объект Python (это основная причина потери производительности).
  • Цикл выполняется в Python (не относится к Counter в python 3.x, потому что он был перезаписан в C)
  • Каждое сравнение должно выполняться в Python (вместо простого сравнения чисел в C-символах представлены числами)
  • Счетчик должен хэшировать значения, и ваш цикл должен индексировать ваш список.

Обратите внимание, что причина замедления аналогична вопросу о Почему массивы Python медленны?.


Я сделал несколько дополнительных тестов, чтобы узнать, в какой момент collections.Counter следует использовать более str.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)

и результат был нанесен с использованием :

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.count и Counter можно оценить только путем экстраполяции, потому что даже со 100 различными символами str.count все еще в 6 раз быстрее.

Ответ 2

Разницу во времени здесь довольно просто объяснить. Все сводится к тому, что работает внутри 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. Таким образом, это создает итератор строки, а затем производит каждый символ отдельно как фактический строковый объект. Thats много накладных расходов и основная причина, почему каждое решение, которое работает путем итерации строки в Python будет медленнее.

То же самое происходит с collection.Counter. Его реализован в Python, поэтому, несмотря на то, что он очень эффективен и гибкий, он страдает от той же самой проблемы, что ее просто никогда не приближается к родной по скорости.