Введение
Статья 505 уже коснулась GIL как одного из факторов производительности — в этой статье разберём шире, какие именно категории операций в Python заметно медленнее эквивалентного C++ кода, и как практически комбинировать оба языка в одном проекте, используя сильные стороны каждого.
Концепция
Python, как интерпретируемый язык с динамической типизацией, в общем случае медленнее эквивалентного, скомпилированного C++ кода для чисто вычислительных задач (статья 455 алгоритмического цикла, чьи принципы сложности алгоритмов одинаково применимы к обоим языкам, но с разными константами производительности) — особенно заметна эта разница для циклов с большим количеством итераций, выполняющих относительно простые операции на каждой итерации, где накладные расходы интерпретации каждой отдельной Python-операции составляют значительную долю общего времени. Cython позволяет писать код, синтаксически похожий на Python, но компилируемый в C-код (а затем в машинный код), с возможностью явного указания статических типов для критичных участков, что устраняет значительную часть накладных расходов интерпретации именно для этих явно типизированных частей. Альтернативный подход — написание производительно критичных частей напрямую на C++ как отдельное расширение (через pybind11 или аналогичный инструмент связывания), вызываемое из основного, по большей части обычного Python-кода приложения.
Пример кода: Производительность
# Чистый Python — заметно медленный для большого объёма численных вычислений
def sum_of_squares_python(n: int) -> int:
total = 0
for i in range(n):
total += i * i
return total
# Для n = 100_000_000 это может занять несколько секунд на типичном оборудовании
# Cython-версия (файл sum_squares.pyx) — статическая типизация устраняет накладные расходы интерпретации
def sum_of_squares_cython(long n) -> long:
cdef long total = 0 # явная, статическая типизация — ключевое отличие от обычного Python
cdef long i
for i in range(n):
total += i * i
return total
# Компилируется через cythonize в обычный, импортируемый из Python модуль —
# производительность приближается к эквивалентному C++ коду
// C++ расширение через pybind11 — альтернативный подход, явное разделение языков
#include <pybind11/pybind11.h>
long sum_of_squares_cpp(long n) {
long total = 0;
for (long i = 0; i < n; ++i) {
total += i * i;
}
return total;
}
PYBIND11_MODULE(fast_math, m) {
m.def("sum_of_squares", &sum_of_squares_cpp, "Быстрое вычисление суммы квадратов");
}
# Использование C++ расширения из основного Python-кода приложения PySide6
import fast_math # импорт скомпилированного C++ расширения, как обычного Python-модуля
from PySide6.QtCore import QThread
class CalculationWorker(QThread):
def run(self):
result = fast_math.sum_of_squares(100_000_000) # реально нативная скорость C++,
# вызываемая прямо из Python-кода
print(f"Результат: {result}")
Пояснения к коду
sum_of_squares_python показывает типичный, «наивный» Python-код для CPU-интенсивной задачи — каждая итерация цикла включает накладные расходы динамической типизации и интерпретации байткода, которые накапливаются для большого количества итераций. Cython-версия с явным cdef long total/cdef long i показывает ключевой механизм ускорения — статическая типизация переменных, используемых в горячем цикле, позволяет компилятору Cython сгенерировать эффективный C-код для этого цикла, близкий по производительности к эквивалентному, напрямую написанному на C++ коду, без необходимости полностью переписывать всю функцию на другом языке. Пример с pybind11 показывает альтернативный, более радикальный подход — функция целиком написана на C++ и компилируется в отдельный модуль расширения, импортируемый из Python кода точно так же, как обычный, чистый Python-модуль (import fast_math), что даёт максимальную производительность для критичной части кода при сохранении удобства Python для остальной, не критичной по производительности логики приложения.
Подводные камни
- Преждевременная оптимизация через Cython/C++ расширения для кода, чья производительность не была реально измерена и не является действительным узким местом приложения — аналогично общему принципу профилирования из основного цикла статей и QML-цикла (статья 494), оптимизация через дополнительную сложность компилируемых расширений должна следовать за объективными данными профилирования (через стандартные Python-инструменты профилирования,
cProfileили аналогичные), а не интуитивным предположением о том, что «Python вообще медленный», без подтверждения, что конкретно эта часть кода реально является узким местом производительности всего приложения. - Использование Cython без явного указания статических типов (
cdef) для критичных переменных, что не даёт реального ускорения по сравнению с обычным Python — простое переименование файла из.pyв.pyxи компиляция через Cython без добавления явной типизации даёт лишь минимальное ускорение (за счёт устранения некоторых общих накладных расходов интерпретации), и реальный, существенный выигрыш производительности Cython достигается именно через явное указание типов в действительно критичных по производительности участках кода, а не автоматически просто за счёт компиляции. - Усложнение процесса сборки и распространения проекта из-за добавления компилируемых Cython/C++ расширений, требующих наличия соответствующего компилятора на машине, где выполняется сборка (хотя не обязательно на машине конечного пользователя, если используется предварительная компиляция и упаковка, статья 506) — это добавляет дополнительный шаг и сложность в процесс сборки (и, потенциально, в CI-пайплайн, статья 446 DevOps-цикла) по сравнению с чистым, не требующим компиляции Python-проектом, и эта дополнительная сложность должна быть оправдана реальной, измеренной потребностью в повышенной производительности.
- Передача больших объёмов данных между Python и C++/Cython-расширением неэффективным способом, через множественные отдельные вызовы или избыточное копирование данных при каждом пересечении границы между языками — для эффективной интеграции стоит проектировать интерфейс между Python и компилируемым расширением так, чтобы минимизировать частоту и объём данных, пересекающих эту границу (например, передавая весь массив данных для обработки одним вызовом, а не вызывая C++ функцию отдельно для каждого элемента массива в цикле на стороне Python).