Привет! Меня зовут Михаил Козлов, я инженер-стажер в группе разработки математических библиотек в YADRO. Эта сфера активно развивается на RISC-V: известные математические библиотеки, такие как OpenBLAS, Eigen и многие другие, портируют и оптимизируют под открытую архитектуру. Большой интерес представляет OpenCL — открытый стандарт разработки программного обеспечения для гетерогенных вычислений. Он используется во многих областях: HPC, AI/ML, AR/VR, линейной алгебре, где он наиболее широко представлен с помощью библиотек clBLAS и CLBlast. 

ClBLAS — более старая, а CLBlast — более современная библиотека, со встроенным тюнером для оптимизации под конкретное железо. Далее я расскажу, как мы с командой исследовали производительность этих библиотек на GPU от Imagination и ARM Mali ARM. Кроме того, покажу, как запустить эти библиотеки на RISC-V CPU при помощи OpenCL — точнее, ее модификации PoCL, созданной разработчиками GPU Vortex.

Запуск СLBlast на GPU

CLBlast — это open source-библиотека на C++ с лицензией Apache License 2.0, использующая ядра на OpenCL. CLBlast реализует функции, соответствующие стандарту BLAS, и 9 дополнительных BLAS-подобных функций. Каждая реализация поддерживает пять типов чисел с плавающей запятой: три типа вещественных (FP16, FP32, FP64) и два типа комплексных чисел (2xFP32, 2xFP64).

BLAS-функции, реализованные в CLBlast
BLAS-функции, реализованные в CLBlast

У библиотеки есть несколько API.

  • CLBlast API С — написан на С, не поддерживает шаблонные функции, создан для совместимости с BLAS API. 

  • СBLast API — написан на C++, поддерживает шаблонные функции, функциональность аналогична CLBlast API С.

  • Netlib BLAS API — написан на С, поддерживает все вычислительные операции, нужен для поддержки приложений, который основывается на этот стандартный API.

  • CLCudaAPI — написан на C++, реализует обертку над OpenCL API и/или CUDA API. Обеспечивает высокую портативность между CUDA и non-CUDA девайсами при небольшом оверхэде.

CLBlast включает 18 ядер, что значительно меньше числа поддерживаемых функций. Причины этому две. Во-первых, каждое ядро может работать с любым из перечисленных типов данных, которые определяются на этапе компиляции. Во-вторых, многие ядра могут быть переиспользованы в разных функциях: например, реализация функции GBMV использует OpenCL-ядро GEMV с изменением препроцессинга.

Ядра, используемые сразу в нескольких функциях, и списки этих функций
Ядра, используемые сразу в нескольких функциях, и списки этих функций

У ядер CLBlast много параметров, что позволяет использовать CLTune для их оптимизации. Параметры делятся на два набора. Первый — это наиболее частые комбинации, чье влияние на производительность ядра на различных устройствах лучше всего изучено. Второй — все возможные комбинации: здесь количество наборов так велико, что пользователи сами проводят эксперименты с различными наборами и предлагают новые оптимальные параметры.

Пользователь также может с помощью CLTune самостоятельно оптимизировать ядра для задач с особыми размерностями. CLTune запускает ядра с различными наборами параметров и выбирает лучший набор по всем запускам. Вот для примера некоторые из этих параметров: 

  • WGS (Work Group Size). Этот параметр есть во многих ядрах, он определяет количество потоков, размерность группы потоков, необходимый размер локальной памяти. В ядрах параметр можно использовать по-разному. В одномерных задачах выделяется группа тредов размерностью WGS x 1, а в двухмерных — WGS x WGS. При работе с большими размерностями данные перекидываются на девайс и обрабатываются на нем порционно. В таких случаях WGS определяет размер локального массива, который будет использоваться в вычислениях.

  • WPT (Work Per Thread). Встречается в ядрах AXPY и GEMV. Этот параметр определяет количество последовательно расположенных в локальной памяти элементов, которые будут обрабатываться одним потоком. При оптимально подобранном параметре можно уменьшить число запросов к памяти в несколько раз.

  • VW (Vector Width). Встречается явно в ядрах AXPY и GEMV, используется в векторной реализации умножения векторов. Параметр задает количество элементов, которое помещаются в регистр.

Параметры в ядрах CLBlast
Параметры в ядрах CLBlast

Ниже представлена архитектура библиотеки:

