#python
Продолжаем серию постов для
Python-собесов!В прошлых постах мы научились ускорять
Python (FFI & Python Compilers). Теперь поговорим о том, что именно нужно ускорять, потому что оптимизировать абсолютно всё - сложно, долго и дорого (да зачастую и не нужно) 😵.Базовый алгоритм выглядит следующим образом:
1. Замерили время выполнения кода.
2. Нашли узкое место.
3. Оптимизировали.
4. Проверили, что стало лучше. Если нет или недостаточно, то возвращаемся к пункту 1 😄.
Начнём с простейших измерений.
На собесах часто спрашивают как в коде замерить время выполнения отдельной функции. К сожалению, ответ
time() встречается неприлично часто. Однако time() это так называемые «wall clock» 🕚 - они не монотонны (могут перепрыгивать из-за NTP, смены поясов, гибернации), а их точность и разрешение невысоки и зависят от настроек ОС. Вместо них лучше использовать монотонные счётчики:-
perf_counter_ns() - точный и монотонный аналог time() (для общих случаяев);-
process_time_ns() - только время на CPU (удобен для числодробилок).Пример кода:
from time import perf_counter_ns as pc_ns
def work(n):
s = 0
for i in range(n):
s += i*i
return s
if __name__ == "__main__":
t = pc_ns()
work(1000000)
dt = pc_ns() - t
print(f"{dt/1e6:.3f} ms")
и его запуск:
python3 pc_ns_demo.py
23.767 ms
Один прогон не слишком показателен - виной тому «шум» планировщика, кэши, вызовы GC и т.д. diff счётчиков можно сохранять в массив в цикле, но лучше использовать готовый инструмент -
timeit. Он позволяет запускать много раз функции или даже программы, задавая как число прогонов в замере (number), так и число самих замеров (repeat), тем самым получая устойчивое распределение времени.Пример кода:
import timeit
from pc_ns_demo import work
rs = timeit.repeat(lambda: work(1000000), number=10, repeat=10)
print("runs:")
for i, t in enumerate(rs, 1):
print(f" {i:02d}: {t/10*1e3:.3f} ms")
bs = min(rs)
print(f"min: {bs/10*1e3:.3f} ms")
и его вызов:
python3 timeit_demo.py
runs:
01: 23.849 ms
...
10: 23.650 ms
min: 23.650 ms
Ещё короче:
python3 -m timeit -n 10 -r 10 -u msec -s "from pc_ns_demo import work" "work(1000000)"
10 loops, best of 10: 23.5 msec per loop
Поговорим о профилировании - определении конкретных медленных участков кода с
cProfile. Он позволяет подсчитать число вызовов каждой функции (ncalls), её целевое и суммарное время исполнения (без/с учётом вызовов функций внутри) (tottime, percall, cumtime, percall) с привязкой к определению (filename:...).Запуск профайлера для программы:
python3 -m cProfile -o prof.bin pc_ns_demo.py
24.467 ms
python -c "import pstats;p=pstats.Stats('prof.bin');p.sort_stats('tottime').print_stats(20)"
Fri Dec 19 06:00:17 2025 prof.bin
7 function calls in 0.024 seconds
Ordered by: internal time
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.024 0.024 0.024 0.024 pc_ns_demo.py:3(work)
...
Файл
prof.bin можно визуализировать пакетом snakeviz - скрин в комментах.И внутри
python-кода:import cProfile
import pstats
from io import StringIO
from pc_ns_demo import work
def kek(a, b, c):
return work(a) + work(b) + work(c)
pr = cProfile.Profile()
pr.enable()
print(kek(1000000, 2000000, 3000000))
pr.disable()
s = StringIO()
pstats.Stats(pr, stream=s).sort_stats("cumtime").print_stats(5)
print(s.getvalue())
и запуск:
python3 cprofile_demo.py
11999993000001000000
6 function calls in 0.146 seconds
Ordered by: cumulative time
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.000 0.000 0.146 0.146 /home/mikhail/cprofile_demo.py:6(kek)
3 0.146 0.049 0.146 0.049 /home/mikhail/pc_ns_demo.py:3(work)
...
Существуют и другие инструменты вроде
py-spy и scalene. Они позволяют периодически дампить callstack процесса, а scalene показывает ещё и аллокации 🤓. Вот мы и познакомились с джентльменским набором тайминга и профайлинга в Python! ⚙️🚀С уважением,
Масягин Михаил
