이전 개념에서 패킹된 부동소수점 값을 소개했어요: 여러 개의 수를 하나의 xmm 레지스터에 담고, 레인별로 병렬로 연산하는 방식이었죠.
같은 xmm 레지스터에는 패킹된 정수 도 담을 수 있어요.
SIMD 부동소수점 모델의 대부분은 패킹된 정수에도 그대로 이어져요:
다만 문법은 조금 달라요:
p
add, mul 등).패킹된 정수에는 네 가지 레인 크기가 있고, 각각 고유한 접미사가 있어요:
| 레인 너비 | 바이트 수 | 128비트 안의 레인 수 | 접미사 |
|---|---|---|---|
| 바이트 | 1 | 16 | b |
| 워드 | 2 | 8 | w |
| 더블워드 | 4 | 4 | d |
| 쿼드워드 | 8 | 2 | q |
예를 들어:
| 명령어 | 의미 |
|---|---|
paddb |
패킹된 add, 8비트 레인(레인 16개) |
paddw |
패킹된 add, 16비트 레인(레인 8개) |
paddd |
패킹된 add, 32비트 레인(레인 4개) |
paddq |
패킹된 add, 64비트 레인(레인 2개) |
일부 명령어는 한 크기의 레인을 입력으로 받고 다른 크기의 레인을 출력으로 내놓아요. 이런 명령어도 일반적인 규칙을 따르지만, 크기 접미사가 두 개 붙어요. 첫 번째는 입력 레인의 크기를, 두 번째는 출력 레인의 크기를 나타내요:
| 명령어 | 의미 |
|---|---|
pmovsxwd |
패킹된 movsx, 16비트 레인에서 32비트 레인으로 |
pmuldq |
패킹된 mul, 32비트 레인에서 64비트 레인으로 |
이름에 정수가 들어간 두 명령어는 xmm 레지스터와 메모리 사이에서 128비트를 복사해요:
| 명령어 | 설명 |
|---|---|
movdqa |
정렬된 위치에서 또는 그 위치로 패킹된 정수를 복사해요 |
movdqu |
정렬되지 않은 위치에서 또는 그 위치로 패킹된 정수를 복사해요 |
movaps와 movups처럼 동작해요: movdqa는 정렬되지 않은 주소에서 폴트를 일으키고, movdqu는 어떤 주소든 받아들여요.
네 명령어 모두 값을 해석하지 않고 128비트를 그대로 복사해요.
이름에 정수가 들어간 두 명령어를 정수 데이터와 함께 쓰는 것은 관례일 뿐, 반드시 그래야 하는 것은 아니에요.
여기서 dq는 double-qword, 즉 128비트(16바이트)를 뜻해요.
덧셈과 뺄셈은 이름 규칙을 따라요:
paddb xmm0, xmm1 ; 16 lanes: each 8-bit, xmm0 += xmm1
paddw xmm2, xmm3 ; 8 lanes: each 16-bit, xmm2 += xmm3
psubd xmm4, xmm5 ; 4 lanes: each 32-bit, xmm4 -= xmm5
psubq xmm6, xmm7 ; 2 lanes: each 64-bit, xmm6 -= xmm7
부호 있는 형태와 부호 없는 형태가 따로 있지는 않아요.
2의 보수에서는 레인을 부호 있는 값으로 읽든 부호 없는 값으로 읽든 덧셈과 뺄셈이 같은 비트를 만들어 내므로, 명령어 하나가 둘 다 처리해요.
해석은 스칼라 add와 sub에서와 마찬가지로 직접 하면 돼요.
이 명령어들은 스칼라 대응 명령어와 마찬가지로 오버플로가 나면 값을 순환시켜요.
8비트 레인은 값을 256으로 나눈 나머지로 담기 때문에, paddb로 200 + 100을 계산하면 들어가지 못하는 비트를 버리고 300 - 256 = 44가 돼요.
이때 넘친 비트가 옆 레인으로 넘어가지는 않아요. 각 레인은 같은 레지스터를 공유하지만 서로 독립적으로 연산돼요.
정수 SIMD에는 부동소수점 SIMD에 없는 연산이 하나 더 있어요: 값을 순환시키는 대신 범위 안으로 제한 하는 포화 덧셈과 뺄셈이에요. 레인의 범위를 넘는 결과는 그 레인이 담을 수 있는 가장 큰 값이 되고, 범위보다 작은 결과는 가장 작은 값이 돼요.
포화 형태는 크기 접미사 앞에 s(부호 있음) 또는 us(부호 없음)를 넣어요:
| 명령어 | 의미 |
|---|---|
paddsb |
add, 포화, 부호 있는 8비트 레인 |
paddusb |
add, 포화, 부호 없는 8비트 레인 |
psubsw |
sub, 포화, 부호 있는 16비트 레인 |
psubusw |
sub, 포화, 부호 없는 16비트 레인 |
제한 범위는 해당 크기와 부호 여부에 맞는 정수가 표현할 수 있는 전체 범위예요. 바이트라면:
[0, 255]로 제한돼요: paddusb로 200 + 100을 계산하면 255가 되고, psubusb로 5 - 10을 계산하면 0이 돼요.[-128, 127]로 제한돼요: paddsb로 100 + 50을 계산하면 127이 돼요.포화는 레인이 픽셀 채널이나 오디오 샘플처럼 범위가 정해진 값을 담을 때 중요해요. 값이 순환하면 너무 밝은 픽셀이 어두워지지만, 제한하면 최대 밝기를 그대로 유지하는데, 이게 바로 원하는 결과예요.
포화 덧셈과 뺄셈은 바이트와 워드 레인에만 있고, 더블워드나 쿼드워드에는 없어요.
N비트 값 두 개를 곱하면 2N비트 곱이 나올 수 있지만, 목적지 레인은 N비트 폭밖에 안 돼요. SIMD 연산은 곱의 어느 쪽 절반을 남길지, 즉 하위 N비트인지 상위 N비트인지를 지정해서 이 문제를 풀어요.
16비트 레인에서는 세 개의 명령어를 써요:
| 명령어 | 의미 |
|---|---|
pmullw |
mul, 16비트 레인, 각 곱의 하위 16비트를 남겨요 |
pmulhw |
mul, 16비트 레인, 상위 16비트를 남겨요, 부호 있는 피연산자 |
pmulhuw |
mul, 16비트 레인, 상위 16비트를 남겨요, 부호 없는 피연산자 |
곱의 하위 16비트는 피연산자를 부호 있는 값으로 읽든 부호 없는 값으로 읽든 같기 때문에 pmullw 하나만 있어요.
그런데 상위 16비트는 결과의 부호 여부에 따라 달라져요. 그래서 상위 절반 곱셈에는 부호 있는 곱셈과 부호 없는 곱셈을 위한 형태가 따로 있어요:
pmulhw, 부호 있는 곱셈용.pmulhuw, u가 하나 더 붙은 부호 없는 곱셈용.문법을 눈여겨봐요:
p.mul.h.u.w.워드에 대한 패킹된 곱셈은 위 규칙을 그대로 따라요.
더블워드(32비트) 곱셈도 규칙을 따르지만, 하위 절반을 선택하는 변형만 있어요: pmulld.
상위 비트를 선택하는 더블워드 곱셈 변형은 없어요. 대신 곱셈을 확장 해서 짝수 인덱스 레인(즉, 위치 0과 2의 레인)의 64비트 곱 전체를 저장하는 변형이 있어요:
| 명령어 | 의미 |
|---|---|
pmuludq |
짝수 인덱스의 32비트 원소를 부호 없이 곱해 64비트 곱 두 개를 만들어요 |
pmuldq |
짝수 인덱스의 32비트 원소를 부호 있게 곱해 64비트 곱 두 개를 만들어요 |
문법에서는 dq를 쓰고, 부호 없는 곱셈일 때는 그 앞에 u가 붙을 수 있다는 점을 눈여겨봐요.
이 명령어들은 dword 레인을 입력으로 받아 qword 레인을 출력하기 때문이에요.
패킹된 정수 나눗셈은 없어요. 필요한 코드는 값을 부동소수점으로 변환하고, 나눈 다음, 다시 되돌려요.
movsx와 movzx에 대응하는 패킹된 명령어가 있어요.
이들은 입력과 출력의 레인 크기가 다른 명령어에 대해 앞서 언급한 것과 같은 문법을 따라요:
pmovsxwd xmm0, xmm1 ; 4 words -> 4 dwords, sign-extended
pmovzxbw xmm0, xmm1 ; 8 bytes -> 8 words, zero-extended
레인 수는 더 큰 쪽(출력) 너비가 정한다는 점에 유의해요.
명령어는 원본의 아래쪽에서 그만큼의 레인을 읽어요.
이는 단정밀도와 배정밀도 부동소수점 사이의 패킹된 cvt 명령어에서 이미 본 것과 같은 의미예요.
32비트 부호 있는 정수와 부동소수점 레인 사이를 변환하는 명령어가 있어요.
cvt와 cvtt 명령어의 일반적인 문법을 따르지만, si 대신 패킹된 정수를 dq로 나타내요:
cvtdq2ps xmm0, xmm1 ; convert 32-bit signed integers in xmm1 to 32-bit floats in xmm0
cvtdq2pd xmm2, xmm3 ; convert 32-bit signed integers in xmm3 to 64-bit floats in xmm2
cvtps2dq xmm4, xmm5 ; convert 32-bit floats in xmm5 to 32-bit signed integers in xmm4
패킹된 정수를 나타내는 데 pi를 쓰는 명령어는 레거시 mmx 레지스터에 쓰고, x86-64에서는 사실상 폐기되었어요.
xmm 레지스터를 쓰는 dq 형태를 선호해요.
부동소수점 수와 정수 사이의 스칼라 변환에 했던 이야기가 여기에도 그대로 적용돼요.
부동소수점 수는 MXCSR이라고 하는 특수 레지스터에 따라 반올림되는데, 함수에 들어올 때 이 모드를 가정할 수 없어요.
항상 결과를 잘라내는 cvtt 변형(t가 하나 더 붙어요)도 있어요.
변환하기 전에 패킹된 부동소수점 값을 알려진 상태로 만들어 두는 데 round를 쓸 수도 있어요.
반올림 제어 값은 스칼라 round와 같고, 문법도 패킹된 부동소수점 값에 대해 같아요:
roundps xmm0, xmm1, 1 ; xmm0 = floor(xmm1), packed 32-bit floats
roundpd xmm2, xmm3, 2 ; xmm2 = ceil(xmm3), packed 64-bit floats
녹음 스튜디오에 있는 작은 믹싱 콘솔의 디지털 코어를 만들고 있어요.
소리는 샘플의 스트림으로 들어와요. 각 샘플은 한 순간의 스피커 콘 위치를 나타내는 숫자예요. 콘솔은 샘플 블록 전체를 한 번에 처리하면서 각 샘플에 같은 연산을 적용해요.
다섯 개의 작업이 있고, 각 작업은 메모리에 담긴 샘플 블록을 다뤄요. 피연산자는 메모리 주소를 통해 받고, 결과는 결과 주소를 통해 기록해요.
이 연습 문제의 계산은 스칼라 연산이 아니라 SIMD 명령어를 사용해서 수행해야 해요.
스튜디오에서는 악기를 서로 다른 트랙에 녹음할 때가 있어요. 같은 스피커로 재생하려면 두 트랙을 하나로 더해야 해요.
mix_tracks 함수를 구현해요. 이 함수는 두 트랙을 샘플 단위로 더해요.
각 트랙은 부호 있는 16비트 샘플 8개로 이루어진 블록이에요.
부호 있는 16비트 범위를 넘어서는 합은 값이 반대편으로 돌아가지 않고 클램프되어야 해요.
이 함수는 다음 인자를 이 순서대로 받아요:
result: 믹스된 샘플 8개가 기록될 버퍼의 16바이트 정렬된 메모리 주소예요.track_a: 부호 있는 16비트 샘플 8개가 담긴 첫 번째 트랙의 16바이트 정렬된 메모리 주소예요.track_b: 부호 있는 16비트 샘플 8개가 담긴 두 번째 트랙의 16바이트 정렬된 메모리 주소예요.track_a = {10000, 30000, 20000, -30000, -20000, -100, 100, 0}
track_b = { 5000, 5000, 20000, -5000, -20000, 100, -100, 0}
result = {15000, 32767, 32767, -32768, -32768, 0, 0, 0}
이 함수는 반환 값이 없어요.
마이크 두 개로 동시에 녹음하면, 각 마이크가 다른 쪽 음원의 희미한 사본을 함께 담아요. 이렇게 원치 않게 섞여 들어온 것을 _블리드_라고 해요. 블리드를 측정하고 나면, 트랙을 깨끗하게 하기 위해 그만큼을 다시 빼줘야 해요.
remove_bleed 함수를 구현해요. 이 함수는 트랙 블록에서 블리드 블록을 샘플 단위로 빼요.
각 블록은 부호 있는 16비트 샘플 8개이고, 뺄셈은 포화 연산이어야 해요.
이 함수는 다음 인자를 이 순서대로 받아요:
result: 정리된 샘플 8개가 기록되는 메모리 주소예요 (정렬은 보장되지 않아요).track: 녹음된 트랙의 16바이트 정렬된 메모리 주소로, 부호 있는 16비트 샘플 8개예요.bleed: 제거할 블리드의 메모리 주소로, 부호 있는 16비트 샘플 8개예요 (정렬은 보장되지 않아요).track = {10000, -30000, 5000, 32767, 0, 200, -200, 15000}
bleed = { 2000, 5000, 5000, -5, 0, 300, 300, 5000}
result = { 8000, -32768, 0, 32767, 0, -100, -500, 10000}
이 함수는 반환 값이 없어요.
각 채널 위에는 레벨 미터가 있어요. 밝기로 그 채널이 얼마나 큰 소리인지 알려주는 작은 막대예요. 전면 패널에는 마스터 미터가 하나 있는데, 두 채널을 합친 만큼 밝게 빛나야 해요. 두 미터 블록을 더해서 이 미터를 구동해요.
밝은 미터는 어두운 값으로 돌아가지 않고 최대 밝기를 유지해요. 그래서 합은 포화 연산이에요.
combine_meters 함수를 구현해요. 이 함수는 두 미터 블록을 샘플 단위로 더해요.
이 함수는 다음 인자를 이 순서대로 받아요:
result: 합쳐진 값 16개가 기록될 16바이트 정렬된 메모리 주소예요.meter_a: 패킹된 부호 없는 8비트 값 16개가 담긴 첫 번째 채널 미터 블록의 16바이트 정렬된 메모리 주소예요.meter_b: 패킹된 부호 없는 8비트 값 16개가 담긴 두 번째 채널 미터 블록의 16바이트 정렬된 메모리 주소예요.각 블록은 부호 없는 8비트 값 16개예요. 두 블록을 더하되, 부호 없는 범위로 포화시켜야 해요.
meter_a = {200, 100, 50, 255, 0, 128, 10, 240, 1, 2, 3, 4, 5, 6, 7, 8}
meter_b = {100, 100, 200, 1, 0, 128, 10, 20, 1, 2, 3, 4, 5, 6, 7, 8}
result = {255, 200, 250, 255, 0, 255, 20, 255, 2, 4, 6, 8, 10, 12, 14, 16}
이 함수는 반환 값이 없어요.
노래는 갑자기 끊기지 않고 끝에서 서서히 사라져야 해요. 샘플마다 하나씩 있는 게인 계수 블록이 있고, 이 계수는 갈수록 점점 더 조용한 값으로 줄어들어요.
각 계수는 65536을 기준으로 표현된 분수예요.
예를 들어 샘플의 값이 16384라면, 실제로는 16384 / 65536을 나타내고, 이는 1 / 4예요.
사용할 수 있는 가장 큰 값인 32767은 절반보다 조금 작아요.
65536은 2^16이므로, 샘플에 계수를 곱하고 곱의 _상위 절반_을 취하면 sample * gain / 65536이 나와요.
apply_fade 함수를 구현해요. 이 함수는 각 샘플을 계수로 스케일링해요.
이 함수는 다음 인자를 이 순서대로 받아요:
result: 스케일링된 샘플이 모두 기록될 16바이트 정렬된 메모리 주소예요.track: 부호 있는 16비트 샘플 n개가 담긴 트랙의 16바이트 정렬된 메모리 주소예요.gains: 게인 계수의 16바이트 정렬된 메모리 주소로, 각각 0에서 32767 범위에 있는 부호 있는 16비트 값 n개가 담겨 있어요.n: 샘플의 개수예요. 항상 0이 아니고 8의 배수이며, 64비트 부호 없는 정수예요.track은 부호 있는 16비트 샘플 n개를 담는 배열이고, n은 절대 0이 아니며 항상 8의 배수예요.
각 샘플에 대해 스케일링된 결과는 부호 있는 곱 sample * gain의 상위 16비트예요.
track = {20000, 20000, 20000, 20000, -20000, -20000, -20000, -20000}
gains = {32767, 24576, 16384, 8192, 32767, 24576, 16384, 8192}
result = { 9999, 7500, 5000, 2500, -10000, -7500, -5000, -2500}
상위 절반을 취하는 것은 음의 무한대 방향으로 반올림해요.
그래서 20000 * 32767은 9999로 스케일링되고, -20000 * 32767은 -9999가 아니라 -10000으로 스케일링돼요.
이 함수는 반환 값이 없어요.
드러머가 너무 크게 녹음됐어요. 트랙 전체의 크기를 낮추기 위해 모든 샘플을 상수로 나눠요.
attenuate_track 함수를 구현해요.
이 함수는 다음 인자를 이 순서대로 받아요:
result: 결과가 32비트 부호 있는 정수로 기록될 16바이트 정렬된 메모리 주소예요.samples: 부호 있는 16비트 값 n개인 샘플의 16바이트 정렬된 메모리 주소예요.divisor: 나누는 값의 동일한 복사본 4개가 담긴 16바이트 정렬된 메모리 주소로, 32비트 정수예요.n: 샘플의 개수예요. 항상 0이 아니고 4의 배수이며, 64비트 부호 없는 정수예요.samples는 부호 있는 16비트 샘플 n개를 담는 배열이고, n은 절대 0이 아니며 항상 4의 배수예요.
나누는 값은 같은 32비트 정수를 4개 복사한 형태로 주어져요. 네 개의 레인을 한 번에 채울 수 있도록 미리 복제된 거예요.
정수 SIMD에는 패킹된 나눗셈이 없어서, 작업은 패킹된 부동소수점으로 수행한 뒤 다시 패킹된 32비트 정수로 변환해서 result에 저장해요.
0 방향으로 버림해서 변환하면, 몫 2.3은 2가 되고 -2.3은 -2가 돼요.
samples = {300, 150, 90, -120, 100, 50, -50, 7}
divisor = {3, 3, 3, 3}
result = {100, 50, 30, -40, 33, 16, -16, 2}
이 함수는 반환 값이 없어요.
Exercism에 가입하고 x86-64 Assembly 트랙을 개념 22개연습 문제 130개, 그리고 실제 사람의 멘토링과 함께 배우고 익혀 보세요. 모두 무료예요.