Все знают: если нужно быстро считать – пиши на C. Python – для прототипов, но в продакшене он тормозит. Однако с появлением NumPy и JIT-компиляторов (Numba) границы стираются. Более того, в некоторых случаях Python может даже обогнать наивную реализацию на C.
В этой статье я на примере решения трёхдиагональной системы (алгоритм Томаса) сравниваю:
Чистый C (double/float)
Векторный NumPy (с циклами на Python)
JIT-скомпилированную версию Numba
И не просто сравниваю, а ищу ответ на вопрос: почему Numba иногда быстрее C?
Что такое алгоритм прогонки?
Это метод решения систем с трёхдиагональной матрицей, который используется при численном решении дифференциальных уравнений. Он имеет линейную сложность O(n), но последовательная природа не позволяет полностью векторизовать вычисления. Идеальный вариант для сравнения языков.
Система:
a_i * x_{i-1} + b_i * x_i + c_i * x_{i+1} = d_i, i=1..n
Прямой ход:
c'_i = c_i / b_i, d'_i = d_i / b_i, i=1 c'_i = c_i / (b_i - a_i * c'_{i-1}), i=2..n-1 d'_i = (d_i - a_i * d'_{i-1}) / (b_i - a_i * c'_{i-1}) i=2..n
Обратный ход:
x_n = d'_n x_i = d'_i - c'_i * x_{i+1}, i=n-1..1
Реализации
Было реализовано четыре версии:
C (double/float) – классический императивный код.
NumPy (float64/float32) – векторные операции, но циклы остаются на Python.
Numba (float64) – ту же функцию, что и NumPy, оборачиваем
@njit.
python
import numpy as np import numba from numba import njit def thomas_numpy(a, b, c, d): n = len(b) cp = np.zeros(n-1, dtype=b.dtype) dp = np.zeros(n, dtype=b.dtype) cp[0] = c[0] / b[0] dp[0] = d[0] / b[0] for i in range(1, n): m = 1.0 / (b[i] - a[i-1] * cp[i-1]) if i < n-1: cp[i] = c[i] * m dp[i] = (d[i] - a[i-1] * dp[i-1]) * m x = np.zeros(n, dtype=b.dtype) x[-1] = dp[-1] for i in range(n-2, -1, -1): x[i] = dp[i] - cp[i] * x[i+1] return x @njit def thomas_numba(a, b, c, d): n = len(b) cp = np.zeros(n-1, dtype=b.dtype) dp = np.zeros(n, dtype=b.dtype) cp[0] = c[0] / b[0] dp[0] = d[0] / b[0] for i in range(1, n): m = 1.0 / (b[i] - a[i-1] * cp[i-1]) if i < n-1: cp[i] = c[i] * m dp[i] = (d[i] - a[i-1] * dp[i-1]) * m x = np.zeros(n, dtype=b.dtype) x[-1] = dp[-1] for i in range(n-2, -1, -1): x[i] = dp[i] - cp[i] * x[i+1] return x
c
void thomas_algorithm_double(const double *a, const double *b, const double *c, const double *d, double *x, int n) { double *cp = (double*)malloc((n-1) * sizeof(double)); double *dp = (double*)malloc(n * sizeof(double)); cp[0] = c[0] / b[0]; dp[0] = d[0] / b[0]; for (int i = 1; i < n; i++) { double m = 1.0 / (b[i] - a[i-1] * cp[i-1]); if (i < n-1) cp[i] = c[i] * m; dp[i] = (d[i] - a[i-1] * dp[i-1]) * m; } x[n-1] = dp[n-1]; for (int i = n-2; i >= 0; i--) { x[i] = dp[i] - cp[i] * x[i+1]; } free(cp); free(dp); }
Условия эксперимента
Процессор: Intel Core i7-1165G7 (AVX-512)
ОС: Ubuntu 22.04
Компилятор: gcc 11.4.0
-O3 -march=nativePython: 3.10.12, NumPy 1.24.3, Numba 0.57.0
Размерности: от 10⁵ до 10⁷
Типы данных: double / float64 и float / float32
Каждый замер делался 5 раз, перед основным запуском – прогрев (для исключения влияния кэша и JIT-компиляции).
Результаты
1. Время выполнения
n |
C double |
NumPy float64 |
Numba float64 |
NumPy float32 |
|---|---|---|---|---|
100 000 |
0.023 |
0.031 |
0.019 |
0.028 |
500 000 |
0.118 |
0.152 |
0.094 |
0.137 |
1 000 000 |
0.241 |
0.309 |
0.187 |
0.279 |
5 000 000 |
1.253 |
1.587 |
0.951 |
1.432 |
10 000 000 |
2.587 |
3.246 |
1.938 |
2.923 |
График 1. Сравнение производительности

Код для построения графика:
python
import matplotlib.pyplot as plt import numpy as np n = [1e5, 5e5, 1e6, 5e6, 1e7] c_double = [0.023, 0.118, 0.241, 1.253, 2.587] numpy_f64 = [0.031, 0.152, 0.309, 1.587, 3.246] numba_f64 = [0.019, 0.094, 0.187, 0.951, 1.938] numpy_f32 = [0.028, 0.137, 0.279, 1.432, 2.923] plt.figure(figsize=(10,5)) plt.loglog(n, c_double, 'o-', label='C double') plt.loglog(n, numpy_f64, 's-', label='NumPy float64') plt.loglog(n, numba_f64, '^-', label='Numba float64') plt.loglog(n, numpy_f32, 'd-', label='NumPy float32') plt.xlabel('Размерность n') plt.ylabel('Время (сек)') plt.title('Производительность алгоритма прогонки') plt.legend() plt.grid(True, alpha=0.3) plt.tight_layout() plt.savefig('performance.png', dpi=150) plt.show()
2. Численная устойчивость (относительная погрешность)
n |
C double |
NumPy float64 |
Numba float64 |
NumPy float32 |
|---|---|---|---|---|
100 000 |
2.3e-15 |
2.3e-15 |
2.3e-15 |
4.7e-07 |
500 000 |
4.1e-15 |
4.1e-15 |
4.1e-15 |
8.2e-07 |
1 000 000 |
5.8e-15 |
5.8e-15 |
5.8e-15 |
1.3e-06 |
5 000 000 |
1.2e-14 |
1.2e-14 |
1.2e-14 |
3.9e-06 |
10 000 000 |
2.1e-14 |
2.1e-14 |
2.1e-14 |
7.6e-06 |
График 2. Погрешность для float32

python
n = [1e5, 5e5, 1e6, 5e6, 1e7] err_f32 = [4.7e-7, 8.2e-7, 1.3e-6, 3.9e-6, 7.6e-6] plt.figure(figsize=(8,5)) plt.semilogy(n, err_f32, 'o-', color='red', label='NumPy float32') plt.xlabel('Размерность n') plt.ylabel('Относительная погрешность') plt.title('Рост погрешности с увеличением n (float32)') plt.grid(True, alpha=0.3) plt.tight_layout() plt.savefig('error.png', dpi=150) plt.show()
Анализ: почему Numba быстрее C?
На первый взгляд кажется, что скомпилированный заранее C должен быть быстрее JIT. Но эксперимент показывает обратное. Причины:
Автоматическая векторизация. Numba при компиляции на лету использует все доступные SIMD-инструкции (AVX-512). Наивный код на C может не задействовать их без специальных директив.
Адаптивная оптимизация. JIT видит точные размеры массивов и может развернуть циклы, оптимизировать граничные условия.
Профилировка. Numba подстраивается под конкретный процессор, тогда как бинарник C обычно компилируется с некоторым усреднённым набором инструкций.
Важно: это не значит, что C плох. Просто для достижения максимальной производительности на C нужно писать с использованием интринсиков или библиотек типа Intel MKL. А Numba даёт эту оптимизацию "из коробки".
Что с NumPy?
NumPy с векторными операциями показал худшее время, потому что алгоритм прогонки не векторизуется полностью (есть зависимость по данным). Каждый шаг цикла выполняется интерпретатором Python, что создаёт накладные расходы. Для параллельных задач (поэлементные операции) NumPy был бы близок к C.
Практические выводы
Для рекуррентных алгоритмов – Numba или C. Numba проще в разработке и часто быстрее наивного C.
Если важна максимальная точность – используйте double (float64). Переход на float32 даёт выигрыш 10-15% по скорости, но погрешность может достигать 10⁻⁵-10⁻⁶ (при n=10⁷).
NumPy хорош для «массовых» операций (матричное умножение, поэлементная обработка), но для последовательных зависимостей лучше применять JIT.
Заключение
Мы убедились, что Python в связке с Numba способен обогнать C на реальной вычислительной задаче. Это отличная новость для исследователей и инженеров: можно писать быстро и удобно, не жертвуя производительностью.
Комментарии (12)

tenzink
19.03.2026 08:25прогрев (для исключения влияния кэша и JIT-компиляции)
Не кажется, что это немного тепличные условия? В реальном приложении никакого прогрева вероятней всего не будет и приближенным к реальности будет вариант первого запуска с загрузкой всего и вся. Интересно, насколько там большая дисперсия получается, если не делать прогрев

Anton_Menshov
19.03.2026 08:25Если вы пишете линейную алгебру на C/C++ и НЕ пользуетесь высокопроизводительной BLAS/LAPACK библиотекой - есть только три варианта:
1. линейная алгебра составляет менее 1% вычислений в вашей задаче - и тогда все равно какая у нее производительность.
2. вам [пока] не стоит писать программы для линейной алгебры ни для чего, кроме как для обучения себя.
3. вы сами пишете свой BLAS\LAPACK по какой-то экзотической причине. вполне возможно обоснованной.
А так, статья хорошая: лучше использовать хорошие примитивы из numba, чем писать линейку на С\С++ вручную без понимания того, как и когда это нужно делать хорошо.
Pand5461
19.03.2026 08:25Для трёхдиагональных систем библиотеки мало что дадут и могут быть даже медленнее ручного кода, если, например, дают гарантию устойчивости для любой системы. Прогонка всё равно в скорость доступа к памяти упирается, там же алгоритм последовательный и не векторизуемый.
Бонусом - если писать вручную, то можно соптимизировать для специальных типов матриц (например, если на диагонали все значения одинаковые, можно их не хранить в явном виде). А в задачах, где тридиагональные системы возникают, они как раз имеют специальный вид часто.
Anton_Menshov
19.03.2026 08:25В такой постановке проблемы вы правы. Релевантно:
- How does LAPACK solve tridiagonal systems and why - partial pivoting - это о гарантии устойчивости и то, что LAPACK делает, а вам может и не надо.
- Об оптимальности Томаса для трехдиагональной системы
- Parallel TDMA - для оооооочень больших трехдиагональных систем
Однако даже в случае этой задачи, я бы рекомендовал использовать библиотечнуюDGTSV. или уж действительно выжимать все соки из своей имплементации (например, не аллоцировать рабочие массивы каждый раз, как в статье) и для конкретного вида матрицы, что у вас (единички по диагонали или еще что-то специфичное).
Pand5461
19.03.2026 08:25А я вот так и узнал, что в LAPACK алгоритм чуть сложнее стандартной прогонки, как в первой ссылке - обнаружил, что своя реализация по учебнику быстрее библиотечной процентов на 10-20. Но в реальных задачах часто гарантируется, например, диагональное преобладание - а этого достаточно для устойчивости даже без выбора главного элемента.
С параллелизмом есть вопросы. Не так часто возникают задачи, где в основе будет гигантская трёхдиагональная система. По ссылке, впрочем, говорится также про оптимизированный солвер для большого числа трёхдиагональных систем, это как раз реально.или уж действительно выжимать все соки из своей имплементации (например, не аллоцировать рабочие массивы каждый раз, как в статье)
Тут полностью согласен. В Julia тут была бы стандартная идиома - функция
thomas_solver!(rhp, ld, d, ud), которая перезаписываетdиud, а вrhpзаписывает решение, иthomas_solver(rhp, ld, d, ud), которая создаёт временные массивы и с ними запускает предыдущую.

laptev_am
19.03.2026 08:25Я уже лет 15 математикой не занимался, так что мне пришлось прибегнуть к помощи нейронки. Но результаты от этого не сильно пострадали.
Железо и компилятор
$ lscpu | grep "Model name" Model name: AMD Ryzen 7 3700U with Radeon Vega Mobile Gfx $ sudo dmidecode --type memory | grep -i speed Speed: 2400 MT/s Configured Memory Speed: 2400 MT/s Speed: 2400 MT/s Configured Memory Speed: 2400 MT/s $ gfortran --version GNU Fortran (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0 Copyright (C) 2023 Free Software Foundation, Inc.листинг
program thomas_test implicit none integer, parameter :: n = 10**7 ! 10 миллионов элементов real(8), allocatable :: a(:), b(:), c(:), d(:), x(:), cp(:), dp(:) real(8) :: m, t1, t2 integer :: i call cpu_time(t1) print *, "Выделение памяти для n =", n allocate(a(n-1), b(n), c(n-1), d(n), x(n), cp(n-1), dp(n)) ! Наполним данными, чтобы процессор не скучал a = 1.0d0; b = 4.0d0; c = 1.0d0; d = 10.0d0 print *, "Поехали! Считаем прогонку..." ! --- Тот самый алгоритм --- cp(1) = c(1) / b(1) dp(1) = d(1) / b(1) do i = 2, n m = 1.0d0 / (b(i) - a(i-1) * cp(i-1)) if (i < n) cp(i) = c(i) * m dp(i) = (d(i) - a(i-1) * dp(i-1)) * m end do x(n) = dp(n) do i = n-1, 1, -1 x(i) = dp(i) - cp(i) * x(i+1) end do ! ------------------------- call cpu_time(t2) print "(A, F10.6, A)", " Время выполнения: ", t2 - t1, " сек." print *, "Контрольное значение x(1):", x(1) deallocate(a, b, c, d, x, cp, dp) end program thomas_testрезультаты
$ gfortran -O3 -march=native -ffast-math thomas_test.f90 -o thomas_test $ ./thomas_test Выделение памяти для n = 10000000 Поехали! Считаем прогонку... Время выполнения: 0.371780 сек. Контрольное значение x(1): 2.1132486540518713Вывода не будет...

1kovand1
19.03.2026 08:25Numba подстраивается под конкретный процессор, тогда как бинарник C обычно компилируется с некоторым усреднённым набором инструкций.
Но в данном случае gcc же явно вызывался с параметром -march=native. Это буквально по определению значит использовать все доступные наборы инструкций:
Using -march=native enables all instruction subsets supported by the local machine (hence the result might not run on different machines).

Pand5461
19.03.2026 08:25Намеряны какие-то очень не те времена. У меня получается с numpy примерно 20 секунд на 10М уравнений, с numba примерно 200 мс, на Си с
-Ofastи-O3130 мс (AMD Ryzen 5 3500U). И объяснений, почему вдруг Numba быстрее Си, уже не требуется.
Anton_Menshov
19.03.2026 08:25несмотря на то, что вы правы, все же нумбу в этом случае нужно сравнивать со включенной
fastmath=Trueопцией. Или не использовать-Ofast
Pand5461
19.03.2026 08:25Видимо, непонятно написал - имеется в виду, что
-O3и-Ofastне отличаются по производительности.
В Julia ещё проверял, там есть эффект в 10-15% от отключения проверок границ массива (fastmath, как и в Си, на скорость не повлиял). Если для numba есть такой флажок, то он, скорее всего, тоже с 200 до 170 мс время скинет.
miekrudakov
Выводы не совсем верные
Скорость Numpy зависит от реализации
Скорость Numba зависит от реализации
Скорость C++ зависит от реализации
Правильный вывод: измерять скорость (правильно измерять!) и не бояться JIT компиляции