Антонио Куни — инженер, давно занимающийся повышением производительности Python, а также разработчик PyPy. Он провёл на EuroPython 2025 в Праге презентацию «Мифы и легенды о производительности Python». Как можно догадаться из названия, он считает многие общепринятые сведения о производительности Python как минимум вводящими в заблуждение. На множестве примеров он показал, где, по его мнению, таятся истинные проблемы. Инженер пришёл к выводу, что управление памятью в конечном итоге наложит ограничения на возможности повышения производительности Python, но у него есть проект SPy, который, возможно, станет способом реализации сверхбыстрого Python.

Он начал своё выступление с просьбы: «Если вы считаете Python медленным или недостаточно быстрым, поднимите руку»; поднялось много рук, в отличие от презентации на PyCon Italy, где руку не поднял почти никто из присутствующих. «Совершенно другая аудитория», — сказал он с улыбкой. Антонио уже много лет работает над производительностью Python, он общался с множеством разработчиков на Python и слышал кучу устоявшихся мифов, которые захотел развеять.

Мифы

Первый миф: «Python не медленный»; Антонио, судя по количеству поднятых рук, понял, что большинство зрителей уже знало, что это миф. Сегодня многие говорят, что скорость Python не важна, потому что это язык-клей; «сегодня важны только GPU», поэтому Python достаточно быстр. Python действительно достаточно быстр для некоторых задач, и именно поэтому им пользуются многие.

Есть множество программ, в которых Python достаточно быстр, но это справедливо не для всех используемых программ на Python. Программы, которым нужно больше производительности Python, стали ведущей силой всех работ по оптимизации интерпретатора; также они заставляют разработчиков постоянно трудиться над совершенствованием производительности их программ, часто при помощи CythonNumba и так далее.

[Antonio Cuni]
Антонио Куни

На своих слайдах он представил эти два множества программ в виде кругов. Подмножество «программы, в которых Python достаточно быстр» полностью входит в множество «программы на Python», но ещё есть и множество «все возможные программы», охватывающие оба круга. В идеальном мире все возможные программы можно было бы писать на Python; сегодня же программы, которым нужна вся мощь процессора, нельзя писать на Python.

Следствием утверждения «это просто язык-клей» становится утверждение «нужно просто переписать самые горячие части на C/C++», хотя оно уже немного устарело; «сегодня обычно говорят, что нужно переписывать на Rust». В этом утверждении есть доля истины, таким образом можно ускорить код, но очень быстро такой подход наткнётся на непреодолимое препятствие. Принцип Парето гласит, что 80% времени тратится на 20% кода, поэтому оптимизация этих 20% должна помочь.

Но затем программа упрётся в закон Амдала, гласящий, что улучшения по оптимизации одной части кода ограничены временем, которое тратится на уже оптимизированный код; «то, что когда-то было горячей частью, стало очень быстрым, и теперь нужно оптимизировать всё остальное». Антонио показал диаграмму, на которой некая функция inner() занимала 80% времени; если его снизить, например, в десять раз, то основное время выполнения будут теперь занимать остальные части программы.

Ещё один «миф» заключается в том, что Python медленный из-за своей интерпретируемости; да, в этом тоже есть доля истины, но это лишь в малой мере определяет медленность Python. Вот пример простого выражения на Python:

p.x * 2

Компилятор C/C++/Rust может превратить подобное выражение в три операции: загрузку значения x, умножение его на два и сохранение результата. Однако в Python должен выполниться длинный список операций, начиная с определения типа p, вызова его метода getattribute(), а затем распаковки p.x и 2 с последующей запаковкой результата, что требует распределения памяти. Ни одна из этих операций не зависит от того, интерпретируемый ли Python; эти шаги нужны из-за семантики языка.

Статические типы

Когда разработчики пользуются в Python статическими типами, они задаются вопросом: могут ли компиляторы языка пропустить все эти шаги и просто выполнять операции напрямую? Антонио привёл пример:

def add(x: int, y: int) -> int:
    return x + y

print(add(2, 3))

Однако статическая типизация не применяется принудительно в среде выполнения, поэтому для нецелочисленных типов add() можно вызывать различными способами, например:

print(add('hello ', 'world')) # type: ignore

Это совершенно валидный код, а благодаря комментарию он пройдёт проверку типов, но сложение строк выполняется не так, как для целых чисел. По словам Антонио, статические типы «совершенно бесполезны с точки зрения оптимизации и производительности». Кроме того, приведём пример ещё одного совершенно валидного кода на Python:

class MyClass:
    def __add__(self, other):
        ...

def foo(x: MyClass, y: MyClass) -> MyClass:
    return x + y

