Данное исследование посвящено умножению матриц, которые за счет размеров помещаются в L1 кеш CPU и как следствие наиболее эффективно умножаются на единственном ядре.
Использовалась машина с Intel(R) Core(TM) i5-11400F @ 2.60GHz, поддерживающим в том числе расширения AVX2 и AVX512. Операционная система: Arch Linux kernel 6.13.8. Компилятор: Clang 19. Для референса в функциональных тестах и тестах производительности использовалась библиотека OpenBLAS 0.3.29.
Гарантируется корректная сборка проекта на x64-машинах с расширениями AVX2 и AVX512 с Linux с компилятором, указанным в окружении выше.
python3 -m venv .venv
source .venv/bin/activate
pip install conan
conan profile detect --force
conan install . --build=missing --output-folder=build -pr:a=linux_release.profile
cd build
cmake .. --preset conan-release
cmake --build . -jКак уже упоминалось, главная цель работы в оптимизациях в небольшом масштабе, в пределах одного потока, поскольку размеры матриц таковы, что сами матрицы очень эффективно кешируются в L1.
Начнем с наивной версии:
void matmul1x1(size_t m, size_t n, size_t k, const float* __restrict__ a,
const float* __restrict__ b, float* __restrict__ c) {
memset(c, 0, m * n);
for (size_t i = 0; i < m; ++i) {
for (size_t j = 0; j < n; ++j) {
for (size_t l = 0; l < k; ++l) {
c[n * i + j] += a[k * i + l] * b[n * l + j];
}
}
}
}Идея оптимизации в замене вычисления одного элемента за раз на вычисление сразу прямоугольными блоками
фиксированного размера, поскольку нам это позволяют векторные расширения Intel AVX. В частности большинство
современных процессоров Intel поддерживают расширения AVX2 и FMA. Реализация с их использованием с размерами блока
static void matmulKernel8x8(size_t m, size_t n, size_t k,
const float* __restrict__ a,
const float* __restrict__ b,
float* __restrict__ c) {
__m256 c0 = {};
__m256 c1 = {};
__m256 c2 = {};
__m256 c3 = {};
__m256 c4 = {};
__m256 c5 = {};
__m256 c6 = {};
__m256 c7 = {};
for (size_t l = 0; l < k; ++l) {
__m256 b_vector = _mm256_load_ps(&b[n * l]);
c0 = _mm256_fmadd_ps(_mm256_set1_ps(a[k * 0 + l]), b_vector, c0);
c1 = _mm256_fmadd_ps(_mm256_set1_ps(a[k * 1 + l]), b_vector, c1);
c2 = _mm256_fmadd_ps(_mm256_set1_ps(a[k * 2 + l]), b_vector, c2);
c3 = _mm256_fmadd_ps(_mm256_set1_ps(a[k * 3 + l]), b_vector, c3);
c4 = _mm256_fmadd_ps(_mm256_set1_ps(a[k * 4 + l]), b_vector, c4);
c5 = _mm256_fmadd_ps(_mm256_set1_ps(a[k * 5 + l]), b_vector, c5);
c6 = _mm256_fmadd_ps(_mm256_set1_ps(a[k * 6 + l]), b_vector, c6);
c7 = _mm256_fmadd_ps(_mm256_set1_ps(a[k * 7 + l]), b_vector, c7);
}
_mm256_store_ps(&c[n * 0], c0);
_mm256_store_ps(&c[n * 1], c1);
_mm256_store_ps(&c[n * 2], c2);
_mm256_store_ps(&c[n * 3], c3);
_mm256_store_ps(&c[n * 4], c4);
_mm256_store_ps(&c[n * 5], c5);
_mm256_store_ps(&c[n * 6], c6);
_mm256_store_ps(&c[n * 7], c7);
}
void matmul8x8(size_t m, size_t n, size_t k, const float* __restrict__ a,
const float* __restrict__ b, float* __restrict__ c) {
assert((m & 0x7) == 0);
assert((n & 0x7) == 0);
assert((k & 0x7) == 0);
assert(((uintptr_t)a & 0x1f) == 0);
assert(((uintptr_t)b & 0x1f) == 0);
assert(((uintptr_t)c & 0x1f) == 0);
memset(c, 0, m * n);
for (size_t i = 0; i < m; i += 8) {
for (size_t j = 0; j < n; j += 8) {
matmulKernel8x8(m, n, k, &a[k * i], &b[j], &c[n * i + j]);
}
}
}Здесь для упрощения предполагается что размеры кратны размерам блоков (матрицу можно "замостить" такими блоками) и указатели выровнены по 32 байта (для операций load и store). Эти предположения закреплены assertions в начале.
Поскольку используемая машина поддерживает также AVX512, была написана и более эффективная полностью аналогичная
реализация с размерами
блоков
static void matmulKernel16x8(size_t m, size_t n, size_t k,
const float* __restrict__ a,
const float* __restrict__ b,
float* __restrict__ c) {
__m512 c0 = {};
__m512 c1 = {};
__m512 c2 = {};
__m512 c3 = {};
__m512 c4 = {};
__m512 c5 = {};
__m512 c6 = {};
__m512 c7 = {};
for (size_t l = 0; l < k; ++l) {
__m512 b_vector = _mm512_load_ps(&b[n * l]);
c0 = _mm512_fmadd_ps(_mm512_set1_ps(a[k * 0 + l]), b_vector, c0);
c1 = _mm512_fmadd_ps(_mm512_set1_ps(a[k * 1 + l]), b_vector, c1);
c2 = _mm512_fmadd_ps(_mm512_set1_ps(a[k * 2 + l]), b_vector, c2);
c3 = _mm512_fmadd_ps(_mm512_set1_ps(a[k * 3 + l]), b_vector, c3);
c4 = _mm512_fmadd_ps(_mm512_set1_ps(a[k * 4 + l]), b_vector, c4);
c5 = _mm512_fmadd_ps(_mm512_set1_ps(a[k * 5 + l]), b_vector, c5);
c6 = _mm512_fmadd_ps(_mm512_set1_ps(a[k * 6 + l]), b_vector, c6);
c7 = _mm512_fmadd_ps(_mm512_set1_ps(a[k * 7 + l]), b_vector, c7);
}
_mm512_store_ps(&c[n * 0], c0);
_mm512_store_ps(&c[n * 1], c1);
_mm512_store_ps(&c[n * 2], c2);
_mm512_store_ps(&c[n * 3], c3);
_mm512_store_ps(&c[n * 4], c4);
_mm512_store_ps(&c[n * 5], c5);
_mm512_store_ps(&c[n * 6], c6);
_mm512_store_ps(&c[n * 7], c7);
}
void matmul16x8(size_t m, size_t n, size_t k, const float* __restrict__ a,
const float* __restrict__ b, float* __restrict__ c) {
assert((m & 0x7) == 0);
assert((n & 0xf) == 0);
assert((k & 0xf) == 0);
assert(((uintptr_t)a & 0x3f) == 0);
assert(((uintptr_t)b & 0x3f) == 0);
assert(((uintptr_t)c & 0x3f) == 0);
memset(c, 0, m * n);
for (size_t i = 0; i < m; i += 8) {
for (size_t j = 0; j < n; j += 16) {
matmulKernel16x8(m, n, k, &a[k * i], &b[j], &c[n * i + j]);
}
}
}UPD: с целью повысить утилизацию zmm-регистров, была также измерена написана и измерена
аналогичная версия с увеличенной разверткой
static void matmulKernel16x16(size_t m, size_t n, size_t k,
const float* __restrict__ a,
const float* __restrict__ b,
float* __restrict__ c) {
__m512 c0 = {};
__m512 c1 = {};
__m512 c2 = {};
__m512 c3 = {};
__m512 c4 = {};
__m512 c5 = {};
__m512 c6 = {};
__m512 c7 = {};
__m512 c8 = {};
__m512 c9 = {};
__m512 c10 = {};
__m512 c11 = {};
__m512 c12 = {};
__m512 c13 = {};
__m512 c14 = {};
__m512 c15 = {};
for (size_t l = 0; l < k; ++l) {
__m512 b_vector = _mm512_load_ps(&b[n * l]);
c0 = _mm512_fmadd_ps(_mm512_set1_ps(a[k * 0 + l]), b_vector, c0);
c1 = _mm512_fmadd_ps(_mm512_set1_ps(a[k * 1 + l]), b_vector, c1);
c2 = _mm512_fmadd_ps(_mm512_set1_ps(a[k * 2 + l]), b_vector, c2);
c3 = _mm512_fmadd_ps(_mm512_set1_ps(a[k * 3 + l]), b_vector, c3);
c4 = _mm512_fmadd_ps(_mm512_set1_ps(a[k * 4 + l]), b_vector, c4);
c5 = _mm512_fmadd_ps(_mm512_set1_ps(a[k * 5 + l]), b_vector, c5);
c6 = _mm512_fmadd_ps(_mm512_set1_ps(a[k * 6 + l]), b_vector, c6);
c7 = _mm512_fmadd_ps(_mm512_set1_ps(a[k * 7 + l]), b_vector, c7);
c8 = _mm512_fmadd_ps(_mm512_set1_ps(a[k * 8 + l]), b_vector, c8);
c9 = _mm512_fmadd_ps(_mm512_set1_ps(a[k * 9 + l]), b_vector, c9);
c10 = _mm512_fmadd_ps(_mm512_set1_ps(a[k * 10 + l]), b_vector, c10);
c11 = _mm512_fmadd_ps(_mm512_set1_ps(a[k * 11 + l]), b_vector, c11);
c12 = _mm512_fmadd_ps(_mm512_set1_ps(a[k * 12 + l]), b_vector, c12);
c13 = _mm512_fmadd_ps(_mm512_set1_ps(a[k * 13 + l]), b_vector, c13);
c14 = _mm512_fmadd_ps(_mm512_set1_ps(a[k * 14 + l]), b_vector, c14);
c15 = _mm512_fmadd_ps(_mm512_set1_ps(a[k * 15 + l]), b_vector, c15);
}
_mm512_store_ps(&c[n * 0], c0);
_mm512_store_ps(&c[n * 1], c1);
_mm512_store_ps(&c[n * 2], c2);
_mm512_store_ps(&c[n * 3], c3);
_mm512_store_ps(&c[n * 4], c4);
_mm512_store_ps(&c[n * 5], c5);
_mm512_store_ps(&c[n * 6], c6);
_mm512_store_ps(&c[n * 7], c7);
_mm512_store_ps(&c[n * 8], c8);
_mm512_store_ps(&c[n * 9], c9);
_mm512_store_ps(&c[n * 10], c10);
_mm512_store_ps(&c[n * 11], c11);
_mm512_store_ps(&c[n * 12], c12);
_mm512_store_ps(&c[n * 13], c13);
_mm512_store_ps(&c[n * 14], c14);
_mm512_store_ps(&c[n * 15], c15);
}
void matmul16x16(size_t m, size_t n, size_t k, const float* __restrict__ a,
const float* __restrict__ b, float* __restrict__ c) {
assert((m & 0x7) == 0);
assert((m & 0xf) == 0);
assert((n & 0xf) == 0);
assert((k & 0xf) == 0);
assert(((uintptr_t)a & 0x3f) == 0);
assert(((uintptr_t)b & 0x3f) == 0);
assert(((uintptr_t)c & 0x3f) == 0);
memset(c, 0, m * n);
for (size_t i = 0; i < m; i += 16) {
for (size_t j = 0; j < n; j += 16) {
matmulKernel16x16(m, n, k, &a[k * i], &b[j], &c[n * i + j]);
}
}
}
Измерялось число тактов CPU, потраченных каждой реализацией, при помощи интринсика __rdtsc().
Каждая реализация вызывалась 100000 раз за бенчмарк.
Для каждой реализации измерения повторялись 8 раз для сведения к минимуму случайных факторов.
Полные данные доступны в таблице. Для наглядности также можно привести
гистограмму среднего числа тактов для разных размеров матриц:
Здесь не была приведена наивная реализация в силу того, что она выполнялась на порядок дольше всех
остальных и очень сильно испортила бы гистограмму. Можно заметить, что мы смогли даже немного обогнать
OpenBLAS. Особенно это заметно на размерах
Увеличение развертки с 8 до 16 для AVX512 версии не привело к значительным результатам, ускорение в пределах погрешности.