Тестирование библиотеки CLBlast

Перед использованием новой библиотеки нужно убедиться в ее функциональной корректности, особенно на новой архитектуре. Для этого запустим функциональные тесты на исследуемых платформах.

Система функционального тестирования в CLBlast устроена следующим образом. Сначала из референс-библиотеки запускаем реализацию тестируемой функции: ею может выступать clBLAS или какая-нибудь CPU-BLAS-библиотека. Я остановил выбор на OpenBLAS — это наиболее популярная библиотека своего рода, хорошо оптимизированная под конкретное железо. Мы убедились в ее корректности, собственноручно исправив множество проблем.

Затем запускаем ту же функцию из CLBlast, сравниваем полученные ответы или, при сравнении с clBLAS, возвращаемый код. По итогам запуска я получил довольно большой список падений и некорректных ответов. Все упавшие тесты можно разделить на пять групп.

Несовпадение возвращаемых кодов. Система тестирования ожидает одинаковые коды ошибок от CLBlast и clBLAS. Но функции в библиотеках реализованы по-разному и требуют разного количества ресурсов. Например, в CLBlast есть усовершенствованная версия GEMM, использующая дополнительный буфер, в clBLAS такой реализации нет. Так что тест на недостаточный размер дополнительного буфера не проходит: мы получаем от CLBlast код ошибки, а от clBLAS — код успешного завершения.

Работа с числами половинной точности. Система тестирования CLBlast универсальна при работе со всеми типами данных. Исключение представляют лишь некоторые функции над числами половинной точности. В C++ нет поддержки чисел типа fp16, поэтому в коде они представлены как последовательность бит, записанных в тип unsigned short — это стандартный способ их представления в OpenCL. Для таких чисел реализованы методы для перевода в тип fp32 и обратно.

Сначала сложности возникли с функцией, возвращающей индекс максимального/минимального элемента: она отличается тем, что возвращает целое число, а не дробное. Остальные функции интерпретируют возвращаемую ядром последовательность бит как число с плавающей запятой (массив чисел с плавающей запятой), поэтому вызывают функцию перевода из fp16 в fp32. Из-за универсальности тестовой системы последовательность бит, возвращаемая iAMAX, будет также восприниматься как fp16, являясь в то же время целым числом.

tester.cpp
bool TestSimilarity(const half val1, const half val2) { 
 const auto kErrorMarginRelative = getRelativeErrorMargin<half>(); 
 const auto kErrorMarginAbsolute = getAbsoluteErrorMargin<half>(); 
 return TestSimilarityNear(HalfToFloat(val1), HalfToFloat(val2), 
 kErrorMarginAbsolute, kErrorMarginRelative); 
}

Проблема немного другого характера — в тестировании функции HGEMMBATCHED и HAXPYBATHED. BATCHED-версией алгоритма называется объединение n вызовов одного ядра в один вызов. При этом выделяется память под входные и выходные данные сразу всех вызовов, вычисления происходят одновременно. У функций умножения матриц и сложения векторов есть также скалярные параметры.

В BATCHED-версиях тест реализован таким образом, что каждый из нескольких сгруппированных вызовов получает различные значения этих параметров. На каждом следующем вызове значение увеличивается на 1. При работе с числами половинной точности происходит сложение не по правилам чисел с плавающей точкой, а по правилам целых чисел. Результат сложения будет интерпретироваться как fp16, что переводит скалярные параметры в значения типа nan.

xgemmbatched.hpp
args.alphas[batch] = args.alpha + Constant<T>(static_cast<double>(batch + 1)); 
args.betas[batch] = args.beta + Constant<T>(static_cast<double>(batch + 1));

Некорректная работа референса. В clBLAS функция TRSM получает иной ответ, нежели при использовании этой же функции в CLBlast и OpenBLAS.

Проблемы в OpenCL-ядрах. Ядра функций GEMMBATCHED и AXPYBATHED при работе с числами половинной точности пытались прочитать массив 16-битных чисел как массив 32-битных чисел. Связано это с некорректной работой с макросами.

