1P by GN⁺ 2일전 | ★ favorite | 댓글 1개
  • 이 글은 부동 소수점(float) 값이 어떻게 메모리상에서 저장되고 표현되는지 설명함
  • 값의 16진수, 10진수 형태와 실제 수치로의 변환 방법에 초점을 맞춤
  • 기호(Sign), 지수(Exponent), 가수(Significand) 영역 정의와 각각의 역할 설명
  • 특정 float 값이 정확히 어떤 2진, 10진 값을 나타내는지 해석 방법 예시 포함
  • 표현 가능한 값 사이의 차이(Delta) 계산 내용도 언급함

부동 소수점 값의 저장 구조 분석

  • "halfb float float double" 등 다양한 부동 소수점 포맷이 존재함
  • 각각의 값은 Raw Hexadecimal Integer Value(16진수 정수 값) , Raw Decimal Integer Value(10진수 정수 값) 와 같이 메모리 내 저장값으로 확인 가능함
  • 16진수 데이터는 Hexadecimal Form ("%a") 로, 실제 부동 소수점 표기와 연결됨
  • 각각의 값의 위치는 Significand–Exponent Range(가수–지수 범위상 위치) 로 나타내어짐

2진수 및 10진수 값 해석 방법

  • 부동 소수점 수는 Base-2(2진법 평가식) 으로 다음과 같이 표현 가능함:
    • (−12)02×​102(100010012 − 011111112)​×​1.011111110010100000000002
      → 2진 표현식을 통한 수치 평가임
  • Base-10(10진법 평가식) 에서는 이런 형태임:
    • 1×​210×​1.4967041015625
      → 2의 10제곱과 소수 부분의 곱으로 표현함
  • 실제로 변환 시의 정확한 10진수 값도 표시됨:
    • 1.532625×​103 같은 식으로 제시됨

인접 값과의 거리(Delta) 계산

  • 표현 가능한 값들 사이의 Delta(간격) 는 중요한 의미를 가짐
  • 다음(Next)혹은 이전(Previous)으로 표현 가능한 값과의 거리(Delta to Next/Previous Representable Value) 를 각각 제공함
    • 예: ±1.220703125×​10-4
  • 이 간격은 부동 소수점 값의 유효 자릿수/정밀도와 연관됨

요약

  • 부동 소수점의 메모리 표현 및 2진, 10진 변환 원리
  • sign, exponent, significand 구조 설명
  • 표현 범위와 인접 값과의 간격 정보도 함께 정리함
