Українська
Лінійна алгебра та продуктивність
Рівні BLAS і множення матриць
BLAS (Basic Linear Algebra Subprograms) – стандартний набір підпрограм лінійної алгебри https://www.netlib.org/blas/, на який спираються бібліотеки LAPACK, NumPy, MATLAB і фреймворки машинного навчання. Оптимізовані реалізації (Intel oneMKL, OpenBLAS) написані з інтринсиками для кожного процесора. Підпрограми поділено на три рівні за складністю (табл. 7.5).
Таблиця 7.5. Рівні BLAS
| Рівень | Приклади | Операція | Складність |
|---|---|---|---|
| 1: вектор–вектор | dot, axpy, nrm2 | ||
| 2: матриця–вектор | gemv | ||
| 3: матриця–матриця | gemm |
Операції рівня 1 обмежені пропускною здатністю пам’яті: на кожне число припадає одна-дві арифметичні операції. Операції рівня 3 виконують
Порядок циклів
Добуток i * n + j. Три вкладені цикли можна переставити шістьма способами, і результат не зміниться, але швидкодія – так (рис. 7.5).
Рис. 7.5. Порядок циклів і доступ до пам’яті
- ijk (як у формулі): найвнутрішніший цикл за
kпроходить рядокAпослідовно, а матрицюB– по стовпцю, стрибками на елементів. Стовпець розкиданий по всій матриці, яка для займає 32 МБ і не поміщається навіть у кеш L3 (16 МБ), тому майже кожне звертання доB– промах кешу (cache miss). - ikj: цикл за
jнайвнутрішніший, і всі три матриці проходяться по рядках, послідовно. Внутрішній циклc[i*n + j] += aik * b[k*n + j]має вигляд операціїaxpyі легко векторизується.
У прикладі «Множення матриць» для B займає 8 МБ і поміщається в кеш L3.
Блочне множення матриць
Порядок ikj проходить рядки послідовно, але для великих B (для B не поміщається в кеш. Блочне множення (blocked, tiled multiplication) ділить матриці на плитки (tiles) розміром
Рис. 7.6. Блочне множення матриць
Три плитки double розміром
- SIMD: внутрішній цикл за
j(операціяaxpyнад рядком плитки) векторизуютьVector256; - потоки: рядки плиток матриці
Cнезалежні, тому зовнішній цикл виконують черезParallel.For; кожен потік пише лише у свої рядкиC, синхронізація не потрібна.
Продуктивність обчислень вимірюють у FLOPS (floating-point operations per second) – кількості операцій з рухомою комою за секунду; GFLOPS – у мільярдах. Множення матриць
де
Таблиця 7.6. Множення матриць
| Спосіб | Час, мс | GFLOPS | |
|---|---|---|---|
| ijk | 48 506,6 | 0,35 | 1,0 |
| ikj | 7153,7 | 2,40 | 6,8 |
| блочне, плитки 64 | 4715,3 | 3,64 | 10,3 |
блочне + SIMD (Vector256) | 2276,8 | 7,55 | 21,3 |
блочне + SIMD + Parallel.For | 461,1 | 37,26 | 105,2 |
Прискорення в 105 разів складається з трьох множників: кеш (у 10 разів), SIMD (удвічі) і 16 потоків (у 4,9 раза). Оптимізовані бібліотеки BLAS досягають ще більшого: вони пишуть ядро операції інтринсиками з кількома рівнями плиток для L1, L2 і L3.
Паралельні методи розв’язання СЛАР
Систему лінійних алгебраїчних рівнянь (СЛАР)
Метод Гаусса
Прямий хід методу Гаусса для кожного стовпця
На кроці Parallel.For, а оновлення рядка – векторно (операція axpy):
cs
// a – розширена матриця n × (n + 1), w = n + 1.
Parallel.For(k + 1, n, i =>
{
double f = a[i * w + k] / a[k * w + k];
ReadOnlySpan<double> rowK = a.AsSpan(k * w + k, w - k);
Span<double> rowI = a.AsSpan(i * w + k, w - k);
// rowI = rowK · (-f) + rowI – векторно
TensorPrimitives.MultiplyAdd(rowK, -f, rowI, rowI);
});Вибір головного елемента й перестановка рядків залишаються послідовними, а кроки Parallel.For коштує більше за роботу: для
Метод Якобі
Метод Якобі обчислює нове наближення лише зі старого:
Усі Parallel.For. Ітерації повторюють, доки
Червоно-чорний метод Гаусса–Зейделя
Метод Гаусса–Зейделя використовує нові значення Parallel.For за рядками i, а в рядку цикл за j починається з 1 + (i + color + 1) % 2 і йде з кроком 2, оновлюючи u[i*n + j] середнім чотирьох сусідів.
Червоний вузол читає лише чорних сусідів, які на цьому півкроці не змінюються, тому гонитви немає. Для сітки
Вимірювання та аналіз продуктивності
Векторизований код вимірюють так само, як паралельний (тема 1): конфігурація Release, прогрівання, медіана запусків. Окремі особливості SIMD:
- рівні JIT: метод з циклом спочатку компілюється без оптимізацій; перш ніж вимірювати, метод викликають десятки разів і дають JIT час на оптимізовану перекомпіляцію (Tier 1);
- малі та великі дані: для масивів з кількох елементів векторний код може бути повільнішим за скалярний через підготовку й хвіст; для великих масивів час обмежує пам’ять;
- вирівнювання: випадкове розташування масиву в пам’яті змінює час між запусками.
BenchmarkDotNet і дизасемблер
Для порівняння способів найзручніший BenchmarkDotNet (тема 4). Атрибут [DisassemblyDiagnoser] додатково зберігає машинний код кожного методу https://benchmarkdotnet.org/articles/features/disassembler.html:
Методи порівняння позначають атрибутом [Benchmark] (один – з Baseline = true), а клас – атрибутом [DisassemblyDiagnoser(maxDepth: 1)]. Для суми мільйона float на i9-11900KF BenchmarkDotNet 0.15.8 показав: скалярний цикл – 870 мкс, Vector<T> – 112 мкс, Vector256 – 112 мкс, TensorPrimitives.Sum – 58 мкс (відношення 0,07, тобто в 15 разів швидше) (рис. 7.7). У стовпці Code Size видно розмір машинного коду: 32 байти для скалярного циклу і 903 байти для TensorPrimitives, який має окремі шляхи для різних ширин і довжин.
Знімок екрана
Windows Terminal: dotnet run -c Release of the SumBenchmarks project; summary table with Scalar, VectorT, Vector256Sum, Tensor; columns Mean, Ratio, Code Size; N = 1000000
Рис. 7.7. Порівняння скалярної та векторної суми в BenchmarkDotNet
Машинний код записується у файл BenchmarkDotNet.Artifacts/results/SumBenchmarks-asm.md (рис. 7.8). У скалярному методі цикл містить інструкцію vaddss (додавання одного float, суфікс ss – scalar single), а у векторному – vaddps ymm6, ymm6, [r8] (8 чисел у регістрі YMM, суфікс ps – packed single). Так перевіряють, чи справді JIT згенерував векторні інструкції, а також чи залишилися перевірки меж масиву (cmp і перехід на CORINFO_HELP_RNGCHKFAIL).
Знімок екрана
Rider: open BenchmarkDotNet.Artifacts/results/SumBenchmarks-asm.md, Markdown preview; the Scalar method with vaddss and the Vector256Sum method with vaddps ymm6,ymm6,[...]
Рис. 7.8. Асемблерний код векторного циклу
Для загального аналізу обмежень використовують модель Roofline (Roofline model): графік продуктивності (GFLOPS) залежно від арифметичної інтенсивності (операцій на байт переміщених даних). Похила частина «даху» – межа пропускної здатності пам’яті, горизонтальна – межа обчислювальної потужності; для C, C++ і Fortran його будує Intel Advisor https://www.intel.com/content/www/us/en/developer/articles/guide/intel-advisor-roofline.html.
Похибки float і double
Векторизація змінює порядок операцій з дійсними числами, тому результат може відрізнятися від скалярного. У прикладі «Сума елементів масиву» сума мільйона випадкових float дорівнює 499 848,3 у скалярному циклі, 499 854,9 у векторних і 499 855,1 у TensorPrimitives, а точне значення (у double) – 499 854,3. Тип float зберігає лише 6–7 значущих цифр, і похибка накопичується з мільйонів округлень. Тому для сум великої кількості чисел використовують double, а векторні й скалярні результати порівнюють з відносним допуском, а не оператором ==. Тип float обирають для графіки, сигналів і нейромереж: він дає вдвічі ширші вектори.