Все знают: если нужно быстро считать – пиши на 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

Реализации

Было реализовано четыре версии:

  1. C (double/float) – классический императивный код.

  2. NumPy (float64/float32) – векторные операции, но циклы остаются на Python.

  3. 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=native

  • Python: 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. Сравнение производительности

Рис.1 – Время выполнения алгоритма прогонки (сек). Логарифмическая шкала.
Рис.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

Рис.2 – Относительная погрешность для float32 (для double график был бы на уровне 10^-15).
Рис.2 – Относительная погрешность для float32 (для double график был бы на уровне 10^-15).

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. Но эксперимент показывает обратное. Причины:

  1. Автоматическая векторизация. Numba при компиляции на лету использует все доступные SIMD-инструкции (AVX-512). Наивный код на C может не задействовать их без специальных директив.

  2. Адаптивная оптимизация. JIT видит точные размеры массивов и может развернуть циклы, оптимизировать граничные условия.

  3. Профилировка. 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)


  1. miekrudakov
    19.03.2026 08:25

    Выводы не совсем верные

    1. Скорость Numpy зависит от реализации

    2. Скорость Numba зависит от реализации

    3. Скорость C++ зависит от реализации

      Правильный вывод: измерять скорость (правильно измерять!) и не бояться JIT компиляции


  1. tenzink
    19.03.2026 08:25

    прогрев (для исключения влияния кэша и JIT-компиляции)

    Не кажется, что это немного тепличные условия? В реальном приложении никакого прогрева вероятней всего не будет и приближенным к реальности будет вариант первого запуска с загрузкой всего и вся. Интересно, насколько там большая дисперсия получается, если не делать прогрев


  1. Anton_Menshov
    19.03.2026 08:25

    Если вы пишете линейную алгебру на C/C++ и НЕ пользуетесь высокопроизводительной BLAS/LAPACK библиотекой - есть только три варианта:

    1. линейная алгебра составляет менее 1% вычислений в вашей задаче - и тогда все равно какая у нее производительность.
    2. вам [пока] не стоит писать программы для линейной алгебры ни для чего, кроме как для обучения себя.
    3. вы сами пишете свой BLAS\LAPACK по какой-то экзотической причине. вполне возможно обоснованной.

    А так, статья хорошая: лучше использовать хорошие примитивы из numba, чем писать линейку на С\С++ вручную без понимания того, как и когда это нужно делать хорошо.


    1. Pand5461
      19.03.2026 08:25

      Для трёхдиагональных систем библиотеки мало что дадут и могут быть даже медленнее ручного кода, если, например, дают гарантию устойчивости для любой системы. Прогонка всё равно в скорость доступа к памяти упирается, там же алгоритм последовательный и не векторизуемый.
      Бонусом - если писать вручную, то можно соптимизировать для специальных типов матриц (например, если на диагонали все значения одинаковые, можно их не хранить в явном виде). А в задачах, где тридиагональные системы возникают, они как раз имеют специальный вид часто.


      1. Anton_Menshov
        19.03.2026 08:25

        В такой постановке проблемы вы правы. Релевантно:
        - How does LAPACK solve tridiagonal systems and why - partial pivoting - это о гарантии устойчивости и то, что LAPACK делает, а вам может и не надо.
        - Об оптимальности Томаса для трехдиагональной системы
        - Parallel TDMA - для оооооочень больших трехдиагональных систем

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


        1. 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), которая создаёт временные массивы и с ними запускает предыдущую.


  1. 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  

    Вывода не будет...


  1. muwrec
    19.03.2026 08:25

    Если C код медленнее питона, то к кого-то руки из ж...


  1. 1kovand1
    19.03.2026 08:25

    Numba подстраивается под конкретный процессор, тогда как бинарник 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).


  1. Pand5461
    19.03.2026 08:25

    Намеряны какие-то очень не те времена. У меня получается с numpy примерно 20 секунд на 10М уравнений, с numba примерно 200 мс, на Си с -Ofast и -O3 130 мс (AMD Ryzen 5 3500U). И объяснений, почему вдруг Numba быстрее Си, уже не требуется.


    1. Anton_Menshov
      19.03.2026 08:25

      несмотря на то, что вы правы, все же нумбу в этом случае нужно сравнивать со включенной fastmath=True опцией. Или не использовать -Ofast


      1. Pand5461
        19.03.2026 08:25

        Видимо, непонятно написал - имеется в виду, что -O3 и -Ofast не отличаются по производительности.
        В Julia ещё проверял, там есть эффект в 10-15% от отключения проверок границ массива (fastmath, как и в Си, на скорость не повлиял). Если для numba есть такой флажок, то он, скорее всего, тоже с 200 до 170 мс время скинет.