С момента публикации статьи на Хабре «Импортозамещаем 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 * ry inside = 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 time import sys import scipy.special def 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 tabulate import numpy as np def 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 data def 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 mse def 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. У меня есть компьютер с этой ОС, но я давно не проверял его, и, скорее всего, сборка сейчас не работает.
Если кто-то хочет поучаствовать в разработке и внести свой вклад, буду рад вашему отклику! Пишите мне в личные сообщения. Спасибо!
Ссылки
⚡ NumPy-style arrays in C++ | CUDA GPU + SIMD (AVX2/AVX512/AMX) CPU: https://github.com/mgorshkov/np
⚡ Data manipulation and analysis library in C++ | CUDA GPU + SIMD (AVX2/AVX512/AMX) CPU: https://github.com/mgorshkov/pd
⚡ SciPy methods in C++ | CUDA GPU + SIMD (AVX2/AVX512/AMX) CPU: https://github.com/mgorshkov/scipy
⚡ ML methods in C++ | CUDA GPU + SIMD (AVX2/AVX512/AMX) CPU: https://github.com/mgorshkov/sklearn
economist75
2X буст - это отлично и весомо, считаю как 3X.
За эти 3 года исходный стек тоже не стоял на месте, пришли стрелы Arrow, расползся Feather всюду, DuckDB стал вытеснять кучу всего зеленого и синего в аналитических локальных БД (ибо быстрее быстрого), а замес стека с Polar привел к сближению API и равновесию/дружбе разработчиков. Плюс выжили Fireducks. JupyterLive (ASM) и прочие интересные концепты. Даже самому Python 3.14 здорово накрутили хвоста, по внутренним бенчам с 3.8 до 3.14 - 1.7X есть, и если забыл сменить окружение - прямо сразу заметно.
В целом все в DS развивается очень динамично, насыщенно. Конкуренция зашкаливает, периодические успехи в лагере TS/JS бодрят, оттуда (и туда) идут много идей и заимствований. И главная проблема развития нового продукта - специализация и позиционирование (импортозамес это про другое).
Правда в том что пользователи Pandas никуда не спешат, брать их надо чем-то другим. По своей кодовой базе для анализа и BI могу сказать что выжать буст 2X можно практически не сходя с места, нужно просто зарефачить с агентом старые костыли из “лапши”, вынесенные в CI/CD в аккуратные whl-либы лет пять назад.
Возможно есть насущные потребности в расширениях DDL-языка для запросов/графиков, но это надо спрашивать у широких слоев, а для начала с ними надо определиться.