Ускоряем и оптимизируем numpy, pandas, scipy и sklearn

от автора

С момента публикации статьи на Хабре «Импортозамещаем numpy, pandas, scipy и sklearn» прошло почти три года. В течение этого времени я приостановил работу над проектом из-за нехватки времени, ресурсов и сил. К тому же, меня расстроило, что не смог выполнить просьбу пользователя @N-Cube, который активно интересовался моей библиотекой и хотел ускорить работу своего Jupyter Notebook.

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

За это время в библиотеки были добавлены поддержка CUDA, множество ручных SIMD-оптимизаций с динамическим выбором SIMD, несколько реализаций линейной регрессии и многое другое.

Давайте рассмотрим, что на сегодняшний день позволяет сделать моя библиотека.

Я представлю несколько тестовых примеров в двух вариантах: с использованием AVX-2 на процессоре Intel® Core™ i7-4790K и AVX-512 на Intel® Xeon. Также покажу результаты замеров для каждого из них. Все тесты проводились без использования GPU, исключительно на процессоре. Это позволяет сравнивать производительность Python и моей библиотеки на равных условиях. Операционная система – Ubuntu 24.04, компилятор – GNU 13.3.0.

Метод Монте-Карло для вычисления числа π

Скрытый текст
  • Генерация координат: Программа создает два вектора случайных координат: rx и ry, которые находятся в диапазоне от 0 до 1. Эти координаты представляют собой точки на плоскости.

  • Проверка попадания: Точка считается находящейся внутри круга, если расстояние dist от начала координат (0, 0) меньше радиуса, равного 1. Это условие можно проверить с помощью следующей формулы: rx² + ry² < 1.

  • Вычисление: Итоговая оценка числа π (pi_est) вычисляется как отношение количества точек, попавших внутрь круга, к общему количеству сгенерированных точек.

Бенчмарк: https://github.com/mgorshkov/np/blob/main/samples/monte-carlo/compare_python_monte_carlo.py

Оригинальный питоновский код

rx = np.random.rand(size)ry = np.random.rand(size)dist = rx * rx + ry * ryinside = np.sum(dist < 1.0)pi_est = 4.0 * inside / size

Код из библиотеки

auto rx = random::rand(size);auto ry = random::rand(size);auto dist = rx * rx + ry * ry;auto inside = sum("dist<1", dist);double pi_est = 4 * static_cast<double>(inside) / size;

Результаты бенчмарка на AVX-2

Size

Py time (us)

Py mem (MiB)

C++ time (us)

C++ mem (MiB)

Speedup

Mem ratio

100000

4222

2.3

638

1.5

6.62x

1.5x

1000000

19760

22.9

3386

15.3

5.84x

1.5x

10000000

181804

228.9

29889

152.6

6.08x

1.5x

100000000

1770601

2288.8

313803

1525.9

5.64x

1.5x

Результаты бенчмарка на AVX-512

Size

Py time (us)

Py mem (MiB)

C++ time (us)

C++ mem (MiB)

Speedup

Mem ratio

100000

7538

2.3

2371

1.5

3.18x

1.5x

1000000

30011

22.9

3782

15.3

7.94x

1.5x

10000000

235035

228.9

23761

152.6

9.89x

1.5x

100000000

6192049

2288.8

285586

1525.9

21.68x

1.5x

Неполная бета-функция

Скрытый текст

Что такое полная бета-функция?

Чтобы понять неполную версию, нужно вспомнить полную. Полная бета-функция $\(B(a, b)\)$ — это определенный интеграл от нуля до единицы, который зависит от двух параметров $\(a\)$ и $\(b\)$:

$$\(B(a, b) = \int_{0}^{1} t^{a-1} (1-t)^{b-1} \, dt\)$$

Что такое неполная бета-функция?

В неполной бета-функции верхний предел интеграла заменяется на переменную $\(x\)$ (где $\(0 \le x \le 1\))$. Это значит, что мы интегрируем функцию не до конца, а только на отрезке от $\(0\)$ до $\(x\)$.

Обозначается она как $\(B_x(a, b)\)$ и определяется следующим образом:

$$\(B_x(a, b) = \int_{0}^{x} t^{a-1} (1-t)^{b-1} \, dt\)$$

