швидке лінійне рішення системи для малих квадратних матриць (10x10)


9

Мені дуже цікаво оптимізувати лінійне рішення системи для малих матриць (10х10), які іноді називають крихітними матрицями. Чи є для цього готове рішення? Матрицю можна вважати несинулярною.

Цей вирішувач повинен бути виконаний понад 1 000 000 разів за мікросекунди на процесорі Intel. Я говорю про рівень оптимізації, який використовується в комп’ютерних іграх. Незалежно від того, чи я кодую це в специфічних для складання та архітектури, або вивчайте точність або зменшення компромісів над надійністю і використовую хакі з плаваючою комою (я використовую прапор компіляції -ffast-math, немає проблем). Рішення може навіть провалитись приблизно за 20% часу!

Egien's partPivLu - найшвидший у моєму поточному орієнтирі, перевершує LAPACK при оптимізації з -O3 та хорошим компілятором. Але зараз я перебуваю в руці розробки персонального лінійного вирішувача. Будь-яка порада буде дуже вдячна. Я зроблю своє рішення відкритим кодом, і я ознайомлюсь із ключовими відомостями у публікаціях тощо.

Пов'язане: Швидкість розв’язування лінійної системи за допомогою діагональної блокової матриці Який найшвидший метод перевернути мільйони матриць? https://stackoverflow.com/q/50909385/1489510


7
Це виглядає як мета розтяжки. Припустимо, що ми використовуємо найшвидший Skylake-X Xeon Platinum 8180 з теоретичною піковою пропускною здатністю 4 одноточних TFLOP, і що одна система 10х10 вимагає розв'язання приблизно 700 (приблизно 2n ** 3/3) операцій з плаваючою точкою. Тоді партія 1М таких систем теоретично могла бути вирішена за 175 мікросекунд. Це не може перевищити швидкість світла. Чи можете ви поділитися якою ефективністю, яку ви зараз досягаєте, за допомогою свого найшвидшого існуючого коду? До речі, дані є однією точністю або подвійною точністю?
njuffa

@njuffa так, я мав на меті досягти близько 1 мс, але мікро - інша історія. Для мікро я розглядав можливість використання інкрементної зворотної структури в партії шляхом виявлення подібних матриць, які трапляються часто. Perf знаходиться в діапазоні 10-500 мс залежно від процесора. Точність подвійна або навіть складна подвійна. Одноточна точність робить повільніше.
rfabbri

@njuffa Я можу зменшити або підвищити точність швидкості
rfabbri

2
Схоже, точність / точність - не ваш пріоритет. Для вашої мети, можливо, корисний ітераційний метод, урізаний при порівняно невеликій кількості оцінок? Особливо, якщо у вас є розумна початкова здогадка.
Спенсер Брінгельсон

1
Ти зводиш? Не могли б ви зробити QR-факторизацію замість усунення Гаусса. Чи переплітаєте ви системи, щоб ви могли користуватися інструкціями SIMD та робити декілька систем одночасно? Ви пишете прямолінійні програми без циклів і непрямої адреси? Якої точності ви хочете, і як я обумовлена ​​вашою системою? Чи мають вони структуру, яку можна було б використати.
Карл Крістіан

Відповіді:


7

Використання матричного типу Eigen, де кількість рядків і стовпців кодується у тип під час компіляції, дає вам перевагу над LAPACK, де розмір матриці відомий лише під час виконання. Ця додаткова інформація дозволяє компілятору виконувати повну або часткову циклічну розгортання, виключаючи безліч інструкцій гілок. Якщо ви шукаєте використовувати існуючу бібліотеку, а не писати власні ядра, то тип даних, у якому розмір матриці може бути включений як параметри шаблону C ++, ймовірно, буде важливим. Тільки інші бібліотеки я НЕ знаю , що робить це полум'я , так що може бути варто бенчмаркінг проти Ейгена.

