IEEE-754 Black Magic: Bitwise Casting with Only Multiply and Add
엔지니어링을 하다 보면 가끔 “이건 절대 불가능해”라고 느껴지는 제약 조건 속에서 기상천외한 해법을 찾아내는 순간들이 있습니다. 오늘 소개할 이야기가 바로 그런 경우입니다.
우리가 흔히 사용하는 double (64-bit floating point) 값을 비트 단위의 정수(uint64 또는 두 개의 uint32)로 변환하려면 어떻게 해야 할까요? C++이라면 std::bit_cast나 reinterpret_cast를, 자바스크립트라면 DataView를 사용하면 그만입니다. 메모리 레이아웃을 그대로 읽어오면 되니까요.
그런데 만약, 비트 연산자(&, |, ^, >>)도 없고, 메모리 접근(casting)도 불가능한 환경 이라면 어떨까요? 오직 부동소수점 덧셈(+)과 곱셈(*)만 사용할 수 있다면요?
“말도 안 되는 소리”라고 생각했다면, 저와 같은 반응입니다. 하지만 Dougall Johnson의 2020년 아티클은 이 불가능해 보이는 도전을 수학적 해킹으로 풀어냈습니다. 이 글은 그 광기 어린 여정에 대한 Deep Dive입니다.
왜 이런 짓을 하는가?
Tom Lehrer의 명언이 이 프로젝트의 존재 의의를 가장 잘 설명합니다.
“이건 완전히 무의미합니다. 하지만 언젠가, 아마도 다소 기이한 상황에서 여러분 중 누군가에게 유용할지도 모릅니다.”
실제로 이런 제약은 쉐이더(Shader) 프로그래밍이나 아주 제한적인 인터프리터 환경, 혹은 과거 C++ constexpr 컨텍스트에서 종종 마주치곤 했습니다. 하지만 솔직히 말하자면, 이건 실용성보다는 “가능성의 한계”를 시험하는 해커들의 유희 에 가깝습니다.
핵심 원리: 반올림(Rounding)은 정보를 담고 있다
이 트릭의 핵심은 IEEE-754 부동소수점의 Rounding Mode(반올림 모드) 를 역이용하는 것입니다. 기본적으로 대부분의 환경은 “Round to Nearest, Ties to Even”(가장 가까운 값으로, 딱 중간이면 짝수로 반올림)을 사용합니다.
1. 논리 연산을 수학으로 대체하기
비트 연산 없이 비트를 조작하려면 먼저 논리 연산(AND, OR, NOT)을 수학적으로 구현해야 합니다. 0.0을 false, 1.0을 true로 가정해 봅시다.
- AND:
a * b(둘 다 1일 때만 1) - NOT:
1.0 - a - OR:
a + b - a * b - Select (Mux):
cond * true_val + (1 - cond) * false_val
여기까진 쉽습니다. 진짜 문제는 “값의 상태를 어떻게 알아내는가” 입니다.
2. 정밀도 손실을 이용한 비교 연산
어떤 double 값이 정수인지, 혹은 특정 비트가 켜져 있는지 어떻게 알 수 있을까요? 여기서 IEEE-754의 ULP(Unit in the Last Place) 개념이 등장합니다.
매우 작은 값(예: 2^-1074)을 더했을 때, 원래 값의 크기(Exponent)에 따라 이 덧셈이 무시되거나(정밀도 부족), 반올림되어 반영됩니다. 저자는 이를 이용해 x == 0이나 x < y 같은 비교 연산을 만들어냈습니다.
예를 들어, 아주 큰 수에 작은 수를 더하면 부동소수점 특성상 작은 수는 증발해 버립니다. 반면 작은 수에 더하면 값이 변하죠. 연산 결과가 변했는지 안 변했는지를 통해, 우리는 그 숫자의 대략적인 범위(Exponent)를 역추적할 수 있습니다.
3. 더러운 바닥 함수 (Dirty Floor Trick)
가장 인상적인 부분은 floor(바닥 함수) 구현입니다. 비트를 추출하려면 정수 부분과 소수 부분을 분리해야 하는데, floor 함수 없이 이를 어떻게 할까요?
// Dirty Floor Trick
// 2^52는 가수부(Mantissa)의 모든 비트를 정수부로 밀어 올릴 만큼 큰 수입니다.
v + 2^52 - 2^52
이 코드는 부동소수점의 정밀도 한계를 이용해 소수점 아래 비트를 날려버립니다. 2^52보다 큰 수와 더해지면 하위 비트들이 반올림되어 사라지는 원리를 이용한 것이죠. 이것은 musl libc 같은 저수준 라이브러리에서도 종종 보이는 기법입니다.
구현: 수학으로 비트 뜯어내기
저자는 이 원리들을 조합해 get_encoded_exponent 같은 함수를 만들어냅니다. 원리는 이진 탐색(Binary Search) 입니다.
- 값이
2^1024보다 큰가? (곱셈과 덧셈을 이용한 비교) - 크다면
2^-1024를 곱해 Exponent를 깎고, 결과 변수에 1024를 더함. - 이를
2^512,2^256… 순으로 반복.
결국 double 하나를 입력받아, 부호(Sign), 지수(Exponent), 가수(Mantissa)를 각각 별도의 double 변수(하지만 정수 값을 가진)로 분리해 냅니다. 그리고 이들을 다시 조합해 low, high 두 개의 32비트 정수로 반환하죠.
코드를 보면 이게 난독화된 코드인지 수학 공식인지 구분이 안 갈 정도입니다. 아래는 그 결과물의 일부입니다.
// 저자가 공개한 JS 코드의 일부 (압도적인 연산량)
f=2.2250738585072014e-308+a;
j=5e-324;
b=j+f;
b-=f;
// ... 수백 줄의 덧셈과 곱셈 ...
return [low, high];
한계점과 Hacker News의 반응
물론 이 방식은 완벽하지 않습니다. NaN, Infinity, -Infinity는 덧셈/곱셈을 하는 순간 오염(propagation)되기 때문에 비트 단위로 완벽하게 복원할 수 없습니다. -0과 +0을 구분하는 것도 덧셈만으로는 불가능하죠.
이번 Hacker News 스레드에서도 흥미로운 반응들이 많았습니다. 특히 Tom Murphy VII (일명 Tom7, ‘지뢰찾기로 튜링 완전성 증명하기’ 같은 기행을 일삼는 천재)의 연구들과 비교하는 코멘트가 눈에 띄더군요.
“이건 ‘X만 가지고 Y를 할 수 있을까?’ 류의 퍼즐 중에서도 최상급이다. 반올림 동작이 정보를 담고 있다는 사실을 깨닫는 순간, 불가능은 가능이 된다.”
엔지니어의 관점: 기술적 잉여력의 가치
솔직히 말해, 현업에서 이 코드를 프로덕션에 넣는다면 저는 코드 리뷰에서 즉시 반려할 겁니다. std::bit_cast 쓰세요. 제발요.
하지만 Principal Engineer 로서, 이런 종류의 “탐구”는 매우 높게 평가합니다. 우리가 매일 쓰는 double이 실제로 어떻게 동작하는지, IEEE-754 표준이 엣지 케이스에서 어떤 거동을 보장하는지(또는 보장하지 않는지)를 이보다 더 처절하게 배울 방법은 없기 때문입니다.
가끔은 이런 쓸모없어 보이는 깊은 구멍을 파보는 것이, 시스템의 바닥(Bottom)을 이해하는 가장 빠른 지름길이 되기도 합니다. 만약 여러분이 부동소수점 연산이 부정확하다고만 알고 있었다면, 이 글을 통해 “부동소수점은 부정확한 것이 아니라, 정해진 규칙에 따라 너무나도 정확하게 반올림될 뿐” 이라는 사실을 기억했으면 좋겠습니다.