Бенчмарк: https://github.com/mgorshkov/scipy/tree/main/benchmarks/betainc

Оригинальный код на Python

#!/usr/bin/env python3"""Python scipy betainc benchmark - called by the C++ comparison benchmark.Uses the same test parameters as the C++ benchmark for fair comparison."""import timeimport sysimport scipy.specialdef benchmark_python_scipy():    a = 0.5 * 99997    b = 0.5 * 99997    x = 0.4    count = 0    res = 0.0    start = time.perf_counter_ns()    while x < 0.6:        count += 1        res += scipy.special.betainc(a, b, x)        x += 0.000001    stop = time.perf_counter_ns()    diff = stop - start    print(f"Result = {res}")    print(f"Time = {diff} ns")    print(f"Loops = {count}")if __name__ == "__main__":    benchmark_python_scipy()

Код из библиотеки

timespec start;clock_gettime(CLOCK_MONOTONIC, &start);np::float_ a = 0.5 * 99997;np::float_ b = 0.5 * 99997;np::float_ x = 0.4;int count = 0;np::float_ res = 0;while (x < 0.6) {    ++count;    res += scipy::special::betainc(a, b, x);    x += 0.000001;}timespec stop;clock_gettime(CLOCK_MONOTONIC, &stop);std::uint64_t diff = 1000000000L * (stop.tv_sec - start.tv_sec) + stop.tv_nsec - start.tv_nsec;std::cout << "Result = " << res << std::endl;std::cout << "Time = " << diff << " ns" << std::endl;std::cout << "Loops = " << count << std::endl;

Результаты бенчмарка на AVX-2

Implementation

Time (ns)

Loops

Speedup vs Python

C++ scipy (AVX2)

115882110

200000

2.26x

Python scipy

262307821

200000

1.00x

Результаты бенчмарка на AVX-512

Implementation

Time (ns)

Loops

Speedup vs Python

C++ scipy (AVX512)

113440191

200000

2.75x

Python scipy

311787699

200000

1.00x

Большой фрагмент оптимизированного Jupyter Notebook (основные компоненты — неполная бета-функция и линейная регрессия)

Оригинальный код на Python из комментария к предыдущей статье: https://habr.com/ru/articles/752762/#comment_25829022

Бенчмарк: https://github.com/mgorshkov/sklearn/blob/main/samples/gmt_trend_2d/benchmark.cpp

Код на Python

