С момента публикации статьи на Хабре «Импортозамещаем 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

















