Стал ли ввод-вывод медленнее с Python 2.7? - PullRequest
6 голосов
/ 29 мая 2020

В настоящее время у меня есть небольшой побочный проект, в котором я хочу как можно быстрее отсортировать файл размером 20 ГБ на моей машине. Идея состоит в том, чтобы разбить файл на части, отсортировать куски, объединить куски. Я просто использовал pyenv, чтобы синхронизировать код radixsort с разными версиями Python, и увидел, что 2.7.18 намного быстрее, чем 3.6.10, 3.7.7, 3.8.3 и 3.9.0a. Кто-нибудь может объяснить, почему Python 3.x медленнее, чем 2.7.18 в этом простом примере? Были ли добавлены новые функции?

import os


def chunk_data(filepath, prefixes):
    """
    Pre-sort and chunk the content of filepath according to the prefixes.

    Parameters
    ----------
    filepath : str
        Path to a text file which should get sorted. Each line contains
        a string which has at least 2 characters and the first two
        characters are guaranteed to be in prefixes
    prefixes : List[str]
    """
    prefix2file = {}
    for prefix in prefixes:
        chunk = os.path.abspath("radixsort_tmp/{:}.txt".format(prefix))
        prefix2file[prefix] = open(chunk, "w")

    # This is where most of the execution time is spent:
    with open(filepath) as fp:
        for line in fp:
            prefix2file[line[:2]].write(line)

Время выполнения (несколько запусков):

  • 2.7.18: 192,2 с, 220,3 с, 225,8 с
  • 3,6 .10: 302,5 с
  • 3,7,7: 308,5 с
  • 3.8.3: 279,8 с, 279,7 с (двоичный режим), 295,3 с (двоичный режим), 307,7 с, 380,6 с ( wtf?)
  • 3.9.0a: 292.6s

Полный код на Github вместе с минимальной полной версией

Unicode

Да, я знаю, что Python 3 и Python 2 работают со строками по-разному. Пробовал открывать файлы в двоичном режиме (rb / wb), см. Комментарии «двоичного режима». Они немного быстрее на пару пробежек. Тем не менее, Python 2.7 НАМНОГО быстрее во всех запусках.

Попробуйте 1: Доступ к словарю

Когда я сформулировал этот вопрос, я подумал, что доступ к словарю может быть причиной этой разницы. Однако я думаю, что общее время выполнения намного меньше для доступа к словарю, чем для ввода-вывода. Также timeit не показал ничего важного:

import timeit
import numpy as np

durations = timeit.repeat(
    'a["b"]',
    repeat=10 ** 6,
    number=1,
    setup="a = {'b': 3, 'c': 4, 'd': 5}"
)

mul = 10 ** -7

print(
    "mean = {:0.1f} * 10^-7, std={:0.1f} * 10^-7".format(
        np.mean(durations) / mul,
        np.std(durations) / mul
    )
)
print("min  = {:0.1f} * 10^-7".format(np.min(durations) / mul))
print("max  = {:0.1f} * 10^-7".format(np.max(durations) / mul))

Попробуйте 2: Время копирования

В качестве упрощенного эксперимента я попытался скопировать файл размером 20 ГБ:

  • cp через оболочку: 230 с
  • Python 2.7.18: 237 с, 249 с
  • Python 3.8.3: 233 с, 267 с, 272 с

Материал Python генерируется с помощью следующего кода.

Моя первая мысль заключалась в том, что дисперсия довольно высока. Так что это могло быть причиной. Но тогда дисперсия времени выполнения chunk_data также высока, но среднее значение для Python 2,7 заметно ниже, чем для Python 3.x. Так что, похоже, сценарий ввода-вывода не такой простой, как я пытался здесь.

import time
import sys
import os


version = sys.version_info
version = "{}.{}.{}".format(version.major, version.minor, version.micro)


if os.path.isfile("numbers-tmp.txt"):
    os.remove("numers-tmp.txt")

t0 = time.time()
with open("numbers-large.txt") as fin, open("numers-tmp.txt", "w") as fout:
    for line in fin:
        fout.write(line)
t1 = time.time()


print("Python {}: {:0.0f}s".format(version, t1 - t0))

Моя система

  • Ubuntu 20.04
  • Thinkpad T460p * ​​1065 *
  • Python через pyenv

1 Ответ

11 голосов
/ 03 июня 2020

Это комбинация нескольких эффектов, в основном тот факт, что Python 3 необходимо выполнять декодирование / кодирование Unicode при работе в текстовом режиме, а при работе в двоичном режиме данные будут отправляться через выделенные реализации буферизованного ввода-вывода.

Прежде всего, использование time.time для измерения времени выполнения использует время стены и, следовательно, включает в себя всевозможные Python несвязанные вещи, такие как кэширование и буферизация на уровне ОС, а также буферизация носителя. Он также отражает любое вмешательство в другие процессы, которым требуется носитель данных. Вот почему вы наблюдаете такие дикие вариации результатов по времени. Вот результаты для моей системы из семи последовательных прогонов для каждой версии:

py3 = [660.9, 659.9, 644.5, 639.5, 752.4, 648.7, 626.6]  # 661.79 +/- 38.58
py2 = [635.3, 623.4, 612.4, 589.6, 633.1, 613.7, 603.4]  # 615.84 +/- 15.09

Несмотря на большие различия, кажется, что эти результаты действительно указывают на разные тайминги, что может быть подтверждено, например, статистическим тестом:

