Треки
/
x86-64 Assembly
x86-64 Assembly
/
Вправи
/
Виробнича лінія
Виробнича лінія

Виробнича лінія

Навчальна вправа

Вступ

SIMD

Single instruction, multiple data (SIMD) - це форма паралелізму на рівні апаратного забезпечення. Процесор виконує ту саму обчислювальну операцію над кількома елементами одночасно.

Розглянемо, наприклад, поелементне додавання двох масивів із чотирьох 32-бітових чисел з плаваючою комою. Скалярна реалізація видала б чотири окремі інструкції addss, по одній на елемент. Реалізація з SIMD використовує одну інструкцію addps, щоб виконати всі чотири додавання паралельно. Це дає вчетверо більше роботи на одну інструкцію.

В асемблері x86-64 SIMD-інструкції зазвичай виконуються з використанням регістрів xmm. Частина цих інструкцій працює з цілими числами. Інші працюють з числами з плаваючою комою.

Такі цілі числа або числа з плаваючою комою називаються упакованими. Кожне з них займає місце, яке називається смугою, у межах одного регістру. SIMD-інструкції зазвичай паралельно опрацьовують значення на однаковій позиції смуги в двох операндах-джерелах. Результат кожної операції зберігається у смузі на тій самій позиції в операнді призначення.

Смуга не є жорстким обмеженням. Це інтерпретація, яку кожна інструкція дає послідовності байтів. 128-бітовий операнд містить 16 байтів. Залежно від інструкції ці байти можна інтерпретувати як 16 1-байтових смуг, 8 2-байтових смуг, 4 4-байтових смуги, 2 8-байтових смуги або навіть одну 16-байтову смугу.

Наприклад, інструкція addps додає упаковані значення з плаваючою комою одинарної точності (4 байти). Для цієї інструкції кожен 4-байтовий фрагмент операнда є окремою смугою. У 128-бітовому (16-байтовому) операнді:

операнд 4-та смуга 3-тя смуга 2-га смуга 1-ша смуга
джерело 1 1.5 2.1 6.2 3.4
+ + + + +
джерело 2 0.5 8.26 -13.4 22.9
= = = = =
результат 2.0 10.36 -7.2 26.3

З іншого боку, у випадку mulpd 128-бітовий операнд інтерпретувався б як такий, що має 2 смуги. Це тому, що ця інструкція виконує упаковане множення значень подвійної точності (8 байтів):

операнд 2-га смуга 1-ша смуга
джерело 1 4.2 2.1
* * *
джерело 2 1.0 92.4
= = =
результат 4.2 194.04

У цій концепції ми зосередимося на невеликій добірці SIMD-інструкцій. Вони працюють з упакованими значеннями з плаваючою комою, використовуючи регістри xmm.

SIMD-інструкції для чисел з плаваючою комою

Як правило, SIMD-інструкції, що працюють зі значеннями з плаваючою комою, мають двороперандну форму. Їхня назва має певну структуру:

  1. Назва операції, яка виконується, така сама, як у її не-SIMD (її ще називають скалярною) відповідника (наприклад, add, mul тощо).
  2. Суфікс p, який позначає, що інструкція працює з упакованими даними, на тій самій позиції, де раніше стояв s (для скалярних даних).
  3. Суфікс, який позначає розмір кожної смуги, з тією самою семантикою, що й у не-SIMD відповідника (s для одинарної точності та d для подвійної).

Отже, упаковану назву отримують зі скалярної, замінивши s (scalar) у середині мнемоніки на p (packed). Назва операції та суфікс розміру залишаються без змін:

скалярна упакована опис
addss addps додавання, одинарна точність (32-бітові смуги)
addsd addpd додавання, подвійна точність (64-бітові смуги)
mulss mulps множення, одинарна точність
mulsd mulpd множення, подвійна точність
Арифметика

Арифметичні SIMD-інструкції підпорядковуються наведеному вище правилу. Кілька прикладів:

addps xmm0, xmm1 ; xmm0 = xmm0 + xmm1 (each 32-bit float at the same position)
subpd xmm2, xmm3 ; xmm2 = xmm2 - xmm3 (each 64-bit float at the same position)
mulps xmm4, xmm5 ; xmm4 = xmm4 * xmm5 (each 32-bit float at the same position)
divpd xmm6, xmm7 ; xmm6 = xmm6 / xmm7 (each 64-bit float at the same position)
Перетворення між точністями

Існують також SIMD-інструкції для перетворення між упакованими значеннями з плаваючою комою одинарної та подвійної точності. Вони підпорядковуються тому самому правилу найменування, де кожен s для скалярних даних замінено на p для упакованих:

скалярна упакована опис
cvtss2sd cvtps2pd перетворення з одинарної точності в подвійну
cvtsd2ss cvtpd2ps перетворення з подвійної точності в одинарну

Ці інструкції ілюструють раніше сказане: смуга є інтерпретацією, а не жорстким обмеженням. Операнди джерела й призначення мають різні розміри смуг, тому й різну кількість смуг.

Для cvtps2pd джерело (ps) має 4 смуги одинарної точності. Призначення (pd) має 2 смуги подвійної точності. Зчитуються лише 2 молодші смуги джерела. 2 старші ігноруються:

операнд 4-та смуга 3-тя смуга 2-га смуга 1-ша смуга
джерело як ps ігнорується ігнорується 2.5 1.5
призначення як pd 2.5 1.5

Для cvtpd2ps джерело (pd) має 2 смуги подвійної точності. Призначення (ps) має 4 смуги одинарної точності. Обидві смуги джерела перетворюються й записуються в 2 молодші смуги призначення. 2 старші смуги призначення заповнюються нулями:

операнд 4-та смуга 3-тя смуга 2-га смуга 1-ша смуга
джерело як pd 4.0 3.0
призначення як ps 0 0 4.0 3.0

Кілька прикладів:

cvtps2pd xmm0, xmm1 ; xmm0 = xmm1 as 2 doubles (low 2 lanes of xmm1 are read)
cvtpd2ps xmm0, xmm1 ; xmm0 = xmm1 as 2 floats (in low 2 lanes; upper 2 zeroed)
Операнди в памʼяті

Усі наведені вище SIMD-інструкції показано з операндами виду «регістр-регістр». Вони також можуть приймати операнд у памʼяті як джерело (але не як призначення).

Кількість байтів, зчитаних з памʼяті, визначає інструкція. Наприклад, addps читає 16 байтів (4 числа з плаваючою комою одинарної точності). Натомість cvtps2pd читає лише 8 байтів (2 числа з плаваючою комою одинарної точності), бо це все, що йому потрібно на вході.

addps    xmm0, [rsi]      ; xmm0 = xmm0 + 4 floats at [rsi]
mulpd    xmm1, [rsi + 16] ; xmm1 = xmm1 * 2 doubles at [rsi + 16]
cvtps2pd xmm2, [rsi + 8]  ; xmm2 = (2 floats at [rsi + 8]) converted to 2 doubles

Коли операнд у памʼяті має ширину 16 байтів, він повинен бути вирівняний на 16 байтів. Вирівняною називається ділянка памʼяті, адреса якої кратна розміру операції (у цьому випадку 16 байтам).

Щоб оголосити вирівняну памʼять, можна використати директиву align у секції .data і .rodata або alignb у секції .bss:

section .rodata
align 16
mem1: dd 4.0, 3.5, 2.0, -1.0

section .data
align 16
mem2: dq 6.0, 2.1

section .bss
alignb 16
mem3: resb 16

section .text
fn:
    movaps xmm0, oword [rel mem1] ; loads 4 packed 32-bit floats in xmm0
    movapd xmm1, oword [rel mem2] ; loads 2 packed 64-bit floats in xmm1
    movaps oword [rel mem3], xmm0 ; stores 4 packed 32-bit floats in mem3
    ret

Операнди в памʼяті, менші за 16 байтів, як-от 8-байтове джерело cvtps2pd, не мають вимог щодо вирівнювання.

Завантаження та збереження значень

Є чотири інструкції, які копіюють 128 бітів упакованих значень з плаваючою комою. Вони працюють між двома регістрами xmm або між одним регістром xmm і ділянкою памʼяті:

інструкція опис
movaps копіює упаковані дані одинарної точності з вирівняної ділянки памʼяті або в неї
movups копіює упаковані дані одинарної точності з невирівняної ділянки памʼяті або в неї
movapd копіює упаковані дані подвійної точності з вирівняної ділянки памʼяті або в неї
movupd копіює упаковані дані подвійної точності з невирівняної ділянки памʼяті або в неї

Усі ці інструкції можна використовувати для копіювання значень між двома регістрами xmm.

Якщо операція залучає ділянку памʼяті, movaps і movapd вимагають, щоб вона була вирівняною. movaps і movapd спричинять помилку, якщо отримають невирівняну адресу. Вони також слугують перевіркою того, що розташування наших даних саме таке, як ми думаємо.

З іншого боку, movups і movupd можна застосовувати з будь-якою ділянкою памʼяті, вирівняною чи ні.

У NASM 16-байтове значення позначається префіксом розміру oword.

Розширення SIMD

SIMD уже десятиліттями є обʼєктом активного розвитку в екосистемі x86-64. Кожне розширення архітектури приносить нові інструкції або нові способи використання старих.