Якщо ви вирішите скористатися власною реалізацією, ви можете виявити, що PETSc робить для свого блокового формату CSR корисним прикладом, хоча сам PETSc, ймовірно, не буде правильним інструментом для того, що ви маєте на увазі. Замість того, щоб записати цикл, вони виписують кожну операцію для малих матричних векторів, що розмножуються явно (див. Цей файл у їх сховищі). Це гарантує відсутність таких галузевих інструкцій, як, можливо, ви отримаєте цикл. Версії коду з інструкціями AVX є хорошим прикладом того, як насправді використовувати векторні розширення. Наприклад, ця функція використовує__m256dтип даних одночасно працювати на чотирьох парних одночасно. Ви можете отримати помітне підвищення продуктивності, чітко виписавши всі операції з використанням векторних розширень, тільки для LU-факторизації замість множення матричного вектора. Замість того, щоб насправді писати код С вручну, вам краще буде використовувати сценарій для його створення. Також може бути цікаво побачити, чи є помітна різниця в продуктивності, коли ви переставляєте деякі операції, щоб краще скористатися інструкціями конвеєрного планування.

Ви також можете отримати деякий пробіг у інструменту STOKE , який випадковим чином вивчить простір можливих програмних перетворень, щоб знайти більш швидку версію.


tx. Я вже використовую Eigen, як Map <const Matrix <complex, 10, 10>> AA (A). перевіримо інші речі.
rfabbri

Eigen також має AVX і навіть складний заголовок для нього. Навіщо для цього PETSc? У цьому випадку важко конкурувати з Ейґеном. Я ще більше спеціалізувався на Eigen для своєї проблеми і з приблизною стратегією зведення, що замість того, щоб брати макс над стовпчиком, негайно поміняє стрижень, коли виявить інший, який на 3 порядки більший.
rfabbri

1
@rfabbri Я не пропонував використовувати для цього PETSc, тільки те, що вони роблять у конкретному випадку, може бути повчальним. Я відредагував відповідь, щоб зробити це зрозумілішим.
Даніель Шаперо

4

Іншою ідеєю може бути використання генеративного підходу (написання програми). Автор (мета) програми, яка виписує послідовність інструкцій C / C ++ для виконання невідтворених ** LU в системі 10x10 .. в основному, приймаючи гніздо k / i / j петлі і розрівнюючи його в лінії O (1000) або близько того скалярна арифметика. Потім подайте цю програму в той чи інший оптимізуючий компілятор. Я вважаю, що тут щось цікаво, це вилучення циклів, викриває кожну залежність даних і надмірне підвираз, і надає компілятору максимальну можливість переупорядкувати інструкції, щоб вони добре відповідали фактичному обладнання (наприклад, кількість одиниць виконання, небезпеки / стійла, так на).

Якщо вам трапляється знати всі матриці (або навіть лише декілька з них), ви можете покращити пропускну здатність, зателефонувавши до ідентифікаторів / функцій SIMD (SSE / AVX) замість скалярного коду. Тут ви будете використовувати ганебний паралелізм у всіх примірниках, а не гнатися за будь-яким паралелізмом в одному екземплярі. Наприклад, ви можете одночасно виконати 4 LU з подвійною точністю, використовуючи внутрішні елементи AVX256, упакувавши 4 матриці "поперек" реєстру і виконуючи однакові операції ** на всіх.

** Звідси зосередження уваги на нецільовому ЛУ. Повороти псують такий підхід двома способами. По-перше, він вводить гілки за рахунок вибору стрижня, тобто ваші залежності даних не такі добре відомі. По-друге, це означає, що різні "слоти" SIMD повинні були робити різні речі, тому що екземпляр A може згорнутись інакше, ніж екземпляр B. Отже, якщо ви будете проводити будь-що з цього, я б запропонував статично поворотити ваші матриці до обчислення (перестановка найбільшого запису кожного стовпця до діагоналі).


Оскільки матриць настільки малий, можливо, їх можна перетворити, якщо вони попередньо розширені. Навіть не попередньо обертаючи матриці. Все, що нам потрібно, це те, що записи знаходяться в межах 2-3 порядків один від одного.
rfabbri

