С момента публикации статьи на Хабре «Импортозамещаем 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. У меня есть компьютер с этой ОС, но я давно не проверял его, и, скорее всего, сборка сейчас не работает.
-
Если кто-то хочет поучаствовать в разработке и внести свой вклад, буду рад вашему отклику! Пишите мне в личные сообщения. Спасибо!
Ссылки
-
⚡ 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
ссылка на оригинал статьи https://habr.com/ru/articles/1039866/