Скрытый текст
from tabulate import tabulateimport numpy as npdef generate_data(rank, num_points, noise_level):    np.random.seed(42)    x = np.linspace(-10, 10, num_points)    y = np.linspace(-10, 10, num_points)    if rank == 1:        z = 3 * x + 5 + noise_level * np.random.randn(num_points)        data = np.column_stack((x, y, z))    elif rank == 2:        z = 2 * x + 3 * y + 5 + noise_level * np.random.randn(num_points)        data = np.column_stack((x, y, z))    elif rank == 3:        z = 2 * x**2 + 3 * y**2 + 5 + noise_level * np.random.randn(num_points)        data = np.column_stack((x, y, z))    return datadef GMT_trend2d(data, rank):    import numpy as np    from sklearn.linear_model import LinearRegression    # scale factor for normally distributed data is 1.4826    # https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.median_abs_deviation.html    MAD_NORMALIZE = 1.4826    # significance value    sig_threshold = 0.51    if rank not in [1,2,3]:        raise Exception('Number of model parameters "rank" should be 1, 2, or 3')    #see gmt_stat.c    def gmtstat_f_q (chisq1, nu1, chisq2, nu2):        import scipy.special as sc        if chisq1 == 0.0:            return 1        if chisq2 == 0.0:            return 0        return sc.betainc(0.5*nu2, 0.5*nu1, chisq2/(chisq2+chisq1))    if rank in [2,3]:        x = data[:,0]        x = np.interp(x, (x.min(), x.max()), (-1, +1))    if rank == 3:        y = data[:,1]        y = np.interp(y, (y.min(), y.max()), (-1, +1))    z = data[:,2]    w = np.ones(z.shape)    if rank == 1:        xy = np.expand_dims(np.zeros(z.shape),1)    elif rank == 2:        xy = np.expand_dims(x,1)    elif rank == 3:        xy = np.stack([x,y]).transpose()    # create linear regression object    mlr = LinearRegression()    chisqs = []    coeffs = []    while True:        # fit linear regression        mlr.fit(xy, z, sample_weight=w)        r = np.abs(z - mlr.predict(xy))        chisq = np.sum((r**2*w))/(z.size-3)        chisqs.append(chisq)        k = 1.5 * MAD_NORMALIZE * np.median(r)        w = np.where(r <= k, 1, (2*k/r) - (k * k/(r**2)))        sig = 1 if len(chisqs)==1 else gmtstat_f_q(chisqs[-1], z.size-3, chisqs[-2], z.size-3)        # Go back to previous model only if previous chisq < current chisq        if len(chisqs)==1 or chisqs[-2] > chisqs[-1]:            coeffs = [mlr.intercept_, *mlr.coef_]        #print ('chisq', chisq, 'significant', sig)        if sig < sig_threshold:            break    # get the slope and intercept of the line best fit    return (coeffs[:rank])def calculate_mse(data, coeffs, rank):    z_actual = data[:, 2]    if rank == 1:        z_predicted = coeffs[0]    elif rank == 2:        # Interpolate x the same way as in GMT_trend2d        x = data[:, 0]        x_interp = np.interp(x, (x.min(), x.max()), (-1, +1))        z_predicted = coeffs[0] + coeffs[1] * x_interp    elif rank == 3:        # Interpolate x and y the same way as in GMT_trend2d        x = data[:, 0]        x_interp = np.interp(x, (x.min(), x.max()), (-1, +1))        y = data[:, 1]        y_interp = np.interp(y, (y.min(), y.max()), (-1, +1))        z_predicted = coeffs[0] + coeffs[1] * x_interp + coeffs[2] * y_interp    mse = np.mean((z_actual - z_predicted) ** 2)    return msedef test_mse(num_points = 100*1000, ranks = [1, 2, 3], noise_levels = [0, 1, 10, 50]):    import warnings    results = []    # Suppress the specific warning    with warnings.catch_warnings():        warnings.simplefilter("ignore", category=RuntimeWarning)        for rank in ranks:            for noise_level in noise_levels:                data = generate_data(rank, num_points, noise_level)                # round the output                coeffs_gmt = [v.round(8) for v in GMT_trend2d(data, rank)]                mse_gmt = np.round(calculate_mse(data, coeffs_gmt, rank), 0)                results.append([rank, noise_level, mse_gmt])    headers = ["Rank", "Noise Level", "GMT_trend2d, MSE"]    print(tabulate(results, headers=headers))test_mse()

Код из библиотеки