2

Ваше запитання призводить до двох різних міркувань.

Спочатку потрібно підібрати правильний алгоритм. Отже, слід враховувати питання, чи мають матриці якусь структуру. Наприклад, коли матриці симетричні, розклад Холеського є більш ефективним, ніж LU. Коли вам потрібна лише обмежена кількість точності, ітераційний метод може бути швидшим.

По-друге, потрібно ефективно реалізувати алгоритм. Для цього вам потрібно знати вузьке місце вашого алгоритму. Чи пов'язана ваша реалізація швидкістю передачі пам'яті або швидкістю обчислення. Так як ви вважаєте лише10×10матриць, ваша матриця повинна повністю вміститися в кеш процесора. Таким чином, вам слід використовувати модулі SIMD (SSE, AVX тощо) та ядра вашого процесора, щоб зробити якомога більше обчислень за цикл.

Загалом, відповідь на ваше питання сильно залежить від обладнання та матриць, які ви розглядаєте. Напевно, немає однозначної відповіді, і вам доведеться випробувати кілька речей, щоб знайти оптимальний метод.


Поки Eigen вже сильно оптимізує, використовує SEE, AVX тощо, і я спробував ітераційні методи в попередньому тесті, і вони не допомогли. Я спробував Intel MKL, але не кращий за Eigen з оптимізованими прапорцями GCC. На даний момент я намагаюся розробити щось краще і простіше, ніж Ейген, і робити більш детальні тести ітераційними методами.
rfabbri

1

Я б спробував блокадно інверсію.

https://en.wikipedia.org/wiki/Invertible_matrix#Blockwise_inversion

Eigen використовує оптимізовану процедуру для обчислення зворотної матриці 4x4, що, мабуть, найкраще, що ви збираєтеся отримати. Спробуйте використовувати це якомога більше.

http://www.eigen.tuxfamily.org/dox/Inverse__SSE_8h_source.html

Зліва вгорі: 8х8. Праворуч вгорі: 8х2. Знизу ліворуч: 2х8. Праворуч знизу: 2х2. Інвертуйте 8x8 за допомогою оптимізованого коду інверсії 4x4. Решта - матричні вироби.

EDIT: Використання блоків 6x6, 6x4, 4x6 та 4x4 виявилося трохи швидше, ніж те, що я описав вище.

using namespace Eigen;

template<typename Scalar, int tl_size, int br_size>
Matrix<Scalar, tl_size + br_size, tl_size + br_size> blockwise_inversion(const Matrix<Scalar, tl_size, tl_size>& A, const Matrix<Scalar, tl_size, br_size>& B, const Matrix<Scalar, br_size, tl_size>& C, const Matrix<Scalar, br_size, br_size>& D)
{
    Matrix<Scalar, tl_size + br_size, tl_size + br_size> result;

    Matrix<Scalar, tl_size, tl_size> A_inv = A.inverse().eval();
    Matrix<Scalar, br_size, br_size> DCAB_inv = (D - C * A_inv * B).inverse();

    result.topLeftCorner<tl_size, tl_size>() = A_inv + A_inv * B * DCAB_inv * C * A_inv;
    result.topRightCorner<tl_size, br_size>() = -A_inv * B * DCAB_inv;
    result.bottomLeftCorner<br_size, tl_size>() = -DCAB_inv * C * A_inv;
    result.bottomRightCorner<br_size, br_size>() = DCAB_inv;

    return result;
}

template<typename Scalar, int tl_size, int br_size>
Matrix<Scalar, tl_size + br_size, tl_size + br_size> my_inverse(const Matrix<Scalar, tl_size + br_size, tl_size + br_size>& mat)
{
    const Matrix<Scalar, tl_size, tl_size>& A = mat.topLeftCorner<tl_size, tl_size>();
    const Matrix<Scalar, tl_size, br_size>& B = mat.topRightCorner<tl_size, br_size>();
    const Matrix<Scalar, br_size, tl_size>& C = mat.bottomLeftCorner<br_size, tl_size>();
    const Matrix<Scalar, br_size, br_size>& D = mat.bottomRightCorner<br_size, br_size>();

    return blockwise_inversion<Scalar,tl_size,br_size>(A, B, C, D);
}