xgemmbatched.opencl
void XaxpyBatched(const int n, const __constant real_arg* arg_alphas, 
 const __global real* restrict xgm, const __constant int* x_offsets, const int x_inc,  __global real* ygm, const __constant int* y_offsets, const int y_inc) {  const int batch = get_group_id(1); 
 const real alpha = GetRealArg(arg_alphas[batch]);

common.opencl
#if PRECISION == 16 
 typedef float real_arg;  
 #define GetRealArg(x) (half)x  
#else  
 typedef real real_arg;  
 #define GetRealArg(x) x

Проблемы с отдельными устройствами. Все описанные проблемы воспроизводились на всех конфигурациях, участвовавших в тесте. Но были и случаи, когда проблемы возникали только на определенных платах. Например, функция ASUM некорректно работала на ARM Mali GPU.

Мы исправили проблемы, касающиеся неправильной работы тестовой системы и ядер с числами половинной точности. CLBlas уже не особо актуален, и до него, возможно, руки дойдут позже, а стороннее «железо» нам неподвластно, так что мы сможем изучить лишь причины проблемы. Поэтому ряд таких проблемных функций в тестах производительности мы решили не рассматривать.

Проблема с несовпадением возвращаемых кодов фундаментальна для тестовой системы. Мы пришли к выводу, что ее исправление потребует глубокой переработки всей системы. Убедившись, что возвращаемые статусы СLBlast корректны и соответствуют ожиданиям, мы решили отложить этот вопрос.

Результаты экспериментов

В качестве тестовых мы выбрали три популярные функции из разных уровней BLAS API:

  • Первый уровень — AXPY, операция по сложению векторов, относящаяся к memory-bound задачам.

  • Второй уровень — GEMV, матрично-векторное умножение.

  • Третий уровень — GEMM, матричное умножение, compute-bound задача.

Все вычисления проводились над числами одинарной точности. Использование встроенного в CLBlast тюнера в нашем случае не дало прироста производительности — чаще всего лучшими для запуска оставались параметры по умолчанию. На GPU на плате VIM4 с новыми параметрами функции начинали работать медленнее. Это может быть связано с тем, что тюнинг производится только для конкретной размерности. Тестовые конфигурации были следующими:

OpenBLAS для LicheePI собрали с поддержкой векторного расширения V0p7 для запуска в многопоточном режиме — 4 потока на RISC-V, 8 потоков на ARM. Показатели производительности GPU получили с помощью утилиты clpeak, в которой реализованы синтетические бенчмарки на OpenCL.

AXPY

CLBlast превосходит clBLAS на 25–50% на ARM-платах, отстает почти в 2,5 раза на BXM-4-64 и выступает наравне на BXE-2-32. На Mali G610 OpenBLAS быстрее clBLAS на 5% и медленнее CLBlast на 25%. На остальных картах OpenBLAS опережает конкурентов на 5–17%.

GEMV

Производительность clBLAS довольно сильно колеблется исходя из размерности задачи. По сравнению с ним CLBlast работает в среднем на 70% медленнее, но только на BXM-4-64 и на больших размерностях. На остальных же ускорителях CLBlast, наоборот, на 75–100% более производительна, чем clBLAS.

Что касается OpenBLAS, то на Imagination GPU он в среднем на 55–70% медленнее clBLAS. На Mali G610 его отставание от CLBlast и clBLAS составляет 80 и 8% соответственно. Но на Mali G52 производительность OpenBLAS в 2–3,5 раза выше конкурентов.

GEMM

На больших размерностях CLBlast превосходит clBLAS на 45–65%. На BXM-4-64 CLBlast работает быстрее в 2,5 раза. На BXM-4-64 OpenBLAS опережает CLBlast на 10%, на Mali G52 — на 56%. На BXE-2-32 и Mali G610 производительность OpenBLAS ниже, чем у OpenBLAS, на 30 и 44% соответственно. 

Из результатов запусков на GPU можно сделать вывод, что в большинстве случаев CLBlast существенно превосходит по производительности clBLAS, исключением являются только запуски memory-bound бенчмарков SAXPY и SGEMV на LicheePi. Также у CLBlast на Mali G610 и BXE-2-32 производительность выше, чем OpenBLAS, во всех задачах.

Сравнение производительности разных GPU

Полученные результаты хорошо соотносятся с характеристиками рассматриваемых GPU: более производительные решения на ARM показывают лучшие результаты. Наиболее высокая производительность — у Mali G610: на AXPY и GEMM ускоритель опережает ближайший по мощности Mali G52 примерно в 2,2 раза, а GEMV в среднем работает вообще в 4 раза быстрее.

Также G610 демонстрирует интересную динамику на матрично-векторном умножении: на больших размерностях происходит снижение производительности. Это может быть связано с тем, что при увеличении количества элементов растет и доля операций по перемещению данных с CPU на GPU в итоговом времени.

Весьма любопытно себя ведут GPU от Imagination. На AXPY и GEMV ускоритель BXM-4-64 работает медленнее, чем BXE-2-32, хотя на бенчмарках clpeak производительность у первого GPU выше. Причиной этому может быть медленный перенос данных с CPU на GPU на плате LicheePi. На GEMM производительность этих GPU соотносится так же, как в бенчмарках clpeak: BXM-4-64 работает примерно в два раза быстрее.

Запуск OpenCL на RISC-V СPU

Для запуска OpenCL на RISC-V мы будем использовать PoCL — переносимую реализацию OpenCL с открытым исходным кодом. Она запускается на большом количестве устройств, включая CPU и GPU общего назначения, а также другие пользовательские ускорители.

Архитектура PoCL включает в себя runtime-библиотеку, реализующую OpenCL API, и компилятор на базе LLVM для компиляции ядер. Специально для поддержки RISC-V в PoCL были произведены следующие модификации:

  • добавлены новые поддерживаемые устройства для конфигурации сборки, 

  • обеспечена компиляция среды выполнения в библиотеку RISC-V посредством кросс-компиляции,

  • добавлен новый режим исполнения для автономной компиляции ядра. 

Более подробно работа PoCL на RISC-V описана в статье разработчиков Vortex GPU.

Сборка PoCL на плате не вызвала особых затруднений, зато кросс-компиляция обернулась настоящим испытанием. Делюсь порядком действий, к которому я в итоге пришел:

  • Установить локально clang и LLVM для локальной машины и для платы (можно просто скопировать с платы /usr/lib/llvm-x, где x — версия LLVM на плате). Версии обеих сборок должны совпадать.

  • Если нужно, установить на плате ocl-icd и libhwloc и скопировать их файлы с платы в папку lib, расположенную в установочной директории LLVM, собранной для платы.

  • Установить pkg-config на машину, на которой проходит сборка, и настроить переменную окружения.

export PKG_CONFIG_PATH=/path/to/hwloc/prefix/lib/pkgconfig:/path/to/opencl/prefix/lib/pkgconfig
  • Скопировать с платы папку с основными библиотеками (/usr/lib/riscv64-linux-gnu) в папку lib, расположенную в установочной директории LLVM, собранной для платы.

  • Cкопировать /usr/lib/gcc/riscv64-linux-gnu/13/libstdc++.so в папку lib, расположенную в установочной директории LLVM, собранной для платы.

  • Скачать toolchain для использования заголовочных файлов из папки ris cv-gcc/sysroot/usr/include и скопировать содержимое в папку include из установочной директории LLVM, собранной для платы.

  • Добавить флаги компиляции и прописать пути к папкам в файле /<pocl_source_dir>/ToolchainExample.cmake.

SET(CMAKE_C_COMPILER <devtoolkit dir>/riscv-gcc/bin/riscv64-unknown-linux-gnu-gcc) SET(CMAKE_CXX_COMPILER <devtoolkit dir>/sc-devtoolkit/riscv-gcc/bin/riscv64-unknown-linux-gnu-g++) 
SET(CMAKE_CXX_FLAGS "-mabi=lp64d -march=rv64imafdczbb0p93_zba0p93 -mcpu=sifive-u74 -mtune=sifive-7-series") SET(CMAKE_C_FLAGS "-mabi=lp64d -march=rv64imafdczbb0p93_zba0p93 -mcpu=sifive-u74 -mtune=sifive-7-series") SET(CMAKE_C_FLAGS "-I<hwloc install path>/include") 
SET(CMAKE_CXX_FLAGS "-Wl,-rpath-link,<llvm-x install path>/lib") 
# should work, but does not yet. Instead set FIND_ROOT below 
# set(CMAKE_SYSROOT /home/a/zynq/ZYNQ_ROOT) 
# where is the target environment 
SET(CMAKE_FIND_ROOT_PATH <llvm-x install path>/) 
# where to find libraries in target environment 
SET(CMAKE_LIBRARY_PATH <llvm-x install path>/lib)
  • Скопировать с заменой исполняемый файл llvm-config из папки bin в установочной директории LLVM, собранной для локальной машины, — в папку bin в установочной директории LLVM, собранной для платы.

  • Так же, как и при локальной сборке, изменить файл <pocl_source_dir>/lib/CL/pocl_llvm_wg.cc.

  • Если на этапе сборки PoCL возникнут проблемы с переопределением типов, нужно изменить исходные файлы в директории LLVM, собранной для платы, и исходные файлы PoCL.

$LLVM_BUILD_PREFIX/lib/clang/x/include/stdint.h
96: - typedef __INT64_TYPE__ int64_t; 
 + typedef long long int int64_t; 
98: - typedef __UINT64_TYPE__ int64_t; 
 + typedef unisgned long long int int64_t;

<pocl_source_dir>/lib/kernel/printf.c
32: - typedef intptr_t ssize_t; 
 + typedef int ssize_t;

  • Осуществить сборку.

cmake -DHOST_DEVICE_BUILD_HASH=riscv64-unknown-linux-gnu-rv64gc -DLLC_HOST_CPU=generic-rv64 - DHOST_CPU_TARGET_ABI=lp64d -DENABLE_LLVM=1 -DCMAKE_TOOLCHAIN_FILE=<pocl_source_dir>/ToolchainExample.cmake - DCMAKE_PREFIX_PATH=$HOST_PREFIX -DLLC_TRIPLE=riscv64-unknown-gnu -DLLVM_HOST_TARGET=riscv64-unknown-linux gnu -DENABLE_ICD=ON(OFF) -DLLVM_BINDIR=$BUILD_PREFIX/bin <pocl_soucre_dir> 
cmake --build . --target install 
$BUILD_PREFIX - LLVM ,  
$HOST_PREFIX - LLVM,

Если PoCL был собран с ключом -DENABLE_ICD=ON, нужно исправить путь до библиотеки, который прописан в файле <pocl_install_dir>/etc/OpenCL/vendors/pocl.icd.

Результаты экспериментов

Увы, нам не удалось собрать PoCL специально под плату LicheePI, так что на ней запуски производились с использованием PoCL, собранного под VisionFive.

AXPY

В большинстве случаев разница в производительности между OpenBLAS и CLBlast небольшая, не более 15–20%. Существенный разрыв появляется только на плате Radaxa ROCK5 Model B: ускорение CLBlast относительно OpenBLAS может достигать 85%.

GEMV

С GEMV видно, что хоть на меньших размерностях разница в производительности между CLBlast и OpenBLAS может быть не такой большой (10–15%), но при увеличении числа элементов OpenBLAS оказывается производительнее, чем CLBlast, на 85–230%.

GEMM

В случае больших размерностей производительность OpenBLAS выше CLBlast PoCL: на 25% — на VisionFive, на 55% — на VIM4, на 110% — на Radaxa ROCK5 Model B, на 257% — на LicheePI. Также стоит отметить, что при увеличении числа элементов матрицы производительность OpenBLAS возрастает, а производительность CLBlast, наоборот, падает.

В целом мы получили ожидаемые результаты. Более популярная библиотека OpenBLAS работает лучше, чем экспериментальный и сырой вариант запуска OpenCL BLAS-функций на CPU. Но местами отставание не такое большое даже на compute-bound задаче (GEMM VisionFive 2), а на AXPY CLBlast и вовсе оказывался быстрее. Это аргумент в пользу развития OpenCL на архитектуре RISC-V.

Заключение

Мы убедились, что CLBlast показывает высокую производительность по сравнению с clBLAS и OpenBLAS. Нам удалось запустить CLBlast на RISC-V CPU. Чаще всего CLBlast довольно сильно проигрывает в производительности OpenBLAS, но на memory-bound задачах CLBlast держится на том же уровне или существенно обгоняет OpenBLAS. При замере скорости работы GEMM на плате VisionFive 2 мы получили отставание всего на 25%.

Подобные результаты обнадеживают. Если появится реализация OpenCL, оптимизированная под архитектуру RISC-V, то библиотеки на ее основе смогут приблизиться к более популярным решениям и даже обогнать их.

Комментарии (1)


  1. unreal_undead2
    09.10.2024 08:33
    +1

    Если появится реализация OpenCL, оптимизированная под архитектуру RISC-V

    Кстати, как сейчас дела в PoCL и llvm с поддержкой RVV ?