del MyClass.__add__

«Статическая компиляция Python проблематична, потому что меняться может всё».

Возможно, все наши проблемы могут решить JIT-компиляторы? По словам Куни, они могут внести серьёзный вклад в повышение скорости Python или любого другого динамического языка. Но это приводит к ещё одной, более тонкой проблеме: существует трилемма — динамический язык, скорость и простота реализации. Можно одновременно выбрать лишь два пункта, но не три.

Исторически Python был динамическим просто реализуемым языком, но сегодня он развивается в сторону динамического быстрого языка в таких проектах, как JIT-компилятор CPython. При этом теряется простота реализации, что повышает сложность для разработчиков.

Однако на практике производительность с JIT спрогнозировать оказывается сложно. На основании своего опыта разработки PyPy и консалтинговых услуг по повышению производительности Python для заказчиков, Антонио считает, что для обеспечения наилучшей производительности необходимо задумываться о том, как себя будет вести JIT. Это сложный и подверженный ошибкам процесс; в некоторых ситуациях ему не удавалось активировать оптимизации в компиляторе PyPy, потому что код был слишком сложным.

Всё это приводит к так называемой «погоне за производительностью». Допустим, сначала есть медленная программа; при оптимизации её быстрого пути выполнения мы получаем повышение скорости программы. Разработчики начинают полагаться на эту дополнительную скорость, которая может внезапно пропасть из-за, казалось бы, никак не связанным с ней изменением в другом месте программы. Антонио привёл свой любимый пример: программа работала на PyPy (она была написана на Python 2) и неожиданно стала в десять раз медленнее; оказалось, в словаре строк использовался Unicode-ключ, из-за чего JIT деоптимизировал код и всё стало гораздо медленнее.

Динамическая часть

Вот пример кода, не делающий ничего особо интересного или полезного, но демонстрирующий некоторые из проблем, с которыми сталкиваются компиляторы Python:

import numpy as np

N = 10

def calc(v: np.ndarray[float], k: float) -> float:
   return (v * k).sum() + N

Для этого кода компилятор не может сделать никаких предположений. Похоже, что он обычным образом импортирует NumPy, функция calc() умножает каждый элемент массива v на k, складывает их всех при помощи sum() , а затем прибавляет константу N. Во-первых, import может вообще не добавить NumPy; возможно, существует какой-то хук импорта, делающий нечто совершенно неожиданное. Нельзя предполагать, что N равно десяти, потому что значение может быть изменено в другом месте кода; как и в примере выше с функцией add(), декларации типов в calc() тоже не гарантированы.

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

Этот язык медленный из-за своей чрезвычайно динамической природы, но в то же время этим Python и очень удобен. Динамические возможности в 99% не нужны, но оставшийся 1% и делает Python столь потрясающим. Библиотеки часто используют паттерны, полагаясь на динамическую природу языка, чтобы создавать удобные для конечных пользователей API, поэтому от этих фич нельзя просто так отказаться.

Игра

Антонио показал «игру с компилятором», пошагово демонстрируя примеры кода, доказывающие, насколько мало компилятор может на самом деле «знать» о коде. Вот этот код, похоже, должен выдавать какую-то ошибку:

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

def foo(p: Point):
    assert isinstance(p, Point)
    print(p.name) # ???

Компилятор знает, что p внутри foo() — это Point, не имеющая атрибута имени. Но, разумеется, Python — это динамический язык:

def bar():
    p = Point(1, 2)
    p.name = 'P0'
    foo(p)

А вот пример того, что компилятор даже не может предполагать существование метода:

import random

class Evil:
    if random.random() > 0.5:
        def hello(self):
            print('hello world')

Evil().hello() # ??‍♂️

Это совершенно допустимый Python, но такое не стоит определять в продакшене. В половине случаев это сработает, в другой половине вызовет исключение. Удачи с компиляцией такого кода.

Вот ещё один пример с функцией:

def foo():
    p = Person('Alice', 16)
    print(p.name, p.age)
    assert isinstance(p, Person) # <<<

Класс Person не встречался (пока), но был пустой класс Student. В этом случае assert будет сбойным из-за определения Person:

class Person:
    def __new__(cls, name, age):
        if age < 18:
            p = object.__new__(Student)
        else:
            p = object.__new__(Person)
        p.name = name
        p.age = age
        return p

«Может существовать класс с dunder-new [например, new()], возвращающий что-то никак не связанное с этой частью и не являющееся экземпляром класса. Удачи в оптимизации всего этого».

И последний пример игры с компилятором:

N = 10

@magic
def foo():
   return N

Антонио избавил от сахара декоратор @magic и добавил несколько assert:

def foo():
   return N

bar = magic(foo)

assert foo.__code__ == bar.__code__
assert bar.__module__ == '__main__'
assert bar.__closure__ is None

assert foo() == 10
assert bar() == 20 # ??

Объект кода для foo() и bar() одинаков, но они дают разные результаты. Как можно предположить, значение N было изменено magic(); код имеет следующий вид:

def rebind_globals(func, newglobals):
    newfunc = types.FunctionType(
        func.__code__,
        newglobals,
        func.__name__,
        func.__defaults__,
        func.__closure__)
    newfunc.__module__ = func.__module__
    return newfunc

def magic(fn):
    return rebind_globals(fn, {'N': 20})

Здесь возвращается версия функции (была передана foo()), по-другому видящая значения глобальных переменных. Этот пример может показаться преувеличенным, но Антонио много лет назад писал очень похожий код для отладчика Python pdb++.

Абстрагирование

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

def algo(points: list[tuple[float, float]]):
    res = 0
    for x, y in points:
        res += x**2 * y + 10
    return

Она получает список точек, каждая из которых представлена как кортеж из чисел с плавающей запятой, и выполняет с ними вычисления. Затем Антонио вынес вычисления в отдельную функцию:

def fn(x, y):
    return x**2 * y + 10

Это уже будет медленнее первоначального кода, потому что возникает оверхед вызова функции: функцию необходимо найти, создать frame object и так далее. Здесь может помочь JIT-компилятор, но это всё равно добавит больше оверхеда. Далее Антонио сделал ещё один шаг, перейдя к классу данных Point:

@dataclass
class Point:
    x: float
    y: float

def fn(p):
    return p.x**2 * p.y + 10

def algo(items: list[Point]):
    res = 0
    for p in items:
        res += fn(p)
    return

Абстракция «из Python в C», в которой горячие пути выполнения кода пишутся на C или каком-то другом компилируемом языке, тоже подвержены дополнительным затратам. Можно представить добавление в реализации на Python всё большего количества оптимизаций, например, представления списка объектов Point в виде простого линейного массива чисел с плавающей запятой без упаковки, но если fn() будет написана для C API Python, то эти числа придётся упаковывать и распаковывать (в обоих направлениях), что будет совершенно впустую потраченным временем. В условиях современного C API этого никак нельзя избежать. Один из способов ускорения запускаемых в PyPy программ заключался в удалении кода на C и выполнении вычислений непосредственно на Python; с оптимизацией такого кода PyPy справляется хорошо.

Слон в гостиной

При обсуждении производительности Python практически никогда не упоминают «слона в гостиной»: управление памятью. В современном оборудовании вычисления очень дёшевы, но узким местом становится память. Если данные находятся на любом из уровней кэша, доступ к ним малозатратен, однако доступ к ОЗУ довольно медленный. В общем случае, для обеспечения очень высокой производительности количество промахов кэша должно быть минимальным.

Однако языку Python свойственна неудобная для кэширования структура памяти. Антонио показал это на простом примере:

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

p = [Person('Alice', 16), Person('Bob', 21)]

У каждого Person есть два поля, которые в идеале должны размещаться в памяти рядом, и два объекта в списке тоже должны располагаться по соседству для удобства кэширования. Однако на практике, эти объекты разбросаны по памяти; это можно увидеть на визуализации из Python Tutor. Каждая стрелка — это указатель, по которому нужно следовать, то есть потенциальный промах кэша; даже для такой простой структуры данных таких стрелок почти дюжина.

Эту проблему нельзя решить просто при помощи JIT-компилятора; она нерешаема без изменения семантики. Неудобство кэширования — это неотъемлемая особенность Python. Антонио признаётся, что не знает, как решить эту проблему. Печальная правда заключается в том, что Python не может стать супербыстрым без поломки совместимости. Часть описанных выше динамических фич рано или поздно станет помехой для улучшений производительности. Если мы хотим сохранить эти фичи, то придётся смириться с невозможностью обеспечения части производительности.

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

Тем временем необходимо будет переделать систему типов с расчётом на производительность. В настоящее время типы опциональны и они не применяются принудительно, поэтому не могут использоваться для оптимизаций. Цель заключается в том, чтобы ориентированный на производительность код писался на самом Python, а не на каком-то языке, вызываемом из Python. Но для случаев, когда вызов другого языка по-прежнему предпочтителен, от дополнительных затрат (например, на упаковку) следует избавляться. Ещё важнее наше стремление к тому, что язык оставался Pythonic, потому что он нам нравится.

