**단일 명령어, 다중 데이터(SIMD)**는 하드웨어 수준 병렬 처리의 한 형태예요. CPU가 여러 원소에 대해 같은 연산을 동시에 수행하죠.
예를 들어, 32비트 부동소수점 네 개로 이루어진 배열 두 개를 원소별로 더하는 경우를 생각해 봐요.
스칼라 구현에서는 원소마다 addss 명령어를 하나씩, 모두 네 번 실행해요.
SIMD 구현은 addps 하나로 네 번의 덧셈을 모두 병렬로 처리해요.
그러면 명령어 하나로 네 배의 작업을 처리하는 셈이에요.
x86-64 어셈블리에서는 보통 xmm 레지스터로 SIMD 명령어를 수행해요.
이 명령어 중에는 정수를 다루는 것도 있고, 부동소수점 수를 다루는 것도 있어요.
이런 정수나 부동소수점 수를 패킹된 값이라고 해요. 각 값은 같은 레지스터 안에서 레인이라고 하는 공간을 하나씩 차지해요. 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 명령어는 피연산자 두 개를 받는 형태예요. 이름은 다음과 같은 구조를 따라요.
add, mul 등).p가, 이전에 s(스칼라 데이터)가 있던 같은 자리에 들어가요.s, 배정밀도는 d).따라서 패킹된 이름은 스칼라 이름에서 니모닉 가운데의 s(스칼라)를 p(패킹)로 바꾼 것이에요.
연산 이름과 크기 접미사는 그대로예요.
| 스칼라 | 패킹 | 의미 |
|---|---|---|
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개 레인에 쓰여요.
목적지의 위쪽 2개 레인은 0으로 설정돼요.
| 피연산자 | 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바이트).
정렬된 메모리를 선언하려면 .data와 .rodata 섹션에서는 align 지시자를, .bss 섹션에서는 alignb를 사용하면 돼요.
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
cvtps2pd의 8바이트 소스처럼 16바이트보다 작은 메모리 피연산자에는 정렬 요구 사항이 없어요.
패킹된 부동소수점 값 128비트를 복사하는 명령어가 네 개 있어요.
이 명령어들은 두 xmm 레지스터 사이에서, 또는 xmm 레지스터 하나와 메모리 위치 사이에서 동작해요.
| 명령어 | 설명 |
|---|---|
movaps |
패킹된 단정밀도 데이터를 정렬된 메모리 위치에서 또는 그곳으로 복사해요 |
movups |
패킹된 단정밀도 데이터를 정렬되지 않은 메모리 위치에서 또는 그곳으로 복사해요 |
movapd |
패킹된 배정밀도 데이터를 정렬된 메모리 위치에서 또는 그곳으로 복사해요 |
movupd |
패킹된 배정밀도 데이터를 정렬되지 않은 메모리 위치에서 또는 그곳으로 복사해요 |
이 명령어들은 모두 두 xmm 레지스터 사이에서 값을 복사하는 데 쓸 수 있어요.
연산에 메모리 위치가 포함되면 movaps와 movapd는 그 위치가 _정렬_되어 있기를 요구해요.
movaps와 movapd에 정렬되지 않은 주소를 주면 폴트가 발생해요.
이 명령어들은 데이터 배치가 생각한 대로 되어 있는지 확인해 주는 안전 점검 역할도 해요.
반면 movups와 movupd는 정렬 여부와 상관없이 어떤 메모리 위치에도 쓸 수 있어요.
NASM에서 16바이트 값의 크기 접두사는 oword예요.
SIMD는 수십 년 동안 x86-64 생태계에서 활발히 개발되어 온 분야예요. 아키텍처가 확장될 때마다 새로운 명령어가 추가되거나 기존 명령어에 새로운 용도가 생겨요.
특히 AVX와 AVX2 확장은 몇 가지 중요한 변화를 가져왔어요.
첫째, ymm이라고 하는 256비트 SIMD 레지스터를 지원해요.
rax가 eax를 포함하듯이, ymm은 대응하는 xmm 레지스터를 확장한 것이죠.
둘째, xmm(그리고 ymm) 레지스터를 다루는 대부분의 명령어에 대해 피연산자 3개짜리 변형을 지원해요.
여기에는 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 확장까지의 명령어와 레지스터로 범위를 제한했어요. 이 정도면 기초를 가르치기에 충분하고, 다양한 CPU와의 호환성도 보장해 줘요.
하지만 테스트 러너는 AVX와 AVX2, 그리고 AVX-512에 속하는 최신 확장까지 완전히 지원해요. 원한다면 자유롭게 사용해도 돼요.
SIMD 변형을 사용할 때는 일관성을 유지하는 게 좋아요. 같은 명령어에 SSE와 AVX 변형을 섞지 않도록 해요.
코드베이스에서 이런 변형을 섞는다면, AVX 작업을 마친 뒤 SSE를 쓰기 전에 vzeroupper를 사용하는 게 좋아요.
이렇게 하면 의존성 체인을 끊고, CPU가 AVX와 SSE 상태 사이를 전환할 때 발생하는 값비싼 지연을 피할 수 있어요.
자동차 부품 공장 현장의 데이터 시스템을 위한 코드를 작성해요. 엔진과 섀시 부품은 일련의 가공 스테이션을 거쳐 이동해요. 각 스테이션은 측정값을 만들어 내는데, 다음 단계가 실행되기 전에 이 값들을 결합하거나 비교하거나 보정하거나 정규화해야 해요. 여기서 구현할 함수는 스테이션마다 하나씩이에요.
이 연습 문제의 계산은 스칼라 연산이 아니라 SIMD 명령어를 사용해서 수행해야 해요.
공장 현장에서는 나란히 돌아가는 두 개의 조립 라인이 있어요. 각 교대 근무가 끝날 때마다 두 라인 모두 부품 4개 구간의 생산량을 단정밀도 숫자로 보고해요. 관리자 대시보드는 구간별로 합쳐진 생산량을 보여주기 때문에, 두 보고 값은 구간별로 더해져요. 세 버퍼는 모두 보고 시스템이 관리하며 항상 16바이트로 정렬되어 있어요.
line_a와 line_b의 구간별 생산량이 주어졌을 때, 구간별 합을 result에 쓰는 sum_yields 함수를 정의해요.
인자는 순서대로 다음과 같아요:
line_a: 32비트 부동소수점 숫자 4개를 담고 있는 16바이트로 정렬된 메모리 주소line_b: 32비트 부동소수점 숫자 4개를 담고 있는 16바이트로 정렬된 메모리 주소result: 함수가 32비트 부동소수점 숫자 4개를 쓸 16바이트로 정렬된 메모리 주소이 함수는 반환 값이 없고, 대신 결과는 전달받은 버퍼(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};
작업 사이사이에 샘플링 스테이션이 가공된 부품을 측정하고 목표 치수와 비교해요. 측정값은 정밀 게이지에서 항상 16바이트로 정렬된 버퍼로 흘러 들어와요. 목표 값은 작업 지시 패킷에서 오는데, 이 패킷은 정렬이 보장되지 않아요. 편차는 하위 공정의 공차 검사기로 전달되기 전에 축별 민감도 계수와 곱해져요. 이 단계에서 하위 공정 검사기는 메모리가 아니라 SIMD 레지스터에서 편차를 직접 읽어요.
measured와 target, 그리고 sensitivity가 주어졌을 때 조정된 편차를 result에 쓰는 scaled_deviation 함수를 정의해요.
인자는 순서대로 다음과 같아요:
measured: 64비트 부동소수점 숫자 2개를 담고 있는 16바이트로 정렬된 메모리 주소target: 64비트 부동소수점 숫자 2개를 담고 있는 메모리 주소(정렬 보장 안 됨)sensitivity: 64비트 부동소수점 숫자 2개를 담고 있는 메모리 주소(정렬 보장 안 됨)result: 함수가 64비트 부동소수점 숫자 2개를 쓸 버퍼의 메모리 주소(정렬 보장 안 됨)조정된 편차는 레인별로 (measured - target) * sensitivity예요.
이 함수는 반환 값이 없고, 대신 결과는 64비트 부동소수점 숫자 한 쌍으로 전달받은 버퍼(result)에 저장돼요.
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};
부품이 최종 검사 구역에 들어가기 전에, 부품 4개로 이루어진 배치의 치수 판독값을 배정밀도로 다시 스케일링하고 알려진 온도 드리프트를 보정해요.
4개의 원시 판독값은 단정밀도 숫자로 들어와요.
판독값 2개로 이루어진 각 행에 대해, 보정은 reference / (raw - offset)을 계산한 다음 이 결과에 상수 변환 계수 0.5를 곱해요.
참고로 reference와 offset은 배치의 두 행 모두에 대해 같아요. 변환 계수는 모든 보정에서 같기 때문에, 인자로 받는 대신 메모리에 16바이트로 압축된 상수로 선언해야 해요.
raw 배치, reference, offset, result 버퍼가 주어졌을 때 보정된 값들을 result에 쓰는 calibrate_batch 함수를 정의해요.
인자는 순서대로 다음과 같아요:
raw: 32비트 부동소수점 숫자 4개를 담고 있는 메모리 주소(정렬 보장 안 됨)reference: 64비트 부동소수점 숫자 2개를 담고 있는 16바이트로 정렬된 메모리 주소offset: 64비트 부동소수점 숫자 2개를 담고 있는 16바이트로 정렬된 메모리 주소result: 함수가 64비트 부동소수점 숫자 4개(총 32바이트)를 쓸 16바이트로 정렬된 메모리 주소이 함수는 배치의 두 행 각각에 대해 다음을 수행해야 해요:
offset을 빼요.reference를 그 결과로 나눠요.0.5를 레인별로 곱해요.result의 해당하는 16바이트에 저장해요.이 함수는 반환 값이 없고, 대신 결과는 전달받은 버퍼(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};
라인 끝에서 모든 부품은 품질 점수를 가지는데, 이 점수는 보고되기 전에 정규화되어야 해요. 각 점수는 부품별 자체 게인과 곱해진 다음, 축별 생산 스케일로 나눠져요. 게인 버퍼는 부품마다 게인을 하나씩 담고 있어요.
생산 스케일은 전체 실행에 대해 같으며, 64비트 부동소수점 숫자 두 개를 담고 있는 메모리 주소로 전달돼요. 점수는 제자리에서 갱신돼요: 원시 점수를 담고 있던 바로 그 버퍼가 이제 정규화된 점수를 담아요.
scores 배열, gains 배열, scale, 그리고 개수 n이 주어졌을 때 점수를 제자리에서 정규화하는 normalize_scores 함수를 정의해요.
인자는 순서대로 다음과 같아요:
scores: n개의 64비트 부동소수점 숫자를 담고 있는 16바이트로 정렬된 메모리 주소(제자리에서 수정됨)gains: n개의 64비트 부동소수점 숫자를 담고 있는 16바이트로 정렬된 메모리 주소scale: 64비트 부동소수점 숫자 2개를 담고 있는 16바이트로 정렬된 메모리 주소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};
Exercism에 가입하고 x86-64 Assembly 트랙을 개념 22개연습 문제 130개, 그리고 실제 사람의 멘토링과 함께 배우고 익혀 보세요. 모두 무료예요.