Один и тот же код Python, похоже, имеет разные характеристики производительности - PullRequest
2 голосов
/ 28 мая 2020

Описание

В моем коде для создания N случайных строк длины k выражение генератора для создания случайной строки k -символа вызывается либо N+1, либо (N+1)*k ( он же N*k+N) раз. Похоже, это происходит с точно таким же кодом , и я могу переключаться туда и обратно, казалось бы, недетерминированно, переопределив вспомогательную функцию chrs(). Я подозреваю профилировщик, но понятия не имею о механизме.

Сведения о системе, если они имеют значение: CPython, Python 2.7.17 и Python 3.6.9 на Ubuntu 18.04 на HP Envy 15k (i7-4720)


Воспроизведение проблемы

Неправильный MCVE, поскольку я не смог детерминированно воспроизвести проблему; вместо этого, мы надеемся, что это приведет к одному из двух выходных данных профилировщика ниже. Если вы попытаетесь запустить это в REPL и повторно определить функцию chrs(), используя один и тот же код, вы сможете получить оба выхода профилировщика.

Я написал эту bits_strings() функцию при тестировании различных способы генерации N (num_strings) случайных строк длины k (len_string):

import cProfile
import random
import string

def chrs(bits, len_string):
    return ''.join(chr((bits >> (8*x)) & 0xff) for x in range(len_string))

def bits_strings(num_strings, len_string):
    return list(
        chrs(random.getrandbits(len_string*8), len_string)
        for x in range(num_strings)
    )

cProfile.run('bits_strings(1000, 2000)')

Для тестирования я использовал cProfile.run().

Когда Я запустил код в первый раз, он показался довольно быстрым:

cProfile.run('bits_strings(1000, 2000)')
         2005005 function calls in 0.368 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    1.970    1.970 <stdin>:1(bits_strings)
     1000    0.001    0.000    1.963    0.002 <stdin>:1(chrs)
     1001    0.001    0.000    1.970    0.002 <stdin>:2(<genexpr>)
        1    0.000    0.000    1.970    1.970 <string>:1(<module>)
  2000000    0.173    0.000    0.173    0.000 {chr}
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}
     1000    0.005    0.000    0.005    0.000 {method 'getrandbits' of '_random.Random' objects}
     1000    0.178    0.000    1.953    0.002 {method 'join' of 'str' objects}
     1001    0.009    0.000    0.009    0.000 {range}

, но позже:

cProfile.run('bits_strings(1000, 2000)')

         4005004 function calls in 1.960 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    1.961    1.961 <stdin>:1(bits_strings)
     1000    0.001    0.000    1.954    0.002 <stdin>:1(chrs)
  2001000    1.593    0.000    1.762    0.000 <stdin>:2(<genexpr>)
        1    0.000    0.000    1.961    1.961 <string>:1(<module>)
  2000000    0.170    0.000    0.170    0.000 {chr}
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}
     1000    0.005    0.000    0.005    0.000 {method 'getrandbits' of '_random.Random' objects}
     1000    0.182    0.000    1.944    0.002 {method 'join' of 'str' objects}
     1001    0.009    0.000    0.009    0.000 {range}

Разница выглядит так, как будто это <genexpr>, который - из cumtime и местоположения в различных аналогичных тестах я предположил, что это тот, который находится в chrs(). (Мой инстинкт подсказывает, что (chr((bits >> (8*x)) & 0xff) for x in range(len_string)) должен вызываться N раз, а chr((bits >> (8*x)) & 0xff) должен вызываться N*k раз).


Итак, в чем разница между двумя bits_strings() функции? Пытаясь найти разницу в коде между двумя попытками, я ничего не нашел. Фактически, у меня был сеанс Python REPL, в котором я определил функции, дважды запустил профилировщик (второй раз, чтобы проверить, была ли это проблема кеширования), а затем повторно определил функции, используя стрелку вверх, чтобы получить мой ранее введенный код. Он показал такое же расхождение: https://pastebin.com/1u1j1ZUt


Теории и мысли