Скрытый текст
using namespace np;using namespace scipy;using namespace sklearn;auto generate_data(auto rank, auto num_points, auto noise_level) {    random::seed(42);    auto x = linspace(-10.0, 10.0, num_points);    auto y = linspace(-10.0, 10.0, num_points);    if (rank == 1) {        auto z = 3 * x + 5 + noise_level * random::randn(num_points);        return column_stack(x, y, z);    }    if (rank == 2) {        auto z = 2 * x + 3 * y + 5 + noise_level * random::randn(num_points);        return column_stack(x, y, z);    }    auto z = 2 * x * x + 3 * y * y + 5 + noise_level * random::randn(num_points);    return column_stack(x, y, z);}auto GMT_trend2d(const Array<float_> &data, int rank) {    float_ MAD_NORMALIZE = 1.4826;    float_ sig_threshold = 0.51;    if (rank != 1 && rank != 2 && rank != 3) {        throw sklearn::RuntimeError("Number of model parameters \"rank\" should be 1, 2, or 3");    }    auto gmtstat_f_q = [](float_ chisq1, float_ nu1, float_ chisq2, float_ nu2) {        if (chisq1 == 0.0) return 1.0;        if (chisq2 == 0.0) return 0.0;        return scipy::special::betainc(0.5 * nu2, 0.5 * nu1, chisq2 / (chisq2 + chisq1));    };    Array<float_> x;    if (rank == 2 || rank == 3) {        auto x_ = data[":,0"];        x = interp(x_, Array<float_>{x_.min(), x_.max()}, Array<float_>{-1, +1});    }    Array<float_> y;    if (rank == 3) {        auto y_ = data[":,1"];        y = interp(y_, Array<float_>{y_.min(), y_.max()}, Array<float_>{-1, +1});    }    auto z = data[":, 2"].copy();    Array<float_> w = ones(z.shape()).copy();    Array<float_> xy;    if (rank == 1) {        xy = expand_dims(zeros(z.shape()), 1);    } else if (rank == 2) {        xy = expand_dims(x, 1);    } else if (rank == 3) {        xy = stack(x, y).transpose();    }    auto mlr = linear_model::LinearRegression{};    std::vector<float_> chisqs;    Array<float_> coeffs;    while (true) {        mlr.fit(xy, z, w);        auto r = abs_sub(z, mlr.predict(xy));        auto chisq = sum_sq_weighted(r, w) / static_cast<float_>(z.size() - 3);        chisqs.push_back(chisq);        auto k = 1.5 * MAD_NORMALIZE * median(r);        w = where_tukey(r, k);        auto sig = (chisqs.size() == 1 ? 1 : gmtstat_f_q(chisqs[chisqs.size() - 1], static_cast<float_>(z.size() - 3), chisqs[chisqs.size() - 2], static_cast<float_>(z.size() - 3)));        if (chisqs.size() == 1 or chisqs[chisqs.size() - 2] > chisqs[chisqs.size() - 1]) {            coeffs = mlr.coeffs_();        }        if (sig < sig_threshold) {            break;        }    }    auto result = Array<float_>{};    for (int i = 0; i < rank; ++i) {        result = append(result, Array<float_>{coeffs.get(i)});    }    return result;auto calculate_mse(const Array<float_> &data, const Array<float_> &coeffs, int rank) {    auto z_actual = data[":,2"].copy();    Array<float_> z_predicted;    if (rank == 1) {        z_predicted = coeffs.get(0) * ones(z_actual.shape());    } else if (rank == 2) {        // Interpolate x the same way as in GMT_trend2d        auto x_ = data[":,0"];        auto x = interp(x_, Array<float_>{x_.min(), x_.max()}, Array<float_>{-1, +1});        z_predicted = coeffs.get(0) + coeffs.get(1) * x;    } else if (rank == 3) {        // Interpolate x and y the same way as in GMT_trend2d        auto x_ = data[":,0"];        auto x = interp(x_, Array<float_>{x_.min(), x_.max()}, Array<float_>{-1, +1});        auto y_ = data[":,1"];        auto y = interp(y_, Array<float_>{y_.min(), y_.max()}, Array<float_>{-1, +1});        z_predicted = coeffs.get(0) + coeffs.get(1) * x + coeffs.get(2) * y;    }    using sklearn::metrics::mean_squared_error;    using sklearn::metrics::MeanSquaredErrorParameters;    MeanSquaredErrorParameters<Array<float_>> params{.y_true = z_actual, .y_pred = z_predicted};    return mean_squared_error(params);}void test_mse(int num_points = 100 * 1000, const std::vector<int> &ranks = {1, 2, 3}, const std::vector<int> &noise_levels = {0, 1, 10, 50}) {    std::vector<std::tuple<int, int, float_>> results;    for (auto rank: ranks) {        for (auto noise_level: noise_levels) {            auto data = generate_data(rank, num_points, noise_level);            auto coeffs_gmt = GMT_trend2d(data, rank);            // round coefficients to 8 decimal places            auto coeffs_rounded = coeffs_gmt.copy();            for (std::size_t i = 0; i < coeffs_rounded.size(); ++i) {                coeffs_rounded.set(i, std::round(coeffs_rounded.get(i) * 1e8) / 1e8);            }            auto mse_gmt = calculate_mse(data, coeffs_rounded, rank);            // round MSE to zero decimal places            mse_gmt = std::round(mse_gmt);            results.emplace_back(rank, noise_level, mse_gmt);        }    }    // print table    std::cout << "Rank\tNoise Level\tGMT_trend2d, MSE\n";    for (const auto &[rank, noise_level, mse]: results) {        std::cout << rank << "\t" << noise_level << "\t" << mse << "\n";    }}

Результаты бенчмарка на AVX-2

Rank

Noise Level

C++ Time [ms]

Python Time [ms]

Speedup (C++ vs Py)

Result

1

0

6.623

12.580

1.9x (+90%)

C++ FASTER

1

1

6.326

11.698

1.8x (+85%)

C++ FASTER

1

10

8.351

17.884

2.1x (+114%)

C++ FASTER

1

50

8.423

17.564

2.1x (+109%)

C++ FASTER

2

0

11.848

24.378

2.1x (+106%)

C++ FASTER

2

1

13.988

21.392

1.5x (+53%)

C++ FASTER

2

10

14.298

21.454

1.5x (+50%)

C++ FASTER

2

50

13.892

21.267

1.5x (+53%)

C++ FASTER

3

0

22.118

27.332

1.2x (+24%)

C++ FASTER

3

1

21.651

26.097

1.2x (+21%)

C++ FASTER

3

10

22.267

25.905

1.2x (+16%)

C++ FASTER

3

50

24.563

33.731

1.4x (+37%)

C++ FASTER

Результаты бенчмарка на AVX-512

Rank

Noise Level

C++ Time [ms]

Python Time [ms]

Speedup (C++ vs Py)

Result

1

0

10.465

14.564

1.4x (+39%)

C++ FASTER

1

1

8.728

13.618

1.6x (+56%)

C++ FASTER

1

10

11.995

20.686

1.7x (+72%)

C++ FASTER

1

50

12.432

19.809

1.6x (+59%)

C++ FASTER

2

0

13.804

16.047

1.2x (+16%)

C++ FASTER

2

1

16.994

26.332

1.5x (+55%)

C++ FASTER

2

10

16.816

25.047

1.5x (+49%)

C++ FASTER

2

50

15.862

25.106

1.6x (+58%)

C++ FASTER

3

0

22.385

29.881

1.3x (+33%)

C++ FASTER

3

1

21.063

29.933

1.4x (+42%)

C++ FASTER

3

10

21.285

30.517

1.4x (+43%)

C++ FASTER

3

50

25.981

36.520

1.4x (+41%)

C++ FASTER

Заключение

Таким образом, мы смогли ускорить работу библиотек более чем в два раза и сократить использование памяти примерно в 1.5 раза. Что нас ждет дальше? В планах продолжить развитие темы линейной регрессии, чтобы обеспечить быстрое вычисление на больших массивах данных. Также хотелось бы довести все библиотеки до полного соответствия с API аналогичных библиотек на Python. Кроме того, мы планируем создать новые библиотеки для машинного обучения и реализовать нейронные сети, скажем, аналог PyTorch или Tensorflow.

С помощью вайбкодинга возможности разработчиков становятся поистине безграничными.

Вопросы

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

  • Есть ли предложения по дальнейшему развитию этих библиотек? Возможно, кому-то нужно ускорить работу в Jupyter и подготовить его в виде компактного и быстрого исполняемого файла для продакшена? Размер бинарника из примера составляет всего около 2 Мб. Не стесняйтесь обращаться!

  • Какое направление, по вашему мнению, следует развивать более активно? Возможно, это будет numpy, pandas, scipy, sklearn, или стоит сосредоточиться на нейросетях?

  • Также приглашаю желающих помочь протестировать библиотеки на процессорах AMX. У меня нет доступа к таким процессорам, но подойдут машины с CPU 4-го поколения Intel Xeon Scalable (Sapphire Rapids), 5-го поколения Intel Xeon Scalable (Emerald Rapids) или процессорами Intel Xeon 6 (Granite Rapids / Sierra Forest).

  • Ищем помощь для настройки сборки и тестирования библиотек под Windows. У меня есть компьютер с этой ОС, но я давно не проверял его, и, скорее всего, сборка сейчас не работает.

  • Если кто-то хочет поучаствовать в разработке и внести свой вклад, буду рад вашему отклику! Пишите мне в личные сообщения. Спасибо!

Ссылки

ссылка на оригинал статьи https://habr.com/ru/articles/1039866/