Я являюсь автором проекта по математическому моделированию прикладной механики и в работе моей программы до 90% вычислительного времени уходит на решение системы линейных уравнений. Цель этой статьи сугубо практическая - найти оптимальный метод решения системы линейных уравнений с точки зрения производительность/трудозатрат для небольшого проекта и рассказать о результате.
В прошлом я уже несколько раз обращал внимание на вычисления на GPU, но всегда что-то останавливало. И вот у меня накопился достаточный практический опыт программирования на C/C++ и наконец дошли руки, чтобы протестировать OpenCL.
В результате изучения в поисковых системах и в различных списках, включая страницу Википедии, были найдены следующие проекты с линейной алгеброй:
clMagma - его я открыл и закрыл. Насколько я понял, это проект переноса на OpenCL алгоритмов с языка C, которые в свою очередь переписаны с Fortran. Я уважаю Fortran, но посчитал нецелесообразным осваивать столь специфичный код для данного проекта;
ArrayFire - многообещающий проект, который к сожалению не собрался на моей машине без проблем и я решил что не судьба;
ViennaCL 1.7.1 - интересный проект с реализацией LU-алгоритма, и немалым количеством итеративных методов решения и написанный на относительно современном C++. Однако у него есть 2 минуса: во-первых реализован алгоритм LU, а не LUP и, во-вторых, нет поддержки комплексных чисел. К сожалению, проект ViennaCL остановился в развитии (текущий релиз вышел 8 лет назад, а последний коммит был сделан 5 лет назад). По реализации LUP даже создан Issue в 2013 году. С другой стороны, у них есть инфраструктура для запуска произвольных kernel's, возможно она будет удобна.
Изначально планировалось просто применить существующую библиотеку, но обзор имеющихся вариантов оставил негативное впечатление, а благодаря книге "OpenCL in Action" и серии статей на Habr'е, я понял что зверь не так страшен и можно попробовать переписать используемый алгоритм на OpenCL.
Реализация алгоритма
В качестве основы для собственной реализации алгоритма была использована обёртка OpenCL-Wrapper. Кое-что, на мой взгляд, там реализовано не совсем удачно, но для стартового проекта OpenCL отличное решение.
При реализации алгоритма возникали сложности. Так совсем не очевидным оказалось модель синхронизации потоков. Можно устанавливать барьер/семафор для потоков в определенной части программы, однако такой подход не поможет, если количество work item'ов больше чем количество вычислительных ядер. Причетельно что не возникает deadlock, а алгоритм просто тихо продолжается без полной синхронизации. Так на моей интегрированной видеокарте с 192 ядрами расхождение с эталонным решением начиналось на 193 шаге.
Вышесказанное заставило прибегнуть к другой модели синхронизации. Исходный алгоритм был разбит на отдельные kernels, которые запускались по очереди с различными параметрами. Алгоритм превратился в эдакий конвеер.
Алгоритм LUP-разложения без дополнительной памяти (in-place) распадается на 3 kernels. Во-первых ищем максимальный элемент в столбце:
__kernel void find_max_in_column_kernel(const uint k, const uint N, __global double* A, __global int* ip, __global int* ier) {
int m = k;
for (int i = k + 1; i < N; ++i) {
if (fabs(A[i * N + k]) > fabs(A[m * N + k])) m = i;
}
ip[k] = m ; // save row number of max element
// swap rows if needed
if (m != k) {
ip[N-1] = -ip[N-1];
// swap diagonal element A
double t = A[m * N + k];
A[m * N + k] = A[k * N + k];
A[k * N + k] = t;
if (t == 0.0) {
*ier = k;
ip[N-1] = 0;
return;
}
}
}
Эта задача редукции и её эффективное распараллеливание это отдельный интересный вопрос. Но пока нас устроит если ею займётся одно вычислительное ядро GPU. Хоть CPU и быстрее GPU в однопоточном режиме, но данные уже загружены в видеопамять и переносить их туда-сюда для одного шага выглядит нецелесообразным. Затем делим все элементы в столбце ниже диагонального на величину этого диагонального элемента:
__kernel void identify_column_kernel(const uint k, const uint N, __global double* A, __global int* ip, __global int* ier) {
const uint i = get_global_id(0);
// equivalent to "for (uint i = k+1; i < N; i++)", but executed in parallel
if (i >= k + 1 && i < N)
A[i * N + k] /= - A[k * N + k];
}
И наконец вычитаем строку на диагонали из всех остальных строк
__kernel void columns_process_kernel(const uint k, const uint N, __global double* A, __global int* ip, __global int* ier) {
const uint j = get_global_id(0);
// equivalent to "for (uint i = k+1; i < N; i++)", but executed in parallel
if (j >= k + 1 && j < N) {
// swap row element
double tmp = A[ip[k] * N + j];
A[ip[k] * N + j] = A[k * N + j];
A[k * N + j] = tmp;
if (tmp != 0.0)
for (int i = k + 1; i < N; ++i)
A[i * N + j] += A[i * N + k] * tmp;
}
}
Во всех kernels есть параметр k, обозначающий шаг алгоритма, все три kernels запускаются в цикле по k ∊ [0;N-1].
После разложения матрицы, её компоненты L, U и P используются для решения системы уравнений вида с плотной матрицей заполненной случайными значениями. Было выполнено несколько бенчмарков в которых сравнивалась производительность следущих методов:
метод LUP-разложения на CPU, в данный момент используемый в проекте;
метод LU-разложения на GPU в реализации ViennaCL;
метод LUP-разложения на GPU собственной реализации.
Результаты
Дисклеймер: в процессе тестирования у меня возникли сложности с оборудованием, так что результаты нужно воспримать как ориентир. Так проект ViennaCL тестировался уже в самом конце и по нему можно сделать вывод только что их реализация имеет тот же порядок производительности что и моя реализация.
Результаты бенчмарков можно увидеть на рисунках ниже. Каждая точка запускалась по несколько раз, чтобы получить доверительный интервал. Как и ожидалось, решение системы уравнений на GPU требует меньше времени почти во всех случаях, за исключением маленьких матриц (меньше 1000х1000).
Матрицы 7600х7600 представляются как "отличный" результат, которого мне хотелось бы достичь Однако, даже с GPU 40 секунд на решение это много. Для достежения "отличного" результата потребуется использовать мощную игровую видеокарту и, возможно, оптимизировать алгоритм. Первым кандидатом на оптимизацию видится шаг поиска максимального элемента в столбце.
Исходный последовательный алгоритм имеет сложность O(N3). В параллельной версии, при N меньшем количества вычислительных ядер, один цикл исчезает (выполняется только одна итерации на каждом ядре) и сложность становиться O(N2). На интегрированной видеокарте с небольшим количеством ядер, когда каждое ядро выполняет несколько шагов цикла, была получена аппроксимация сложности ~О(N2,5).
Для матриц большой размерности имеет смысл попробовать другой класс алгоритмов решения - итеративные решатели. В планах также было протестировать метод BiCGStab, однако по неизвестной пока причине он переставал сходиться уже на матрице 40х40 заполненной случайными значениями и в реалзиации ViennaCL и в реализации IML++ (Iterative Methods Library) v. 1.2a. Возможно требуется применение предобуславливателя (предварительные шаги, повышающие сходимость решения) или более продвинутых методов. А также я не обнаружил способа задания начального приближения в реализации ViennaCL, что снижает ценность этой реализации для моего проекта.
Заключение
Результаты эксперимента показали отличные возможности применения OpenCL. Дальнешие планы по развитию проекта предполагают рост размерности системы уравнений и без применения GPU или смены численного метода (что не факт что возможно) мне не обойтись.
Все найденные библиотеки (насколько я изучил) и полученные результаты касались систем уравнений с действительными числами. Потребуется также разработать реализацию алгоритма LUP-разложения матрицы с комплексными значениями.
Разработанная программа будет внедрена в проект, а результаты применения OpenCL на реальной задаче прикладной механики планирую описать в следующей статье.
Комментарии (14)
BorisU
11.11.2024 10:43Не пытаетесь ли вы изобретать велосипед? для CUDA точно есть реализация, для AMD наверняка тоже. ну и было бы интересно сравнить скорость на GPU с ними.
vsnikolaev Автор
11.11.2024 10:43К собственному удивлению простого решения я не нашëл.
Наверняка эти реализации есть, но как было заявлено в начале, я исходил из уменьшения личных трудозатрат, т.к. я разрабатываю прикладное ПО. Несколько заходов на поиск результатов не дали, а глубоко копать даже clMagma я не счëл целесообразным.
Если у вас есть предложения по готовым библиотекам, я их рассмотрю и уже буду описывать результаты внедрения)
BorisU
11.11.2024 10:43не мешает посмотреть на MAGMA https://icl.utk.edu/magma/
У меня на GF4080 примерно так решается система с одинарной точностью
% N NRHS CPU Gflop/s (sec) GPU Gflop/s (sec) ||B - AX|| / N*||A||*||X|| % ===============================================================================
10304 1 --- ( --- ) 6008.86 ( 0.12) 1.19e-10 ok
JerryI
11.11.2024 10:43Искал реализацию поиска собственных значений и векторов (как раз нужен LUP) на OpenCL/CUDA там почти всегда целый фреймворк + общение с центральным процессором активное.
Ratikonkik
11.11.2024 10:43Отличная работа! Интересно увидеть дальнейшие оптимизации, особенно для поиска максимума. Рассматривайте предобуславливание для итеративных методов.
vsnikolaev Автор
11.11.2024 10:43Спасибо)
После некоторого рассмотрения задач редукции, видится следующее решение: поделить общее количество элементов на наибольшее число кратное 2^N и не превышающее количество ядер GPU, а дальше сравнивать по 2 числа на ядре.
raamid
11.11.2024 10:43Тоже интересуюсь данной тематикой, спасибо. У GPU, как известно, точность в основном float32. А для задач математической физики очень желательно float64. Есть ли у вас какие-то идеи по этому поводу?
vsnikolaev Автор
11.11.2024 10:43Меня тоже эта мысль смущала несколько лет назад. В этом подходе к OpenCL выяснилось что мои видеокарты (и интегрированная и дискретная) нативно поддерживают float64. Правда без векторных операций (по крайней мере Intel UHD 620).
BorisU
11.11.2024 10:43GPU умеют 64bit. правда в игровых картах производительность существенно порезана, относительно 32.
raamid
11.11.2024 10:43как интересно, не знал, спасибо что просветили. А насчет WebGL / WebGPU есть шансты получить float64 нативно?
avdx
11.11.2024 10:4340 секунд для матрицы 7600х7600 что то долго, тем более на GPU
Проверил свою реализацию 15 летней давности для обращения float64 матриц через LUP разложение с распараллеливанием для cpu. 19 секунд на i7-6800К в 12 потоков. Но 12 потоков это с hyper threading. Без него 23 секунды в 6 потоков/38 секунд в 3 потока, думаю масштабироваться должно близко к линейному, на современных процессорах по идее в несколько секунд должно укладываться. Реализацией могу поделиться, если интересно.
vsnikolaev Автор
11.11.2024 10:43Да, интересно, поделитесь)
avdx
11.11.2024 10:43Не знаю только как лучше это сделать :) Код старый, лежит в виде нескольких файликов. Могу скинуть архив, но не знаю куда.
devlev
11.11.2024 10:43Программирование на GPU - это отдельный мир программирования. Тут столько нужно знать нюансов, чтобы получить максимальную эффективность. Из того что помню, когда работал с CUDA, так это то что читать матрицу по вертикали было куда медленней, чем читать по горизонтали. Помню я не сразу понял, что причина была в кеше, и то как кешируются данные из глобальной памяти. В вашем алгоритме тоже есть что улучшить, возможно. Попробуйте завести локальные переменные под глобальные переменные (переменная A[m * N + k]), чтобы не читать дважды одно и то же значение.
Насчет скорости вычислений для 7600х7600, я бы начал сравнивать с эталонной программой - простое сложение двух матриц такого же размера. Эта программа на столько просто что там сложно допустить какую то ошибку и она наиболее результативно показывает максимум который достижим для данной размерности. Понаблюдать, за разницей сложения матриц по вертикали и по горизонтали.
vsnikolaev Автор
11.11.2024 10:43Спасибо за подсказку! Звучит как несложная оптимизация, которая может дать не плохой выйгрыш)
JerryI
Вероятно OpenCL остается самой живучей кроссплатформерной технологией для вычислений. Конкуренцию могла бы составить WebGPU, но там что-то пока не густо.