Hacker News 의견
  • 이 주제에 관해서는 이 설명이 최고임: https://fabiensanglard.net/floating_point_visually_explained/ Hacker News를 시작할 때 이 글을 접하고 이런 내용이 계속해서 플랫폼에 남아 있으려는 동기를 줬음: https://news.ycombinator.com/item?id=29368529

    • 나는 수학적으로 너무 치우친 것 같을 수 있지만, 저 설명이 그렇게 쉽지는 않았음 부동소수점에 대한 정말 간단한 설명을 원한다면: 규모와 무관하게 대략 동일한 비트 수의 정밀도를 제공함 즉, 1보다 훨씬 작은 수든, 1 근처든, 매우 큰 수든, 앞쪽 비트에서 거의 같은 정도의 정확도를 기대할 수 있음 이게 핵심 속성이지만 이를 체화하기는 쉽지 않음

    • 최근 TM 연구팀이 쓴 블로그와 맥락이 잘 맞음 https://news.ycombinator.com/item?id=45200925

    • 이렇게 잘 설명된 걸 본 적이 없어서 공유해줘서 고맙게 생각함

  • 내가 한참 고민했던 문제 중 하나는 'float 값을 가장 짧으면서도 명확하게 10진수 문자열로 표현하는 방법'임 예를 들면, 단정밀도 float를 사용할 때는 float를 고유하게 식별하려면 최대 9자리의 10진수 정밀도가 필요함 그래서 %.9g 같은 printf 패턴을 써야 함 그런데 이럴 경우 0.1이 0.100000001 같은 보기 싫은 값으로 출력됨 그래서 보통 6자리로 반올림해서 표현하는데, %.6g를 쓰면 6자리까지 입력된 10진수 값은 저장된 값과 동일하게 출력될 수 있음 하지만 계산 결과로 나온 값에 대해서는 round-trip이 안전하지 않게 됨 특히 float 값을 정확하게 비교해야 할 때 (예를 들어 데이터 변경 여부 확인) 이 부분이 중요함 내가 생각한 아이디어는 일단 6자리로 출력해보고, 파싱했을 때 같은 바이너리 값이 나오면 그걸 쓰고, 다르면 7자리, 8자리, 9자리까지 반복해서 가장 짧은 10진수 표현을 찾는 거였음 내 알고리즘은 아래와 같음

    int out_length;
    char buffer[32];
    for (int prec = 6; prec<=9; prec++) {
      out_length = sprintf(buffer, "%.*g", prec, floatValue);
      if (prec == 9) {
        break;
      }
      float checked_number;
      sscanf(buffer, "%g", &checked_number);
      if (checked_number == floatValue) {
        break;
      }
    }
    

    printf/scanf를 반복하지 않고 더 효율적으로 가장 짧은 표현을 찾는 방법이 있을지 궁금함

    • 이 문제는 실제로 중요함 (가장 가까운 표현이 되도록 한다는 조건 하에) 특정 float를 "정규화된" 문자열로 만드는 문제로 볼 수 있음 그래서 Dragon4, Grisu3, Ryu, Dragonbox 같은 다양한 효율적인 알고리즘들이 있음 구글의 double-conversion 라이브러리도 처음 두 가지를 구현함

    • printf/scanf 루프 없이 구할 수 있는 더 좋은 방법이 있음 printf("%f", ...) 만으로도 가능함 float에서 string으로 변환하는 실제 알고리즘은 꽤 복잡함 최근 좋은 알고리즘은 https://github.com/ulfjack/ryu 임 이보다 더 효율적인 방법이 최근에 나온 것으로 알고 있으나 이름은 기억나지 않음

    • 부정적인 의견에 너무 신경 쓸 필요 없음, 비록 최고의 방법은 아닐지라도 (에러가 없다면) 대개 충분히 잘 동작함 실제로 나도 비슷한 경험이 있었는데, 한 번은 오일러 회전(5°, 5°, 0) 후 동일 벡터가 될 벡터를 찾고 싶어서 랜덤으로 벡터를 살짝 움직이면서 기준 벡터에 더 가까워지는지를 봤었음 수백만 번 루프를 돌렸고, Python에서 몇 초 안에 결과를 얻었음 라이브러리 수준에서는 비효율적이겠지만, 내 사용 목적에는 아주 만족스러웠음

    • std::numeric_limits<float>::max_digits10을 참고하면 좋음 https://en.cppreference.com/w/cpp/…

    • 무의미함, 그리고 절대 sscanf()는 쓰지 않아야 함 부호 없는 정수로 변환해서 직렬화/복원하면 정보 손실 없이 가역적임

      double f = 0.0/0.0; // 몇몇 컴파일러에서 soft error 플래그 필요할 수 있음
      double g;
      char s[9];
      
      assert(sizeof double == sizeof uint64_t);
      
      snprintf(s, 9, "%0" PRIu64, *(uint64_t *)(&f));
      
      snscanf(s, 9, "%0" SCNu64, (uint64_t *)(&g));
      

      더 짧은 표현이 필요하면 원형 복원이 가능한 휴리스틱을 쓰되, 원본 정확도가 보장되는 방법이면 됨 (예: 멱등성)

  • 내가 제일 좋아하는 FP 관련 팁은 float 비교가 거의 정수 비교처럼 쓸 수 있다는 점임 a > b 를 판단할 때 a, b를 부호 있는 정수로 해석해서 그냥 비교하면 됨 이 방식이 (거의) 잘 동작함 즉, 다음으로 더 큰 float 값은 비트 패턴을 정수로 바꿔서 1을 더한 것임 예를 들어, 0.0 float로 시작해서 정수 덧셈으로 1을 더하면, 그게 바로 다음 float 값임(denormal, 미세한 맨틈값) 이런 원리로 nextafter도 구현됨 float 값들이 정수 비교 순서와 같다는 걸 알면 훨씬 자연스럽게 느껴짐 물론 예외는 있음: NaN, 무한대, 음수 0 등은 다름 쓸만한 점이 몇 가지 있지만 전부는 아님

    • 이 얘기는 정확히는 사실이 아님 양수나 양수-음수 비교에서는 맞지만, 음수끼리 비교는 다름 표준 부동소수점(float)은 sign-magnitude 방식이고, 현대의 부호있는 정수는 2의 보수임 음수에서는 둘 간의 크기 비교 방향이 뒤집어짐 float를 int처럼 1씩 올리면, 보통 동일한 부호 내에서 '크기'가 더 큰 수로 이동함 즉, 양수는 올라가고, 음수는 더 작은 음수 쪽으로 내려감 정수에서는 항상 위로 올라가거나, 오버플로우에 걸림 더 정확하게 표현하자면 sign-magnitude 정수 비교랑 같다고 할 수 있음 물론 여전히 언급한 caveat는 유효함

    • 참고로 Rust 표준 라이브러리의 NaN도 비교 가능한 total-order 부동소수점 크기 비교 알고리즘은 아래와 같음 (IEEE 751 권장)

      let mut left = self.to_bits() as i32;
      let mut right = other.to_bits() as i32;
      
      // 음수일 때, 부호를 제외한 모든 비트를 반전시켜주면
      // 2의 보수 정수 비교와 유사한 구조로 정렬됨
      
      left ^= (((left >> 31) as u32) >> 1) as i32;
      right ^= (((right >> 31) as u32) >> 1) as i32;
      
      left.cmp(&right)
      

      알고리즘 전체 보기

  • 이 내용은 나의 OMSCS 게임 AI 과정 중 부동소수점으로 게임 오브젝트 위치를 표현할 때 주의점을 다룬 사례로 접했음 원점이나 참조점에서 멀어질수록 float가 더 큰 값을 저장하느라 정밀도가 줄어들기 때문에 위험함

    • 이런 현상이 Minecraft 신화처럼 Far Lands로 자리잡은 점이 흥미로움 즉, 월드의 원점에서 멀리 갈수록 지형 생성이나 물리현상이 조금씩 이상해지다가, 훨씬 멀리 가면 완전히 망가짐 약간 오컬트 느낌도 나는데, 점점 현실의 법칙이 무너지듯 하는 현상임 이 모든 게 float 정밀도 한계 때문임

    • float의 0~1 사이 숫자를 많이 더할 때, 단순히 차례로 더하는 방식과 두 개씩 짝지어 더한 뒤 다시 합치는 방식(페어링)을 비교하면, 페어링 방식이 훨씬 정확함 float 누적 오차 영향이 심각하다는 예시임 실제로 이런 float 오차가 무시되어서 문제가 된 사례가 있었음 Donald Knuth의 "The Art of Computer Programming"에서 이런 점, a + (b + c) ≠ (a + b) + c 등 float의 기초 진실을 설명함 현실에서도 실수로 인해 문제가 되는 경우가 있었는데, Patriot 미사일 시스템은 시간 누적을 float로 처리하다가 점점 오차가 쌓여서 목표에서 완전히 벗어나 재시작이 필요했음 매 24시간마다 재부팅이 필요했고, 결국 시스템 소프트웨어가 보완됨 float 오류로 대형 구조물이 무너지는 일(두께 수치가 너무 얇게 계산됨)도 있었음

    • 경계 조건을 먼저 정의해서 어느 정도 정밀도가 필요한지 기준을 세워야 함 그러면 최소/최대 거리도 사전에 산출 가능함 월드가 너무 커지면 섹터로 나누거나 글로벌/로컬 좌표를 별도로 관리(예: No Man's Sky)해야 함 게임은 어디까지나 극장 장치 같은 것임 Double-Precision이면 웬만한 상황엔 충분함 중요한 건 작은 값과 큰 값을 같이 더하지 않도록 기억하는 것임

    • Kerbal Space Program은 32bit float만으로 태양계를 구현해내려고 상당히 똑똑한 엔지니어링이 동원됨 관련 아티클과 영상이 많은데 매우 추천함

  • 이 시각화가 재밌고, 예전에 내가 네트워크 범위 이해를 돕기 위해 만든 CIDR range calculator와 비주얼이 비슷해서 흥미로움 이런 시각화들은 매우 유용함

  • 예전에는 float 표현을 탐구할 때 https://www.h-schmidt.net/FloatConverter/IEEE754.html 을 사용했음 이 사이트는 변환 오차도 보여주는 점이 장점이지만, double precision은 지원하지 않음

    • 나도 이미 누가 언급했는지 댓글을 훑어봤는데, 정말 좋은 웹페이지임 다만 OP에서 소개된 사이트는 수치 공간의 분할 구조를 그래프로 정말 직관적으로 설명해줌 세로축은 로그 스케일이며, 가로축은 각 행마다 선형이지만 로그구간에 맞게 정규화돼있음 float를 편하게 이해하는 사람에게는 당연할 수 있지만, 처음 배우는 입장에서는 추가 설명이 필요한 부분임
  • 아직 이 댓글에는 공유되지 않았지만, float 관련해서 내가 제일 좋아하는 사이트는 https://0.30000000000000004.com/

  • 32bit float에서 "가장 흥미로운 정수"는 16777217 (64비트는 9007199254740992)이 수상임 이런 edge case를 테스트 때 알아두면 재미있음

    • 64비트 float 기준 9007199254740991이 JavaScript에서 Number.MAX_SAFE_INTEGER임 이 값은 짝수가 아니고, 다음 값 9007199254740992도 자체적으로는 안전한 값이지만, 9007199254740993 같은 명확하게 안전하지 않은 값이 반올림되어 구분이 안 되는 특성이 있음

    • 64비트 float에서는 정확히 ±9,007,199,254,740,993.0임 :-) 참고로 이런 값들은 float가 '정확하게' 표현할 수 있는 가장 큰 정수 한계 바로 다음 값을 의미함 예컨대 32비트 float에서 ±16,777,216.0 다음으로 표현 가능한 값은 ±16,777,218.0임 ±16,777,217.0은 표현 불가해서 보통 zero 방향 등으로 반올림됨 이런 정밀도 한계와 반올림 문제는 종종 간과되는 영역임

  • IEEE754가 존재한다는 점은 기쁘게 생각하지만, IEEE754는 완벽하지 않고 posit와 같은 값이 (하드웨어 지원 가정하지 않을 때) 더 좋다고 생각함 빅넘 rational(유리수)은 둘보다 더 우월하지만, 속도가 가장 느림

    • IEEE754는 여러 요구를 아우르는 절충안임 대안 방식 중 어떤 것들은 특정 영역에선 우수하지만, 다른 영역에선 더 못함
  • 최근 GPU에서 도입된 다양한 fp8 포맷을 지원해주면 정말 멋질 것 같음