ИЗМЕНИТЬ : Я подозреваю, что cProfile сейчас.

Переключение между «быстрой» и «медленной» версиями путем переопределения chrs(), но cProfile.run() было профилировано cProfile.run(). В оба наблюдаемых времени и согласно отчетам внешнего cProfile.run(), похоже, что внутренний cProfile.run() (тот, который профилирует bits_strings()) иногда занижает, сколько времени занимает bits_strings(): https://pastebin.com/C4W4FEjJ

Я также использовал time.time() до / после cProfile.run('bits_strings(1000,1000)) звонков и заметил несоответствие между «быстрым» профилем cumtime и фактическим временем настенных часов, затраченным при запуске метод.

Механизм все еще неясен.


  • Кеширование : тот же код, резко различающаяся производительность между запусками может быть кешированием вещь. Однако быстрая версия была первым запуском, который я когда-либо выполнял с этим кодом (хотя я раньше запускал другой код в том же сеансе REPL). И я могу переключаться с медленного на быстрое или с быстрого на медленное, просто переопределив chrs() и bits_strings(), хотя, действительно ли я переключаюсь или нет, кажется случайным. Также стоит отметить, что я смог переключить (afaict) только путем переопределения функций, а не просто перезапустив их. Поэтому я не уверен, что что будет кэшироваться или как это изменит количество вызовов <genexpr> с коэффициентом k при переопределении функции.

  • причуда cProfile : я запустил тот же код без cProfile.run() и рассчитал его, дважды получив текущий time.time() внутри bits_strings; общее затраченное время было сравнимо с временем cProfile.run() для медленного случая. Так что cProfile.run(), по крайней мере, не замедляет работу. Есть вероятность, что быстрый результат испорчен cProfile.run(), , но, как ни странно, для больших значений N и k ((10000, 10000)) я заметил заметную разницу между первым запуском bits_strings() и медленный позже днем ​​.

  • причуда интерпретатора : Я думал, что это могло происходить из-за того, что интерпретатор ошибочно оптимизирует выражения генератора внутри chrs() и bits_strings(), но это то, за что вы платите -вы пишете CPython интерпретатор, так что это кажется маловероятным.

  • Python 2,7 причуда : переопределение chrs() (с помощью стрелки вверх чтобы восстановить предыдущее определение) непоследовательно выполняет тот же трюк в python3: https://pastebin.com/Uw7PgF7i

  • Zal go, чтобы отомстить за это один раз я проанализировал HTML с регулярным выражением : не исключено

Мне очень хочется верить, что bits_strings() в разное время (разные определения одна и та же функция с одним и тем же кодом, а не разными запусками), фактически выполнялась по-разному. Но я понятия не имею, почему это могло быть так. Я подозреваю, что это более тесно связано с chrs(), чем с bits_strings().

Я не понимаю, почему переопределение chrs(), кажется, непредсказуемо влияет на вывод профилировщика.

1 Ответ

1 голос
/ 28 мая 2020

Это артефакт отчета о результатах cProfile, который не может различить guish два объекта кода с одинаковым именем, именем файла и номером строки.

При сборе статистики статистика поддерживается в бинарном дереве поиска с произвольной перебалансировкой , очевидно, как случайный вариант расширенного дерева. Адреса памяти кодового объекта используются как ключи для Python функций (и адреса памяти PyMethodDef для C функций).

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


В ваших тестах вы видите статистику для genexp в chrs или genexp в bits_strings. Какие статистические данные вы видите, зависит от того, какой объект кода genexp имеет более высокий адрес памяти.

Если бы вы запускали свой код как сценарий, а не в интерактивном режиме, genexps можно было бы различить по номеру строки, и оба genexps имели бы появился в результате. В интерактивном режиме номера строк перезапускаются для каждой «единицы» кода, введенной в интерактивном режиме (одна подсказка >>> и все последующие подсказки ...). Тем не менее, все еще существуют случаи, когда этот артефакт профилирования может возникать не интерактивно, например, с двумя генэкспами в одной строке или двумя файлами с одинаковым именем.

...