Это комбинация нескольких эффектов, в основном тот факт, что 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