>>> from scipy.stats import ttest_ind
>>> ttest_ind(p2, p3)[1]
0.018729004515179636

т.е. вероятность того, что тайминги возникли из одного и того же распределения, составляет всего 2%.

Мы можем получить более точную картину, измерив время процесса, а не время стены. В Python 2 это можно сделать с помощью time.clock, а Python 3.3+ предлагает time.process_time. Эти две функции сообщают следующие тайминги:

py3_process_time = [224.4, 226.2, 224.0, 226.0, 226.2, 223.7, 223.8]  # 224.90 +/- 1.09
py2_process_time = [171.0, 171.1, 171.2, 171.3, 170.9, 171.2, 171.4]  # 171.16 +/- 0.16

Теперь разброс данных намного меньше, поскольку тайминги отражают только процесс Python.

Эти данные предполагают, что Python 3 выполняется примерно на 53,7 секунды дольше. Учитывая большое количество строк во входном файле (550_000_000), это составляет около 97,7 наносекунд на итерацию.

Первым эффектом, вызывающим увеличение времени выполнения, являются строки Unicode в Python 3. Двоичные данные читать из файла, декодировать, а затем снова кодировать при обратной записи. В Python 2 все строки сразу сохраняются как двоичные строки, поэтому это не приводит к накладным расходам на кодирование / декодирование. Вы не видите этот эффект четко в своих тестах, потому что он исчезает в большом разбросе, вызванном различными внешними ресурсами, которые отражаются в разнице во времени стены. Например, мы можем измерить время, которое требуется для перехода от двоичного кода к юникоду к двоичному:

In [1]: %timeit b'000000000000000000000000000000000000'.decode().encode()                     
162 ns ± 2 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

Это включает два поиска атрибутов, а также два вызова функций, поэтому фактическое необходимое время меньше, чем значение указано выше. Чтобы увидеть влияние на время выполнения, мы можем изменить тестовый скрипт на использование двоичных режимов "rb" и "wb" вместо текстовых режимов "r" и "w". Это уменьшает результаты синхронизации для Python 3 следующим образом:

py3_binary_mode = [200.6, 203.0, 207.2]  # 203.60 +/- 2.73

Это сокращает время процесса примерно на 21,3 секунды или 38,7 наносекунды на итерацию. Это согласуется с результатами по времени для теста туда и обратно за вычетом результатов по времени для поиска имен и вызовов функций:

In [2]: class C: 
   ...:     def f(self): pass 
   ...:                                                                                       

In [3]: x = C()                                                                               

In [4]: %timeit x.f()                                                                         
82.2 ns ± 0.882 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

In [5]: %timeit x                                                                             
17.8 ns ± 0.0564 ns per loop (mean ± std. dev. of 7 runs, 100000000 loops each)

Здесь %timeit x измеряет дополнительные накладные расходы на разрешение глобального имени x и, следовательно, атрибута поиск и вызов функции составляют 82.2 - 17.8 == 64.4 секунд. Если дважды вычесть эти накладные расходы из приведенных выше данных туда и обратно, получаем 162 - 2*64.4 == 33.2 секунды.

Теперь все еще существует разница в 32,4 секунды между Python 3 в двоичном режиме и Python 2. Это происходит из-за того, что ввод-вывод в Python 3 проходит через (довольно сложную) реализацию io.BufferedWriter .write, а в Python 2 метод file.write довольно просто переходит к fwrite.

Мы можем проверить типы файловых объектов в обеих реализациях:

$ python3.8
>>> type(open('/tmp/test', 'wb'))
<class '_io.BufferedWriter'>

$ python2.7
>>> type(open('/tmp/test', 'wb'))
<type 'file'>

Здесь мы также должны отметить, что Приведенные выше результаты синхронизации для Python 2 были получены с использованием текстового режима, а не двоичного режима. Двоичный режим нацелен на поддержку всех объектов, реализующих буфер протокол , что приводит к дополнительной работе, выполняемой также для строк (см. Также этот вопрос ). Если мы переключимся в двоичный режим также для Python 2, то получим:

py2_binary_mode = [212.9, 213.9, 214.3]  # 213.70 +/- 0.59

, что на самом деле немного больше, чем результаты Python 3 (18,4 нс / итерация).

Эти две реализации также отличаются другими деталями, такими как реализация dict. Чтобы измерить этот эффект, мы можем создать соответствующую настройку:

from __future__ import print_function

import timeit

N = 10**6
R = 7
results = timeit.repeat(
    "d[b'10'].write",
    setup="d = dict.fromkeys((str(i).encode() for i in range(10, 100)), open('test', 'rb'))",  # requires file 'test' to exist
    repeat=R, number=N
)
results = [x/N for x in results]
print(['{:.3e}'.format(x) for x in results])
print(sum(results) / R)

Это дает следующие результаты для Python 2 и Python 3:

  • Python 2: ~ 56,9 наносекунд
  • Python 3: ~ 78,1 наносекунд

Эта дополнительная разница примерно в 21,2 наносекунды составляет примерно 12 секунд для полных 550M итераций.

Приведенный выше код синхронизации проверяет поиск dict только на один ключ, поэтому нам также необходимо убедиться, что нет коллизий ha sh:

$ python3.8 -c "print(len({str(i).encode() for i in range(10, 100)}))"
90
$ python2.7 -c "print len({str(i).encode() for i in range(10, 100)})"
90
Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...