template<typename Scalar>
Matrix<Scalar, 10, 10> invert_10_blockwise_8_2(const Matrix<Scalar, 10, 10>& input)
{
    Matrix<Scalar, 10, 10> result;

    const Matrix<Scalar, 8, 8>& A = input.topLeftCorner<8, 8>();
    const Matrix<Scalar, 8, 2>& B = input.topRightCorner<8, 2>();
    const Matrix<Scalar, 2, 8>& C = input.bottomLeftCorner<2, 8>();
    const Matrix<Scalar, 2, 2>& D = input.bottomRightCorner<2, 2>();

    Matrix<Scalar, 8, 8> A_inv = my_inverse<Scalar, 4, 4>(A);
    Matrix<Scalar, 2, 2> DCAB_inv = (D - C * A_inv * B).inverse();

    result.topLeftCorner<8, 8>() = A_inv + A_inv * B * DCAB_inv * C * A_inv;
    result.topRightCorner<8, 2>() = -A_inv * B * DCAB_inv;
    result.bottomLeftCorner<2, 8>() = -DCAB_inv * C * A_inv;
    result.bottomRightCorner<2, 2>() = DCAB_inv;

    return result;
}

template<typename Scalar>
Matrix<Scalar, 10, 10> invert_10_blockwise_6_4(const Matrix<Scalar, 10, 10>& input)
{
    Matrix<Scalar, 10, 10> result;

    const Matrix<Scalar, 6, 6>& A = input.topLeftCorner<6, 6>();
    const Matrix<Scalar, 6, 4>& B = input.topRightCorner<6, 4>();
    const Matrix<Scalar, 4, 6>& C = input.bottomLeftCorner<4, 6>();
    const Matrix<Scalar, 4, 4>& D = input.bottomRightCorner<4, 4>();

    Matrix<Scalar, 6, 6> A_inv = my_inverse<Scalar, 4, 2>(A);
    Matrix<Scalar, 4, 4> DCAB_inv = (D - C * A_inv * B).inverse().eval();

    result.topLeftCorner<6, 6>() = A_inv + A_inv * B * DCAB_inv * C * A_inv;
    result.topRightCorner<6, 4>() = -A_inv * B * DCAB_inv;
    result.bottomLeftCorner<4, 6>() = -DCAB_inv * C * A_inv;
    result.bottomRightCorner<4, 4>() = DCAB_inv;

    return result;
}

Ось результати пробігу однієї лавки з використанням мільйона Eigen::Matrix<double,10,10>::Random()матриць та Eigen::Matrix<double,10,1>::Random()векторів. У всіх моїх тестах моя інверсія завжди швидша. Моя розв'язка включає в себе обчислення зворотного, а потім множення його на вектор. Іноді це швидше, ніж Ейген, іноді - ні. Моя методика розмітки на лавці може бути помилковою (не вимкнено турбо-підвищення тощо). Також випадкові функції Ейгена можуть не відображати реальних даних.

  • Часткове зворотне часткове враження: 3036 мілісекунд
  • Мій зворотній з 8x8 верхнього блоку: 1638 мілісекунд
  • Мій зворотній з 6x6 верхнього блоку: 1234 мілісекунди
  • Часткове вирішення власного відкидання: 1791 мілісекунд
  • Моє рішення з верхнім блоком 8x8: 1739 мілісекунд
  • Моє рішення з 6x6 верхнього блоку: 1286 мілісекунд

Мені дуже цікаво дізнатись, чи може хтось додатково оптимізувати це, оскільки у мене є додаток з кінцевими елементами, який інвертує матриці 10х10 gazillion (і так, мені потрібні індивідуальні коефіцієнти зворотного, тому безпосередньо рішення лінійної системи не завжди є варіантом) .

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.