Зокрема, розширення AVX і AVX2 вносять кілька важливих змін.

По-перше, підтримку 256-бітових SIMD-регістрів, які називаються ymm. Вони розширюють відповідні регістри xmm, так само як rax містить eax.

По-друге, вони підтримують триоперандні варіанти більшості інструкцій, що працюють з регістрами xmm (і ymm). Це стосується не лише SIMD-інструкцій, а й тих, що працюють зі скалярними значеннями з плаваючою комою. Ці варіанти використовують ту саму нотацію, що й відповідники з SSE, але з доданою попереду v:

vaddsd xmm0, xmm1, xmm2   ; xmm0 = xmm1 + xmm2 (scalar sum of 64-bit floats)
vmulps xmm1, xmm3, xmm4   ; xmm1 = xmm3 * xmm4 (packed multiplication of 32-bit floats)
vmovupd ymm5, yword [mem] ; this copies 32 bytes from the location at mem to ymm5

По-третє, вимоги до вирівнювання операндів SIMD у памʼяті помʼякшено. Лише явно вирівняні переміщення, як-от vmovaps, вимагають такого вирівнювання.

У цьому треку ми обмежилися інструкціями та регістрами з попередніх розширень SSE. Цього достатньо, щоб викласти основи, і це забезпечує сумісність із широким колом процесорів.

Однак засіб запуску тестів повністю підтримує AVX і AVX2 і навіть новіші розширення зі складу AVX-512. Ми вільні використовувати їх.

Note

Добре бути послідовними у використанні варіантів SIMD. Намагаймося не змішувати варіанти SSE і AVX для тих самих інструкцій.

Якщо наш код поєднує ці варіанти, добре використати vzeroupper після завершення роботи з AVX і перед переходом до SSE. Це розриває ланцюжки залежностей і дає змогу уникнути дорогої затримки, коли процесор перемикається між станами AVX і SSE.

Вказівки

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

Note

Обчислення в цій вправі слід виконувати за допомогою SIMD-інструкцій, а не скалярних операцій.

1. Поєднати випуск із двох ліній

У цеху працюють дві паралельні складальні лінії. Наприкінці кожної зміни обидві лінії повідомляють свій випуск для 4 контейнерів деталей як числа одинарної точності. Панель керування майстра показує сумарний випуск для кожного контейнера, тож два звіти додаються контейнер за контейнером. Усі три буфери керуються системою звітності й завжди вирівняні на 16 байтів.

Визначте функцію sum_yields, яка приймає випуск для кожного контейнера з line_a і line_b й записує суму за контейнерами в result. Аргументи, у порядку:

  1. line_a: адреса памʼяті, вирівняна на 16 байтів, що містить 4 32-бітові числа з плаваючою комою
  2. line_b: адреса памʼяті, вирівняна на 16 байтів, що містить 4 32-бітові числа з плаваючою комою
  3. result: адреса памʼяті, вирівняна на 16 байтів, куди функція запише 4 32-бітові числа з плаваючою комою

Ця функція не має поверненого значення; натомість результат зберігається в переданому буфері (result):

line_a = {10.0, 20.0, 30.0, 40.0};
line_b = { 5.0, 15.0, 25.0, 35.0};
sum_yields(line_a, line_b, result);
// => result == {15.0, 35.0, 55.0, 75.0};

2. Обчислити масштабоване відхилення

Між операціями станція відбору проб вимірює оброблені деталі й порівнює їх із цільовими розмірами. Виміряні значення надходять потоком із прецизійного вимірювача в буфер, який завжди вирівняний на 16 байтів. Цільові значення надходять із пакета робочого замовлення, для якого вирівнювання не гарантовано. Відхилення множиться на коефіцієнт чутливості для кожної осі, перш ніж його передадуть у наступний перевіряльник допусків. На цьому етапі наступний перевіряльник читає відхилення безпосередньо з SIMD-регістра, а не з памʼяті.

Визначте функцію scaled_deviation, яка приймає measured, target і sensitivity й записує масштабоване відхилення в result. Аргументи, у порядку:

  1. measured: адреса памʼяті, вирівняна на 16 байтів, що містить 2 64-бітові числа з плаваючою комою
  2. target: адреса памʼяті, що містить 2 64-бітові числа з плаваючою комою (вирівнювання не гарантоване)
  3. sensitivity: адреса памʼяті, що містить 2 64-бітові числа з плаваючою комою (вирівнювання не гарантоване)
  4. result: адреса памʼяті для буфера, куди функція запише 2 64-бітові числа з плаваючою комою (вирівнювання не гарантоване)

Масштабоване відхилення обчислюється як (measured - target) * sensitivity для кожної доріжки.