Куни сказал, что у него есть потенциальное решение, но оно не сделает Python быстрее, потому что, по его мнению, это невозможно. SPy, или Static Python — это созданный им несколько лет назад проект, призванный решить проблемы производительности. К SPy применимы все стандартные оговорки: «работа над ним всё ещё продолжается, проходят исследования и разработки, и мы не знаем, к чему он придёт». Самую актуальную информацию можно найти на странице GitHub по ссылке выше или в докладе о SPy на PyCon Italy.

Антонио показал небольшое демо по распознаванию границ при помощи камеры в реальном времени; оно работало в браузере с использованием PyScript. В левой части демо отображается сырой вывод камеры, а в правой части — распознавание границ, выполняемое через NumPy; NumPy достигает скорости всего в пару кадров в секунду. При переключении на алгоритм распознавания границ на основе SPy правое изображение начинает поспевать за камерой, работая с частотой примерно в 60fps. Код демо тоже выложен на GitHub.

Куни порекомендовал заинтересованным разработчикам изучить репозиторий SPy и его issue tracker; некоторые issue помечены, как «первая хорошая issue» и «требуется помощь». Также есть Discord-сервер для обсуждения проекта. Вскоре видео с докладом будет выложено на YouTube-канал EuroPython.

Комментарии (12)


  1. CryCrown
    11.08.2025 13:22

    Принцип Парето гласит, что 80% времени тратится на 20% кода, поэтому оптимизация этих 20% должна помочь.

    Разве принцип Парето говорит не об обратном? 80% кода на 20% времени. Или в программировании он действует по другому?


    1. CryCrown
      11.08.2025 13:22

      Да, я понимаю, что это не столь важно в рамках контекста статьи, но всё равно, просто любопытно


    1. cruiseranonymous
      11.08.2025 13:22

      Так он вообще не про "20% чего взять чтобы 80% результата получить", как его впаривать любят. Он про постериорное наблюдение, то есть "после того как сделали 100% и пересчитали что к чему, оказалось что...". А ещё там исходно нет где же эти заветные 20% искать, потому что такой цели и не было никогда.

      А то, что в статье - это не закон Парето(который не закон) - это анализ производительности, поиск узких мест и их опитимизация. Штука нужна, полезная, правильная. Но не Парето.


      1. CryCrown
        11.08.2025 13:22

        Понял, спасибо!


  1. dyadyaSerezha
    11.08.2025 13:22

    Динамические возможности в 99% не нужны, но оставшийся 1% и делает Python столь потрясающим.

    Почему бы не сделать некое объявление в языке, что данный код не использует динамические черты и тогда интерпретатор, JIT и ран-тайм смогут делать всё гораздо быстрее. Или наоборот, по умолчанию (для 99%) будет статика, а когда нужна динамика, надо явно это указать в коде.


  1. IgnatF
    11.08.2025 13:22

    Самый большой миф тут тот, что язык в честь питона змеи назвали. На самом деле была такая группа Monty Python , вот в честь нее язык и назвали.


    1. HemulGM
      11.08.2025 13:22

      А название этой комик-группы произошло от обычного слова, означающего "питон". И это слово как означало змею (питона), так и означает. И логотип этого языка содержит изображение змеи. Так что хватит поднимать эту глупую тему


  1. DX28
    11.08.2025 13:22

    Все вот говорят, что память дешева.

    Но почему-то уже не в первом проекте в крупном бизнесе на продакшене девопс даёт тебе только 2Гб на под. Ну если долго просить через СТО то расщедрится на 4 Гб. Ну и соответственно привет батчи, целые дни работы над оптимизацией и тд.


  1. shlmzl
    11.08.2025 13:22

    «Если вы считаете Python медленным или недостаточно быстрым, поднимите руку»; поднялось много рук, в отличие от презентации на PyCon Italy, где руку не поднял почти никто из присутствующих. «Совершенно другая аудитория», — сказал он с улыбкой.

    Другая аудитория, но не по части знания мифов, а по культуре дискуссии. Итальянский айтишник не будет публично негативно высказываться по адресу других айтишников. А постсоветские - легко, их спросили - они и ответили что знали.


  1. BobovorTheCommentBeast
    11.08.2025 13:22

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

    Проблема скорее что из этих языков пытаются сделать универсальную тулзу и начинают лепить костыли — тайпхинты и тайпскрипты.

    Короче в воздухе витает дух статически типизированного интерпретируемого языка... (Очередного)


    1. evgenyk
      11.08.2025 13:22

      А потом будут добавлять в него динамические фишки...


      1. BobovorTheCommentBeast
        11.08.2025 13:22

        Кмк это лучше, чем в динамику докидывать статику, которая там не пришей.