컴퓨터를 위한 산술
유한한 비트로 무한한 실수를 흉내내는 법, 그리고 그 와중에 생기는 작은 거짓말들
수치적 정밀도는 과학의 영혼이다. — D'arcy Wentworth Thompson, 1917
3.1 도입 — 비트 몇 개로 우주를 표현해보자
2장에서 우리는 컴퓨터에게 말을 거는 법을 배웠다. 이제 컴퓨터에게 “계산해라”고 시킬 차례다. 그런데 잠깐. 컴퓨터는 0과 1만 알아듣는다. 손가락 열 개로 셈하던 인류가 갑자기 “손가락 두 개”인 친구한테 “100만 곱하기 3.14159 좀 해줘”라고 부탁하는 셈이다. 이 친구가 어떻게 살아남는지 보는 게 이 장의 목적이다.
구체적으로 우리는 네 가지 미스터리를 풀어야 한다.
- 음수는 어떻게 생기나? — 0과 1만 있는 세상에 ‘마이너스’ 부호가 따로 있을 리 없다.
- 곱셈과 나눗셈 회로는 진짜로 어떻게 생겼지? — 덧셈은 그래도 상상이 가는데, 곱셈은 신비롭다.
- 분수와 실수는? — 비트로 1/3을 어떻게 적지? 적을 수 있긴 한가?
- 표현 가능한 범위를 넘어서면? — 손가락 두 개짜리 친구가 ‘무한대’를 만났을 때 어떻게 반응하나?
이 모든 질문의 답은 “정해진 자리에 정해진 의미를 약속하자”다. 약속이 있어야 비트가 숫자가 된다. 그 약속이 어떻게 생겨먹었는지 — 그리고 그 약속을 깰 때 어떤 일이 벌어지는지 — 이제부터 쭉 살펴본다.
비트 패턴은 그냥 0과 1의 줄이다. 그것이 정수든 실수든 ‘ㅋㅋㅋ’이든, 의미는 해석하는 쪽이 정한다. 이 한 문장만 머리에 박혀 있어도 이번 장의 80%는 끝난 것이다. (나머지 20%가 길긴 하다.)
3.2 덧셈과 뺄셈 — 손가락 셈에서 ALU까지
덧셈은 인류가 가장 먼저 배우는 연산이다. 컴퓨터에게도 마찬가지다. 다만 우리가 종이에 쓰던 그 방식 그대로 회로로 옮겼을 뿐이다. 오른쪽부터 한 자리씩, 자리올림(carry)을 챙겨가며.
예를 들어 4비트 두 수를 더해보자. 0110 + 0011:
// 자리올림 →
0110 // 6
+ 0011 // 3
─────────
1001 // 9
각 자리에서 “두 비트 더하고, 들어온 자리올림도 더해서, 합과 나갈 자리올림을 출력”하는 작은 회로 — 이게 바로 전가산기(full adder)다. 이걸 비트 수만큼 줄줄이 이으면 가산기가 된다. 단순하다. 너무 단순해서 처음 배우면 약간 허무할 정도다.
뺄셈은? 부호를 뒤집어서 더한다
뺄셈 회로를 따로 만들면 비싸고 귀찮다. 그래서 컴퓨터는 잔머리를 굴린다.
뺄 수의 부호만 뒤집어서 덧셈기에 던져버린다. 즉 A − B는 A + (−B)다.
여기서 ‘부호 뒤집기’는 2의 보수(two's complement)로 한다. 모든 비트를 NOT한 다음 1을 더하면 된다.
예컨대 4비트에서 3 = 0011을 뒤집으면:
원래: 0011 // +3
비트 반전: 1100
1 더하기: 1101 // -3
그러니 5 − 3은 다음과 같이 굴러간다. 4비트라 자리올림이 마지막에 “버려진다”는 점이 포인트다.
0101 // +5
+ 1101 // -3
─────────
1 0010 // +2 (맨 앞 carry는 무시)
뺄셈 회로를 따로 만들지 않고 “부호 반전기 + 가산기”로 통일하면 칩이 작아지고, 빨라지고, 디버깅도 쉬워진다. 한 가지 일을 잘하는 부품을 여러 곳에서 재사용하는 것 — 이게 컴퓨터 구조의 영원한 미덕이다.
오버플로(overflow) — 손가락이 모자라요
4비트로 양수를 표현할 수 있는 최댓값은 0111 = +7이다. 그런데 +5 + +4를 하면? 9는 4비트
부호 있는 정수로 표현할 수 없다. 비트는 1001이 되는데, 이건 부호 비트가 1이라 컴퓨터 입장에선
−7이다. 양수 더하다가 갑자기 음수가 되어버린 거다. 이런 사고를 오버플로라고 한다.
오버플로 감지 규칙은 의외로 외울 만하다. 핵심은 “부호가 다른 두 수를 더할 땐 절대 오버플로가 안 난다”는 것이다. 음수와 양수를 더하면 결과는 둘 사이 어딘가일 테니까. 따라서:
| 연산 | 오버플로가 나는 경우 |
|---|---|
| A + B | 두 피연산자의 부호가 같은데 결과의 부호가 다른 경우 |
| A − B | 두 피연산자의 부호가 다른데 결과의 부호가 A와 다른 경우 |
한 번 더 풀어 말하자면, 양수 + 양수 = 음수, 또는 음수 + 음수 = 양수 — 이런 일이 일어나면 “이거 뭔가 잘못됐다”다. 회로에서는 마지막 자리올림과 끝에서 두 번째 자리올림이 다를 때 오버플로 신호가 켜진다. 한 줄짜리 XOR 게이트로 끝난다.
ALU — 이 모든 게 한 상자 안에
덧셈, 뺄셈, AND, OR, 비교, 시프트… 이런 산술·논리 연산을 한곳에 모아둔 회로를 ALU(Arithmetic Logic Unit)라 부른다. CPU 한복판에서 묵묵히 일하는 진짜 일꾼. 4장에서 ALU의 신상명세를 다시 만나게 되니, 지금은 “이름만 알아두자” 정도로 충분하다.
포화 산술(saturating arithmetic) — 사고를 사고 안 나게 가리기
보통의 ALU는 오버플로가 나면 ‘둘둘 말려서(wrap around)’ 음수가 양수가 되는 사고를 일으킨다. 그러나 어떤 응용에서는 그게 끔찍한 일이 된다. 예를 들어 오디오 신호 처리에서 큰 양수가 갑자기 큰 음수로 변해버리면, 스피커가 “톡!” 하면서 사용자의 귀를 강타한다. 그래서 멀티미디어용 명령어들은 종종 포화 산술을 쓴다. 오버플로가 나면 그냥 “이 자료형의 최댓값/최솟값에서 멈추는” 방식이다. 컵에 물을 가득 따라도 넘치지 않고 그냥 가득 차 있는 셈. 깔끔하다.
4비트 부호 있는 정수에서 0110 + 0101을 계산하라. 결과 비트와 십진수 값은? 오버플로가 났는가?
났다면 왜 났는가?
3.3 곱셈 — 종이와 연필이 회로가 되기까지
초등학교에서 배운 곱셈을 떠올려보자. 1234 × 567을 계산할 때 우리가 한 일은:
(1) 한 자릿수씩 곱하고, (2) 자리에 맞게 왼쪽으로 밀어서, (3) 다 합치기. 이게 끝이다.
컴퓨터의 곱셈도 정확히 이걸 한다. 다만 “자릿수” 대신 “비트”로.
1011 // 피승수 (multiplicand) = 11
× 0110 // 승수 (multiplier) = 6
─────────
0000 // 승수의 비트 0이 0 → 0
1011 // 승수의 비트 1이 1 → 피승수를 1칸 왼쪽으로
1011 // 승수의 비트 2가 1 → 피승수를 2칸 왼쪽으로
0000 // 승수의 비트 3이 0 → 0
─────────
0001000010 // 결과 = 66
규칙은 단순하다. 승수의 각 비트를 살펴보고, 1이면 피승수를 그 자리만큼 시프트해서 더하고, 0이면 건너뛴다. “시프트 + 덧셈”의 반복. 이걸 회로로 만들면 순차 곱셈기(sequential multiplier)가 된다.
최적화된 하드웨어 — 한 단계 단순한 그림
초창기 곱셈 회로의 그림은 “피승수 64비트 레지스터 + 승수 64비트 레지스터 + 결과 128비트 레지스터”로 꽤 우람했다. 그런데 잘 보면 피승수를 왼쪽으로 미는 것이나, 결과를 오른쪽으로 미는 것이나 결국 같은 일이다. 그래서 현대 곱셈기는 결과 레지스터를 “1단계 더 영리하게” 만든다. 한쪽 끝에선 더하고, 통째로 오른쪽으로 시프트하면서, 승수 자체를 결과 레지스터의 아래쪽에 박아 두는 식이다. 비유하자면 — 책장을 줄이지 않고, 책을 옆으로 밀면서 새로 꽂는 트릭이다. 자세한 그림은 책의 회로도를 따라가면 좋다.
부호 있는 곱셈
부호 있는 곱셈을 하는 가장 게으른 방법은: (1) 두 수의 부호를 기억해두고, (2) 절댓값으로 만든 뒤 곱하고, (3) 결과의 부호를 부호 비트끼리 XOR해서 결정하는 것이다. 더 정교한 방법으로는 부스 알고리즘(Booth's algorithm)이 있다. 음수 비트 패턴을 교묘히 “건너뛰면서” 시프트 횟수를 줄이는 기법인데, 이름만 알아두자. 시험에 나오면 그때 다시 만나자.
병렬 가산기 — 트리(tree)로 더 빠르게
순차 곱셈은 비트 수만큼 시간이 걸린다. 64비트면 64사이클. 너무 느리다. 그래서 현대 CPU는 부분곱(partial product)을
한꺼번에 만들어 놓고, 여러 개를 동시에 더하는 트리로 짜낸다. Wallace tree, Dadda tree 같은 이름이 그것들이다.
개념만 잡으면 된다 — “여덟 개를 한 명이 차례로 합치지 말고, 두 명씩 짝지어 네 개로 줄이고, 또 두 명씩 짝지어 두 개로 줄이고…”
토너먼트 대진표라고 생각하면 딱 맞다. 단계 수가 log₂(n)으로 떨어진다.
RISC-V의 곱셈 명령어
| 명령어 | 의미 | 결과 |
|---|---|---|
mul | 곱셈 | 곱의 하위 XLEN 비트 (부호 무관) |
mulh | 곱셈, 상위 | 부호 있는 × 부호 있는 곱의 상위 XLEN 비트 |
mulhu | 곱셈, 상위 | 부호 없는 × 부호 없는 곱의 상위 XLEN 비트 |
mulhsu | 곱셈, 상위 | 부호 있는 × 부호 없는 곱의 상위 XLEN 비트 |
하위 비트는 mul 하나로 끝나는데, 상위 비트는 부호 처리에 따라 세 종류가 따로 있다는 게 RISC-V의
특징이다. 사실 큰 정수(64비트 × 64비트 = 128비트) 라이브러리를 만들 때 정확히 이 셋이 다 필요하다. 처음 보면
“왜 이렇게 많아?” 싶지만, 다중 정밀도 산술을 한 번이라도 짜보면 “아, 이래서…” 하게 된다.
곱셈 명령어가 결과를 두 레지스터에 나눠 담지 않고 “하위 따로, 상위 따로” 두 번 부르게 한 건 RISC-V의 미니멀리즘이다. 파이프라인이 더 단순해지고, 어차피 컴파일러는 둘 중 하나만 필요한 경우가 대부분이다. 이름만 보면 군더더기처럼 느껴지지만, 실제로는 칩을 깔끔하게 유지해주는 약속이다.
3.4 나눗셈 — 컴퓨터가 가장 싫어하는 연산
덧셈, 뺄셈, 곱셈은 다들 자기 회로를 갖고 자랑스럽게 살아간다. 그런데 나눗셈은… 사정이 다르다. 시간이 오래 걸리고,
회로가 크고, 코너 케이스(0으로 나누기!)가 까다롭다. 컴파일러가 x / 8을 만나면 사실 나눗셈을 부르지 않고
오른쪽 시프트로 슬쩍 바꿔버린다. 다들 나눗셈을 피하고 싶어한다.
긴 나눗셈, 그대로 회로로
먼저 용어 정리. 피제수(dividend) ÷ 제수(divisor) = 몫(quotient) … 나머지(remainder). 초등학교 그 긴 나눗셈을 떠올려 보자. 매 단계에서 우리는 다음과 같이 한다.
- 현재 작업 중인 부분에서 제수를 빼본다.
- 빼서 음수가 되면? 빼지 말고 원래 값을 되돌린다 (몫 비트는 0).
- 빼서 양수가 되면? 그대로 둔다 (몫 비트는 1).
- 제수를 한 칸 오른쪽으로 밀고 다음 비트로 넘어간다.
이 알고리즘을 회로로 옮긴 게 복원 나눗셈(restoring division)이다. ‘일단 빼보고, 음수면 도로 더해서 복원한다’는 이름 그대로의 동작이다. 더 영리한 변종으로 비복원(non-restoring), SRT 나눗셈 같은 게 있다.
부호 있는 나눗셈
부호 있는 나눗셈은 곱셈처럼 “일단 절댓값으로 풀고 부호는 나중에”가 기본이다. 다만 나머지의 부호는 피제수와 같다는
규칙이 일반적이다. 예를 들어 −7 ÷ 2의 결과는 (몫 −3, 나머지 −1)이다. 몫 −4, 나머지 +1이 아니라.
이 ‘피제수 부호 따르기’ 규칙을 지키면 (피제수 / 제수) × 제수 + 나머지 = 피제수의 항등식이 깔끔하게 성립한다.
SRT — 표 보고 빠르게 떠넘기기
더 빠른 나눗셈을 만드는 영리한 방법이 SRT 알고리즘이다. 매 단계마다 “현재 부분 나머지 + 제수의 일부”를 보고 몫의 다음 자릿수를 작은 룩업 테이블에서 골라낸다. 한 번에 1비트가 아니라 2비트, 4비트씩 결정할 수도 있다. “한 칸씩 빼보지 말고, 답을 사전에서 찾자”는 발상이다. 빠르다. 단, 사전이 잘못되면? — 뒤에 나올 펜티엄 FDIV 사건이 바로 이 사전의 빈칸 때문이었다. 스포일러는 여기까지.
RISC-V의 나눗셈 명령어
| 명령어 | 의미 |
|---|---|
div | 부호 있는 나눗셈, 몫 |
divu | 부호 없는 나눗셈, 몫 |
rem | 부호 있는 나눗셈, 나머지 |
remu | 부호 없는 나눗셈, 나머지 |
재밌는 건 RISC-V는 0으로 나누기에서 트랩(예외)을 던지지 않는다. 대신 약속된 결과를 돌려준다. 부호 있는
div의 경우 0으로 나누면 몫은 −1(전부 1인 비트열), 나머지는 피제수가 된다. 가장 큰 음수를 −1로 나누는
오버플로 케이스도 약속이 정해져 있다. 예외 처리를 칩에서 빼면 파이프라인이 단순해진다 — 다만 프로그래머가 직접
검사해야 한다. 이런 트레이드오프가 RISC-V의 향기다.
8비트 부호 없는 정수로 23 ÷ 5를 복원 나눗셈으로 풀어보라. 매 단계의 몫 비트와 부분 나머지를
써내려가는 게 핵심이다. 마지막에 몫 4, 나머지 3이 나오면 성공.
3.5 부동소수점 — 0.1 + 0.2 ≠ 0.3의 진실
드디어 이 장의 진짜 주인공. 부동소수점(floating point)은 컴퓨터가 실수를 흉내내는 방식인데, 이게 그냥 흉내라는 게
포인트다. 진짜가 아니다. 아주 잘 만든 모창이다. 모창인데도 너무 그럴듯해서 우리 모두가 진짜인 줄 알고 살아간다.
그러다 가끔 0.1 + 0.2가 0.30000000000000004로 나오면 “어어?” 한다. 이번 절에서 그 ‘어어?’를 정복하자.
왜 부동소수점이 필요한가
32비트로 정수를 표현하면 약 ±21억까지 적을 수 있다. 그런데 천문학자가 “지구에서 알파 센타우리까지 거리는 4×10¹⁶ m”라고 적고 싶다면? 화학자가 “아보가드로 수는 6×10²³”라고 쓰려면? 21억으로는 한참 모자란다. 동시에 “원자 반지름은 5×10⁻¹¹ m” 같은 매우 작은 수도 다뤄야 한다. 큰 수와 작은 수를 같은 32비트 안에서 모두 표현하려면 자릿수의 개념을 바꿔야 한다.
인류는 이미 답을 알고 있었다. 과학적 표기법(scientific notation)이다.
6.022 × 10²³ // 아보가드로 수
1.602 × 10⁻¹⁹ // 전자 전하 (C)
숫자를 “가수(mantissa) × 밑(base) 의 지수(exponent)승”으로 적는다. 컴퓨터에서는 밑을 2로 쓰고, 가수와 지수를 각각 비트로 적으면 된다. 그게 부동소수점이다. ‘부동(floating)’이란 “소수점이 둥둥 떠다닌다”는 뜻인데, 바로 지수에 의해 소수점이 떠다닐 수 있어서 그렇다. 정수는 소수점이 항상 끝에 고정되어 있는 ‘고정소수점(fixed point)’이고, 이 친구는 그게 자유롭다.
정규화(normalized form)
같은 수를 여러 모양으로 적을 수 있다. 1.0 × 2³도 0.5 × 2⁴도 같은 8이다. 그러면 컴퓨터는 비교할 때마다
“이거 같은 숫자야?”를 일일이 따져야 한다. 짜증난다. 그래서 유일한 모양 한 가지로 약속하기로 했다. 이걸
정규화라고 한다. 이진수 부동소수점에서 정규화된 수는 다음 모양이다.
±1.xxxxxxx × 2^(yyy)
“소수점 앞이 1뿐”인 모양이다. 이진수에서 0이 아닌 첫 비트는 무조건 1이니까, 사실상 “소수점 앞은 항상 1”이다. 그렇다면 그 1을 굳이 비트로 저장할 필요가 없다. IEEE 754는 이 점을 이용해 한 비트를 절약한다. 이걸 숨겨진 1 (hidden 1, implicit leading 1)이라고 한다. 마치 너무 당연해서 굳이 쓰지 않는 약속처럼 — “설마 1이 아니겠어?”
가수와 지수 — 정밀도 vs 범위의 줄다리기
32비트라는 한정된 자원에서 가수에 비트를 더 줄지, 지수에 비트를 더 줄지는 영원한 트레이드오프다. 가수가 길면 정밀도(precision)가 좋아지고, 지수가 길면 표현 범위(range)가 넓어진다. IEEE 754는 32비트(단정도)와 64비트(배정도)에서 다음과 같이 약속했다.
| 형식 | 부호(s) | 지수(exp) | 가수(frac) | 총 비트 |
|---|---|---|---|---|
| 단정도(float) | 1 | 8 | 23 | 32 |
| 배정도(double) | 1 | 11 | 52 | 64 |
IEEE 754의 비트 배치
단정도 32비트의 모양은 다음과 같다. 부호 비트가 맨 위, 그 다음 지수, 마지막에 가수다. 가수보다 지수를 위에 둔다는 것이 의외로 중요하다. 두 부동소수점 수를 그냥 정수처럼 비교하면, 같은 부호일 때 한정해서 “비트 패턴 큰 쪽이 절댓값도 크다”는 좋은 성질이 생기기 때문이다. 정렬, 비교가 빨라진다.
┌─┬────────┬───────────────────────┐
│S│ exp │ fraction │
└─┴────────┴───────────────────────┘
1 8 23 bits
값 = (-1)^S × 1.fraction × 2^(exp - 127)
편향 지수(biased exponent) — 왜 굳이 -127을?
지수는 음수도 양수도 가능해야 한다 (큰 수도 표현하고, 작은 수도 표현하니까). 그런데 IEEE 754는 2의 보수를 쓰지 않고
편향 표현을 쓴다. 단정도에서는 실제 지수에 127을 더해서 저장한다. 그래서 비트로 적힌 지수가 10000000(=128)이면
실제 지수는 128 − 127 = 1이다. 마찬가지로 비트가 01111111(=127)이면 실제 지수는 0이다. 배정도는 1023을 더한다.
왜 편향을 쓰나? 핵심은 “부호 비교 한 번이면 두 양수의 크기 비교가 끝나도록” 만들기 위해서다. 편향 지수에서는 “비트 패턴이 큰 쪽이 지수도 더 크다”가 자연스럽게 성립한다. 2의 보수였다면 음수 지수가 가장 큰 비트 패턴이 되어 비교가 어그러진다.
특수값들 — IEEE 754가 챙겨주는 손님들
지수와 가수의 비트 패턴 중 일부는 ‘특수한 손님들’을 위해 예약돼 있다.
| 의미 | 지수 비트 | 가수 비트 |
|---|---|---|
| ±0 | 전부 0 | 전부 0 |
| 비정규화 수(denormal) | 전부 0 | 0이 아님 |
| 정규화 수 | 1 ~ 254 | 아무 값 |
| ±무한대 | 전부 1 | 전부 0 |
| NaN (Not a Number) | 전부 1 | 0이 아님 |
비정규화 수(denormal, subnormal)는 “너무 작아서 정규화 형식으로는 적을 수 없는 수”다. 정규화의 ‘숨겨진 1’ 약속을 잠깐 풀고, 가수만으로 점진적으로 0까지 내려가는 방식이다. 덕분에 0 근처에 부드러운 풍경이 생긴다 — 이걸 점진적 언더플로(gradual underflow)라고 한다.
±∞는 “큰 수 / 0”처럼 무한대를 만나는 상황을 위한 결과다. 그리고 NaN은 “말이 안 되는 결과” —
0/0, ∞ − ∞, √(−1) 같은 — 의 출구다. NaN의 흥미로운 성질 하나: NaN은 자기 자신과도 같지 않다.
x ≠ x가 참인 유일한 경우다. 자바스크립트에서 NaN === NaN이 false인 게 바로 이래서다. 자바스크립트의
잘못이 아니라 IEEE 754의 결정이다.
예외와 인터럽트
오버플로(너무 커서 무한대가 되는 일), 언더플로(너무 작아서 0이 되는 일), 0으로 나누기, 부정확한 결과(반올림으로
값이 손실됨)… 이런 사건이 일어나면 IEEE 754는 플래그(flag)를 켜둔다. 프로그램이 원하면 즉시 인터럽트로
뛰어들 수도 있고, 아니면 조용히 “기록만 남기고” 계속 진행할 수도 있다. RISC-V의 fcsr 레지스터에 이 플래그들이
살고 있다.
부동소수점 덧셈 — 4단계 의식
부동소수점 덧셈은 정수 덧셈처럼 단순하지 않다. 1.5 × 2³ + 1.0 × 2¹처럼 지수가 다른 두 수를 그냥 더할 수는 없다.
같은 자리에 맞춰야 한다. 다음 4단계를 의식처럼 따른다.
- 지수 정렬(align) — 작은 지수의 수를 오른쪽으로 시프트해서 큰 지수에 맞춘다.
예:
1.0 × 2¹은0.01 × 2³이 된다. - 가수 덧셈(add) — 이제 지수가 같으니 가수만 더한다.
- 정규화(normalize) — 결과를 다시
1.xxx모양으로 만든다. 결과가10.xxx면 오른쪽 시프트(지수 +1),0.0xxx면 왼쪽 시프트(지수 −1). - 반올림(round) — 정해진 비트 수에 맞게 자르고, 잘려나간 부분에 따라 적당히 반올림한다. 반올림 후 다시 정규화가 깨질 수 있어서, 필요하면 단계 3으로 돌아간다.
이 의식을 회로로 옮기면 “정렬 시프터 + 가산기 + 선행 0 검출기 + 정규화 시프터 + 반올림 회로”가 된다. 정수 가산기보다 훨씬 큰 회로다. 그래서 부동소수점 ALU는 따로 둔다.
부동소수점 곱셈 — 5단계 의식
- 지수 더하기 — 단, 둘 다 편향이 들어있으니 한 번 빼줘야 한다.
(e₁ + 127) + (e₂ + 127) − 127 = (e₁ + e₂) + 127. - 가수 곱하기 — 정수 곱셈과 똑같다.
- 정규화 — 결과 가수가
1.xxx가 아니면 시프트하고 지수 조정. - 반올림 — 가수를 정해진 비트 수로 줄인다.
- 부호 결정 — 두 부호 비트의 XOR.
덧셈보다 단계가 하나 더 많지만, 의외로 곱셈이 회로상 더 쉽다는 평가도 있다. ‘지수 정렬’이라는 사악한 단계가 없기 때문이다.
RISC-V의 부동소수점 명령어
RISC-V는 부동소수점 연산을 위해 별도의 레지스터 파일 f0~f31을 둔다. 정수 레지스터 x0~x31과
완전히 분리돼 있다. 두 파일을 분리하면 메모리 주소 계산(정수)과 수치 계산(실수)이 동시에 진행될 수 있고, 컴파일러도
레지스터 할당이 편해진다. 단점은 둘 사이를 옮길 때 별도 명령(fmv.x.w, fmv.w.x)이 필요하다는 것.
명령어 이름 끝의 .s는 single(단정도, float), .d는 double(배정도, double)을 뜻한다.
| 명령어 | 하는 일 |
|---|---|
fadd.s / fadd.d | 부동소수점 덧셈 |
fsub.s / fsub.d | 부동소수점 뺄셈 |
fmul.s / fmul.d | 부동소수점 곱셈 |
fdiv.s / fdiv.d | 부동소수점 나눗셈 |
fsqrt.s / fsqrt.d | 제곱근 |
feq.s / feq.d | 같은가? (1 또는 0을 정수 레지스터에) |
flt.s / flt.d | 작은가? |
fle.s / fle.d | 작거나 같은가? |
flw / fld | 메모리에서 부동소수점 로드 |
fsw / fsd | 메모리에 부동소수점 저장 |
비교 명령어가 beq처럼 분기하지 않고, 정수 레지스터에 0/1을 넣는다는 점에 주목하자. 그 다음에 일반 정수
분기 명령(bne x?, x0)으로 점프하는 식이다. 부동소수점과 정수 분기를 따로 두지 않는 미니멀한 설계다.
가드/라운드/스티키 비트와 ULP
부동소수점 덧셈에서 작은 수를 정렬하려고 오른쪽으로 시프트하면, 가수의 끝부분이 잘려나간다. 이걸 그냥 버리면 반올림이 부정확해진다. 그래서 IEEE 754는 회로 안에 “세 칸의 임시 비트”를 더 둔다.
- 가드(guard) 비트 — 시프트로 가수의 마지막 비트 바깥으로 처음 빠지는 자리.
- 라운드(round) 비트 — 그 다음 자리.
- 스티키(sticky) 비트 — 그 이후 어딘가에 1이 한 번이라도 있었으면 1로 ‘붙어 있는’ 비트.
이 세 비트가 있어야 “정확히 절반인지 / 절반보다 큰지 / 절반보다 작은지”를 가려낼 수 있다. 이게 없으면 누적 오차가 심해진다. ULP(Unit in the Last Place)는 “마지막 가수 비트 한 자리에 해당하는 값”인데, “이 함수의 오차는 ±0.5 ULP” 같은 식으로 정확도를 표현할 때 쓴다. IEEE 754는 사칙연산과 제곱근에 대해 “결과는 ±0.5 ULP 이내로 정확해야 한다”고 못 박는다. 즉 “이상적인 무한 정밀도 결과를 가장 가까운 표현 가능 수로 반올림한 값”과 일치해야 한다는 뜻이다.
FMA — 한 번에 두 일
융합 곱셈-덧셈(Fused Multiply-Add, FMA)은 a × b + c를 한 번의 반올림으로 끝내는 명령이다.
각각 따로 하면 곱셈 후 한 번, 덧셈 후 또 한 번 — 총 두 번의 반올림 오차가 끼는데, FMA는 그 사이에 ‘정확한 값’을
그대로 들고 있다가 마지막에 한 번만 반올림한다. 더 빠르고 더 정확하다. 행렬 곱셈이 빨라지는 비밀 중 하나다.
RISC-V에서는 fmadd.s, fmsub.s, fnmadd.s, fnmsub.s 같은 변형이 다 갖춰져 있다.
네 가지 반올림 모드
“반올림”이라고 한 단어로 말했지만, 실은 IEEE 754가 정한 반올림 모드는 네 가지다.
- 가까운 짝수로 반올림(round to nearest, ties to even) — IEEE 754의 기본값. 정확히 절반인 경우(예: 0.5)에 “마지막 비트가 짝수가 되는 쪽”을 선택한다. 통계적으로 편향을 줄여준다.
- 0 방향으로 자르기(round toward zero, truncate) — 그냥 잘라낸다. 빠르지만 편향이 생긴다.
- +∞ 방향으로(round toward +∞) — 항상 올림.
- −∞ 방향으로(round toward −∞) — 항상 내림.
“가까운 짝수로”는 처음 보면 이상하지만, 같은 데이터를 반복해서 처리할 때 누적 오차가 한쪽으로 쏠리는 걸 막아준다. 예를 들어 ‘반올림 = 항상 올림(0.5 → 1)’만 쓰면 평균값이 살짝 커지는 편향이 생긴다. 짝수로 가면 평균적으로 상쇄된다. 통계학자들이 좋아하는 규칙이다.
부동소수점은 실수를 ‘유한 정밀도로 흉내낸 것’이지 진짜가 아니다. 같은 수가 한 가지 비트 패턴(정규화)으로 적히고, 그 패턴 사이의 간격이 일정하지 않으며(지수에 따라 달라짐), 사칙연산의 일부 성질(결합법칙)이 깨진다. 이걸 모르고 부동소수점을 “그냥 실수처럼” 다루면 어디선가 분명히 사고가 난다. 사고는 우주에서 가장 안 좋은 곳 — 머나먼 우주 탐사선, 금융 결제, 의료기기 — 에서 일어나는 게 머피의 법칙이다.
32비트 단정도에서 0.5의 비트 패턴은 무엇인가? 힌트: 부호 0, 지수는 −1을 표현해야 하고, 가수는 ‘1.0 × 2⁻¹’의
형태이므로 가수 비트는 모두 0이다. 답을 16진수로도 적어보면 0x3F000000이 나오는지 확인하라.
3.6 부분 단어 병렬성 — 한 번에 여러 개 처리하기
64비트 레지스터에 64비트 정수 한 개만 담아 쓰는 건 사실 ‘공간 낭비’일 때가 많다. 영상 픽셀의 색은 보통 R/G/B/A 8비트씩이고, 음성 샘플은 16비트면 충분하다. 그렇다면 64비트 안에 8비트를 8개 담거나, 16비트를 4개 담아두고 한 번에 더하면 공짜로 8배, 4배 빨라진다. 이게 SIMD(Single Instruction Multiple Data)의 핵심 아이디어다.
이를 위해선 가산기에서 특정 자리의 자리올림을 끊어주면 된다. 그러면 한 줄짜리 가산기가 마치 여러 개의 독립 가산기처럼 동작한다. 이를 분할 가산기(partitioned adder)라고 부른다. 회로의 사소한 변화로 벡터 처리 성능이 훌쩍 뛰어오른다. 하드웨어 디자이너가 가장 좋아하는 종류의 트릭이다.
멀티미디어 데이터는 정밀도가 한정적이라 보통 포화 산술까지 결합한다. 즉 “R 값에 50을 더했더니 280이 되어 다시 24로 돌아가버려서 빨강이 갑자기 어두운 회색으로 변하는” 사고를 막기 위해 255에서 멈춘다. 이건 인간의 지각 정확도와 잘 맞는다. 같은 이유로 음성 처리에서도 포화가 ‘차라리 자연스러운’ 결과를 낸다.
3.7 x86의 SSE/AVX — 인텔도 어쩔 수 없이
x86 진영도 SIMD가 필요했다. MMX(1996)로 시작해 SSE, SSE2, SSE3, SSE4, AVX, AVX2, AVX-512 — 알파벳 수프가 점점 길어졌다. 레지스터 너비가 64비트(MMX) → 128비트(SSE) → 256비트(AVX) → 512비트(AVX-512)로 점점 굵어진 게 핵심이다. 이름이 바뀔수록 같은 회로 안에 더 많은 데이터를 욱여넣어 한 명령으로 처리한다. 행렬 연산, 영상 처리, 머신러닝의 무거운 내적(dot product) — 이 모든 게 결국 ‘긴 벡터 한 줄’의 연산이라 SIMD가 절대적이다.
RISC-V는 이를 ‘V 확장(vector extension)’으로 깔끔하게 정리한다. x86이 명령어 군을 추가하는 식이라면, RISC-V는 벡터 길이를 런타임에 묻고 그에 맞춰 동작하는 ‘길이 무관(length-agnostic) 벡터 모델’을 채택했다. 한 번 짠 코드가 128비트 칩에서도, 1024비트 슈퍼컴퓨터에서도 그대로 돈다. 실용주의의 승리.
3.8 행렬 곱셈을 빠르게 — DGEMM
수치 라이브러리에서 가장 사랑받는 함수는 단연 DGEMM(Double-precision GEneral Matrix Multiply)이다.
C = αAB + βC 한 줄을 빠르게 돌리는 게 사실상 모든 머신러닝, 시뮬레이션, 그래픽의 심장이다. AVX 같은 SIMD 명령어를
제대로 쓰면 평범한 3중 for 루프 대비 4배~수십 배의 속도 향상을 얻을 수 있다. 비밀은 두 가지: ① 안쪽 루프에서
8개의 double을 한 번에 처리, ② FMA로 곱셈+덧셈을 한 사이클에 끝내기. 본문에서는 이 단순한 아이디어가 실제 코드에서
어떻게 펼쳐지는지를 점진적으로 확장해 보여준다. 5장의 캐시 최적화와 결합하면 또 한 번 점프한다 — 그건 그때 가서.
3.9 오류와 함정 (Fallacies & Pitfalls)
함정 ① 왼쪽 시프트는 곱셈, 오른쪽 시프트는 나눗셈?
부호 없는 정수에서 x << n은 x × 2ⁿ이고, x >> n은 x ÷ 2ⁿ이다 — 여기까지는 맞다. 그러나
부호 있는 음수에서는 오른쪽 시프트가 나눗셈과 일치하지 않는다. 예를 들어 −5 ÷ 2를 손으로 풀면
“몫 −2, 나머지 −1”이지만, −5의 비트를 산술 오른쪽 시프트하면 결과가 −3이 된다. 이유는 단순하다 —
음수의 시프트는 ‘0 방향으로 반올림’이 아니라 ‘−∞ 방향으로 내림(floor)’을 하기 때문이다. 컴파일러는 음수의 정확한
나눗셈을 위해 시프트 + 보정 코드를 추가로 끼워 넣는다.
함정 ② 부동소수점 덧셈은 결합법칙을 만족하지 않는다
실수에서 (a + b) + c = a + (b + c)는 너무 당연한 진리다. 그런데 부동소수점에서는 그렇지 않다.
예를 들어 (1.5 × 10³⁸ + (−1.5 × 10³⁸)) + 1.0은 0 + 1.0 = 1.0이지만,
1.5 × 10³⁸ + ((−1.5 × 10³⁸) + 1.0)은 — 두 번째 덧셈에서 작은 1.0이 정렬 시프트로 통째로 사라져
그냥 1.5 × 10³⁸가 되고, 결국 0이 된다. 괄호의 위치만 바꿨는데 답이 1과 0으로 갈린다.
병렬 처리에서 합산 순서가 매번 달라질 수 있다는 걸 떠올리면 — 이게 디버깅이 끔찍한 버그의 출생증명서다.
함정 ③ “이런 미세한 오차, 수학자나 신경 쓰는 거잖아?”
1994년, 인텔의 펜티엄 FDIV 버그가 터진다. SRT 나눗셈에 쓰이는 룩업 테이블 5개 칸이 비어 있어서 어떤 입력에서는 결과가 틀렸다. 평균 90억 번의 나눗셈 중 한 번꼴이라는, 정말 거의 안 보이는 오차였다. 인텔은 처음에 “일반 사용자에게는 아무 영향 없다”고 발뺌했다. 그러자 데이비드 레터맨이 토크쇼에서 “펜티엄 칩이 당신의 인생을 망칠 수 있는 톱 10” 같은 코너로 풍자했고, 사용자들은 들고일어났다. 결국 인텔은 5억 달러 가까운 비용을 들여 칩을 회수했다. “Intel Inside”라는 광고 슬로건 자체가 한동안 농담거리가 됐다.
교훈은 분명하다. 부동소수점 정밀도는 결코 ‘이론 수학자의 사치’가 아니다. “보이지 않는 작은 오차”가 어느 날 뉴스 1면에 등장할 수 있다. 그것도 5억 달러짜리 사건으로.
“당신이 펜티엄 칩 소유자라는 가장 큰 단서: 9.999997335을 보고 ‘정확히 10이군!’이라고 외치는 모습.” 이런 식의 농담이 진짜로 미국 전국 방송을 탔다. 칩 하나의 작은 SRT 테이블 빈칸이 인텔의 마케팅 부서를 야근시키고 만 셈이다. 산술의 작은 결함이 회사의 큰 신뢰로 직결된 사건.
3.10 결론 — 비트 패턴은 죄가 없다
이 장에서 우리는 같은 32비트가 부호 있는 정수도 되고, 부호 없는 정수도 되고, 단정도 부동소수점도 되는 것을 보았다.
비트 패턴 그 자체엔 의미가 없다. 의미는 명령어가 부여한다. add로 다루면 그 비트는 정수가 되고,
fadd.s로 다루면 부동소수점이 된다. 같은 메모리 안의 같은 비트가 한순간엔 사진의 픽셀, 다음 순간엔
구조체 포인터, 또 다음 순간엔 파일의 크기가 될 수도 있다.
이런 ‘유연한 무책임함’은 자유이자 위험이다. 자유는 ‘공간 절약’과 ‘일반화된 회로’로 돌아오고, 위험은 ‘타입 캐스팅’, ‘엔디언’, ‘부동소수점 비교’ 같은 영원한 함정으로 돌아온다. 이걸 인정하고, 약속을 지키고, 약속이 깨질 때 무슨 일이 일어나는지 알면 — 우리는 컴퓨터에게 산술을 시키는 좋은 동료가 된다.
다음 장에서는 이 모든 산술이 진짜로 칩 안을 굴러다닐 수 있게 만들어주는 프로세서 자체를 들여다본다. 데이터패스, 제어, 파이프라인 — 산술을 ‘하드웨어’에서 진짜 ‘속도’로 바꾸는 작업이다. 그쪽도 만만치 않다. 같이 가자.
“이 비트가 무엇인가”는 “누가 이 비트를 어떻게 다루는가”에 달려 있다. 이건 단순한 비유가 아니라 컴퓨터 공학 전체를 관통하는 원리다. 명령어 집합, 자료형, 직렬화 포맷, 통신 프로토콜 — 모두 “어떻게 해석할지를 약속”하는 일이다.