Ця функція не має поверненого значення; натомість результат зберігається в переданому буфері (result) як пара 64-бітових чисел з плаваючою комою.

measured    = {10.5, 20.5};
target      = {10.0, 20.0};
sensitivity = { 2.0,  4.0};
scaled_deviation(measured, target, sensitivity, result);
// => result == {1.0, 2.0};

3. Відкалібрувати партію

Перш ніж деталі потраплять у відсік фінального контролю, виміри розмірів із партії з 4 деталей перераховуються в подвійну точність і виправляються з урахуванням відомого температурного дрейфу.

4 первинні виміри надходять як числа одинарної точності. Для кожного рядка з 2 вимірів калібрування обчислює reference / (raw - offset), а потім множить цей результат на сталий коефіцієнт перетворення 0.5.

Зауважте, що reference та offset однакові для обох рядків партії. Коефіцієнт перетворення однаковий за кожного калібрування, тож замість того щоб приймати його як аргумент, його слід оголосити як упаковану 16-байтову константу в памʼяті.

Визначте функцію calibrate_batch, яка приймає партію raw, reference, offset і буфер result й записує відкалібровані значення в result. Аргументи, у порядку:

  1. raw: адреса памʼяті, що містить 4 32-бітові числа з плаваючою комою (вирівнювання не гарантоване)
  2. reference: адреса памʼяті, вирівняна на 16 байтів, що містить 2 64-бітові числа з плаваючою комою
  3. offset: адреса памʼяті, вирівняна на 16 байтів, що містить 2 64-бітові числа з плаваючою комою
  4. result: адреса памʼяті, вирівняна на 16 байтів, куди функція запише 4 64-бітові числа з плаваючою комою (32 байти загалом)

Для кожного з двох рядків партії функція має:

  1. Перетворити 2 первинні значення одинарної точності рядка на подвійну точність.
  2. Відняти offset від перетвореної пари.
  3. Поділити reference на результат.
  4. Поелементно помножити кожне 64-бітове число з плаваючою комою на сталий коефіцієнт перетворення 0.5.
  5. Зберегти 2 64-бітові числа з плаваючою комою у відповідні 16 байтів result.

Ця функція не має поверненого значення; натомість результат зберігається в переданому буфері (result):

raw       = {10.0, 20.0, 25.0, 50.0};
reference = {100.0, 100.0};
offset    = {  5.0,  10.0};
calibrate_batch(raw, reference, offset, result);
// => result == {10.0, 5.0, 2.5, 1.25};

4. Нормалізувати оцінки вздовж лінії

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

Виробничий масштаб однаковий для всього запуску й передається в адресі памʼяті, що містить два 64-бітові числа з плаваючою комою. Оцінки оновлюються на місці: той самий буфер, який містив первинні оцінки, тепер містить нормалізовані оцінки.

Визначте функцію normalize_scores, яка приймає масив scores, масив gains, scale і лічильник n й нормалізує оцінки на місці. Аргументи, у порядку:

  1. scores: адреса памʼяті, вирівняна на 16 байтів, що містить n 64-бітових чисел з плаваючою комою (змінюється на місці)
  2. gains: адреса памʼяті, вирівняна на 16 байтів, що містить n 64-бітових чисел з плаваючою комою
  3. scale: адреса памʼяті, вирівняна на 16 байтів, що містить 2 64-бітові числа з плаваючою комою
  4. n: додатне парне 64-бітове ціле число (мінімум 2), кількість оцінок для нормалізації

Дві доріжки масштабу застосовуються до двох елементів кожної пари. Перша доріжка масштабує перший елемент кожної пари (доріжки на парних позиціях), друга доріжка масштабує другий (доріжки на непарних позиціях). Кожна оцінка множиться на відповідний коефіцієнт підсилення, а потім ділиться на відповідну доріжку масштабу: score * gain / scale_lane.

Ця функція не має поверненого значення; натомість результат зберігається у вхідному буфері (scores), на тій самій позиції відповідної оцінки score, яку було нормалізовано:

n      = 4
scores = {10.0, 20.0, 30.0, 40.0};
gains  = { 2.0,  1.5,  1.0,  0.5};
scale  = { 2.0,  4.0};
normalize_scores(scores, gains, scale, n);
// => scores == {10.0, 7.5, 15.0, 5.0};
Редагувати через GitHub Посилання відкривається в новому вікні або вкладці
x86-64 Assembly Exercism

Час розпочати Виробнича лінія?

Зареєструйтеся на Exercism, щоб вивчати й опановувати x86-64 Assembly, а також 22 концепції130 вправ та справжнє наставництво від людей, і все це безкоштовно.