С момента публикации статьи на Хабре «Импортозамещаем 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 << "RanktNoise LeveltGMT_trend2d, MSEn";
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
Автор: mike1


