3 điểm bởi GN⁺ 2025-09-13 | 1 bình luận | Chia sẻ qua WhatsApp
  • Bài viết này giải thích cách các giá trị số thực dấu chấm động (float) được lưu trữ và biểu diễn trong bộ nhớ
  • Tập trung vào cách chuyển đổi giữa dạng thập lục phân, thập phân của giá trị và giá trị số thực tế
  • Giải thích định nghĩa và vai trò của các vùng dấu (Sign), số mũ (Exponent), trị số/mantissa (Significand)
  • Bao gồm ví dụ về cách diễn giải một giá trị float cụ thể biểu thị chính xác giá trị nhị phân và thập phân nào
  • Cũng đề cập đến cách tính độ chênh (Delta) giữa các giá trị có thể biểu diễn

Phân tích cấu trúc lưu trữ của giá trị dấu chấm động

  • Tồn tại nhiều định dạng dấu chấm động khác nhau như "halfb float float double"
  • Mỗi giá trị có thể được kiểm tra dưới dạng giá trị lưu trong bộ nhớ như Raw Hexadecimal Integer Value (giá trị số nguyên thập lục phân), Raw Decimal Integer Value (giá trị số nguyên thập phân)
  • Dữ liệu thập lục phân được nối với cách biểu diễn dấu chấm động thực tế qua Hexadecimal Form ("%a")
  • Vị trí của từng giá trị được thể hiện bằng Significand–Exponent Range (vị trí trong phạm vi trị số–số mũ)

Cách diễn giải giá trị nhị phân và thập phân

  • Số dấu chấm động có thể được biểu diễn bằng Base-2 (biểu thức đánh giá cơ số 2) như sau:
    • (−12)02×​102(100010012 − 011111112)​×​1.011111110010100000000002
      → Đây là cách đánh giá giá trị số thông qua biểu thức nhị phân
  • Trong Base-10 (biểu thức đánh giá cơ số 10), nó có dạng như sau:
    • 1×​210×​1.4967041015625
      → Được biểu diễn dưới dạng tích của lũy thừa 2 mũ 10 và phần thập phân
  • Giá trị thập phân chính xác khi chuyển đổi cũng được hiển thị:
    • Được trình bày dưới dạng như 1.532625×​103

Tính khoảng cách đến các giá trị lân cận (Delta)

  • Delta (khoảng cách) giữa các giá trị có thể biểu diễn mang ý nghĩa quan trọng
  • Cung cấp riêng khoảng cách đến giá trị có thể biểu diễn kế tiếp hoặc trước đó (Delta to Next/Previous Representable Value)
    • Ví dụ: ±1.220703125×​10-4
  • Khoảng cách này liên quan đến số chữ số có nghĩa/độ chính xác của giá trị dấu chấm động

Tóm tắt

  • Nguyên lý biểu diễn trong bộ nhớ của số dấu chấm động và cách chuyển đổi sang nhị phân, thập phân
  • Giải thích cấu trúc sign, exponent, significand
  • Đồng thời tổng hợp phạm vi biểu diễn và thông tin khoảng cách với các giá trị lân cận

1 bình luận

 
GN⁺ 2025-09-13
Ý kiến trên Hacker News
  • Với chủ đề này thì đây là lời giải thích hay nhất: https://fabiensanglard.net/floating_point_visually_explained/ Tôi đã đọc bài này khi mới bắt đầu dùng Hacker News, và nó khiến tôi có động lực tiếp tục gắn bó với nền tảng vì những nội dung như thế này vẫn còn tồn tại ở đây: https://news.ycombinator.com/item?id=29368529

    • Có thể tôi hơi thiên về mặt toán học, nhưng lời giải thích đó cũng không hẳn là dễ hiểu như vậy Nếu muốn một cách giải thích thật đơn giản về số thực dấu phẩy động: nó cung cấp gần như cùng một số bit độ chính xác bất kể độ lớn Tức là dù là số nhỏ hơn 1 rất nhiều, gần 1, hay cực lớn, bạn đều có thể kỳ vọng mức độ chính xác gần như tương đương ở các bit đầu Đây là thuộc tính cốt lõi, nhưng không dễ để thực sự thấm được điều đó

    • Nó cũng rất khớp với ngữ cảnh của bài blog gần đây do nhóm nghiên cứu TM viết https://news.ycombinator.com/item?id=45200925

    • Tôi chưa từng thấy thứ gì được giải thích hay đến vậy, nên rất cảm ơn vì đã chia sẻ

  • Một trong những vấn đề tôi từng suy nghĩ rất lâu là “làm sao biểu diễn một giá trị float thành chuỗi thập phân vừa ngắn nhất vừa rõ ràng” Ví dụ, khi dùng float đơn chính xác thì cần tối đa 9 chữ số chính xác thập phân để nhận diện duy nhất giá trị float Vì vậy phải dùng mẫu printf kiểu %.9g Nhưng như vậy thì 0.1 sẽ bị in ra thành những giá trị xấu xí như 0.100000001 Thế nên người ta thường làm tròn và biểu diễn bằng 6 chữ số, và nếu dùng %.6g thì các giá trị thập phân được nhập với tối đa 6 chữ số có thể được in ra đúng như giá trị đã lưu Tuy nhiên, với các giá trị sinh ra từ phép tính thì round-trip sẽ không còn an toàn Điều này đặc biệt quan trọng khi cần so sánh chính xác các giá trị float (ví dụ để kiểm tra dữ liệu có thay đổi hay không) Ý tưởng tôi nghĩ ra là trước hết in với 6 chữ số, rồi nếu parse lại mà ra cùng giá trị nhị phân thì dùng nó, nếu không thì thử tiếp 7, 8, rồi 9 chữ số để tìm biểu diễn thập phân ngắn nhất Thuật toán của tôi như sau

    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;
      }
    }
    

    Tôi tự hỏi liệu có cách nào hiệu quả hơn để tìm biểu diễn ngắn nhất mà không phải lặp printf/scanf hay không

    • Đây thực sự là một bài toán quan trọng Có thể xem nó là bài toán tạo ra chuỗi “chuẩn hóa” cho một float nhất định, với điều kiện đó là biểu diễn gần nhất Vì vậy mới có nhiều thuật toán hiệu quả như Dragon4, Grisu3, Ryu, Dragonbox Thư viện double-conversion của Google cũng triển khai hai thuật toán đầu tiên

    • Có cách tốt hơn để làm việc đó mà không cần vòng lặp printf/scanf Chỉ riêng printf("%f", ...) là chưa đủ Bản thân thuật toán chuyển từ float sang string thật ra khá phức tạp Một thuật toán tốt gần đây là https://github.com/ulfjack/ryu Tôi nhớ là gần đây còn có cách hiệu quả hơn nữa, nhưng không nhớ tên

    • Đừng quá bận tâm tới những ý kiến tiêu cực; dù có thể đó không phải cách tốt nhất, nhưng nếu không có lỗi thì thường nó vẫn hoạt động đủ tốt Tôi cũng từng có trải nghiệm tương tự: có lần tôi muốn tìm một vector sao cho sau phép quay Euler (5°, 5°, 0) thì vẫn ra đúng cùng vector đó, nên tôi chỉ ngẫu nhiên dịch nhẹ vector và xem nó có tiến gần hơn tới vector mục tiêu hay không Tôi chạy vòng lặp hàng triệu lần và nhận kết quả trong vài giây bằng Python Ở cấp thư viện thì có thể là kém hiệu quả, nhưng với mục đích sử dụng của tôi thì hoàn toàn ổn

    • Bạn có thể tham khảo std::numeric_limits<float>::max_digits10 https://en.cppreference.com/w/cpp/types/numeric_limits/max_digits10.html

    • Vô nghĩa, và tuyệt đối không nên dùng sscanf() Chỉ cần chuyển sang số nguyên không dấu rồi tuần tự hóa/phục hồi là có thể đảo ngược mà không mất thông tin

      double f = 0.0/0.0; // có thể cần cờ soft error trên một số compiler
      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));
      

      Nếu cần biểu diễn ngắn hơn thì dùng heuristic có thể phục hồi vòng lại được, miễn là vẫn đảm bảo độ chính xác gốc (ví dụ: tính idempotent)

  • Mẹo liên quan tới FP mà tôi thích nhất là phép so sánh float gần như có thể dùng như so sánh số nguyên Để kiểm tra a > b, chỉ cần diễn giải a, b như số nguyên có dấu rồi so sánh trực tiếp Cách này hoạt động (gần như) đúng Nghĩa là giá trị float lớn hơn kế tiếp chính là mẫu bit đó khi đổi sang số nguyên rồi cộng thêm 1 Ví dụ, bắt đầu từ 0.0 float rồi cộng 1 bằng phép cộng số nguyên thì sẽ ra ngay giá trị float kế tiếp (denormal, giá trị rất nhỏ) nextafter cũng được triển khai dựa trên nguyên lý này Khi biết rằng thứ tự của các giá trị float giống với thứ tự so sánh số nguyên thì mọi thứ thấy tự nhiên hơn nhiều Tất nhiên vẫn có ngoại lệ: NaN, vô cực, âm 0, v.v. là khác Nó có vài ứng dụng hữu ích, nhưng không phải mọi thứ

    • Nói chính xác thì điều này không hẳn đúng Nó đúng với số dương hoặc so sánh giữa số dương và âm, nhưng với các số âm so với nhau thì khác Dấu phẩy động chuẩn (float) dùng dạng sign-magnitude, còn số nguyên có dấu hiện đại dùng bù 2 Với số âm thì chiều so sánh độ lớn giữa hai bên sẽ bị đảo ngược Nếu tăng float lên như int từng 1 đơn vị, bạn thường di chuyển tới giá trị có “độ lớn” lớn hơn trong cùng dấu Tức là số dương sẽ tăng lên, còn số âm sẽ đi xuống về phía âm hơn Với số nguyên thì luôn đi lên hoặc bị tràn số Cách diễn đạt chính xác hơn là nó giống so sánh số nguyên sign-magnitude Dĩ nhiên các caveat đã nêu vẫn hoàn toàn đúng

    • Nhân tiện, thuật toán so sánh thứ tự toàn phần cho số thực dấu phẩy động trong thư viện chuẩn Rust, nơi cả NaN cũng so sánh được, trông như sau (khuyến nghị IEEE 751)

      let mut left = self.to_bits() as i32;
      let mut right = other.to_bits() as i32;
      
      // Với số âm, nếu đảo tất cả bit ngoại trừ bit dấu
      // thì thứ tự sẽ trở nên tương tự như so sánh số nguyên bù 2
      
      left ^= (((left >> 31) as u32) >> 1) as i32;
      right ^= (((right >> 31) as u32) >> 1) as i32;
      
      left.cmp(&right)
      

      Xem toàn bộ thuật toán

  • Tôi biết đến nội dung này từ một ví dụ trong khóa Game AI của OMSCS, nói về những điều cần cẩn thận khi biểu diễn vị trí đối tượng game bằng dấu phẩy động Càng xa gốc tọa độ hay điểm tham chiếu thì float càng phải lưu các giá trị lớn hơn và vì thế mất dần độ chính xác, nên rất nguy hiểm

    • Thú vị là hiện tượng này đã trở thành huyền thoại Minecraft dưới tên Far Lands Tức là càng đi xa khỏi gốc thế giới thì việc tạo địa hình hoặc vật lý bắt đầu hơi kỳ quặc, rồi xa hơn nữa thì hỏng hoàn toàn Nó hơi mang cảm giác huyền bí, như thể các quy luật thực tại dần sụp đổ Và tất cả chỉ vì giới hạn độ chính xác của float

    • Khi cộng nhiều số float nằm giữa 0 và 1, nếu so cách cộng tuần tự từng số với cách ghép cặp hai số rồi cộng tiếp (pairing), thì cách ghép cặp chính xác hơn hẳn Đây là ví dụ cho thấy tác động của sai số tích lũy trong float nghiêm trọng đến mức nào Thực tế đã từng có những trường hợp gặp vấn đề vì loại sai số này bị xem nhẹ Trong "The Art of Computer Programming", Donald Knuth giải thích những sự thật nền tảng về float như a + (b + c) ≠ (a + b) + c Ngoài đời cũng từng có sự cố vì chuyện này: hệ thống tên lửa Patriot xử lý việc tích lũy thời gian bằng float, khiến sai số dần tăng lên cho tới khi lệch hẳn khỏi mục tiêu và cần khởi động lại Nó phải được reboot mỗi 24 giờ, và cuối cùng phần mềm hệ thống đã được vá để khắc phục Cũng từng có trường hợp kết cấu lớn bị sập vì lỗi float (độ dày bị tính ra quá mỏng)

    • Bạn nên xác định trước các điều kiện biên để biết mình cần mức độ chính xác nào Từ đó có thể tính trước khoảng cách tối thiểu/tối đa Nếu thế giới quá lớn thì phải chia thành sector hoặc quản lý riêng tọa độ global/local (ví dụ: No Man's Sky) Game suy cho cùng cũng chỉ là một sân khấu dàn dựng Double-Precision là đủ cho hầu hết tình huống Điều quan trọng là nhớ đừng cộng các giá trị rất nhỏ với các giá trị rất lớn cùng nhau

    • Kerbal Space Program đã dùng khá nhiều kỹ thuật rất thông minh để mô phỏng cả hệ Mặt Trời chỉ với float 32 bit Có rất nhiều bài viết và video liên quan, cực kỳ đáng xem

  • Hình trực quan này khá thú vị, và tôi thấy hay ở chỗ nó có hình thức giống với CIDR range calculator mà trước đây tôi từng làm để giúp hiểu về dải mạng Những kiểu trực quan hóa như thế này thực sự rất hữu ích

  • Trước đây khi khám phá cách biểu diễn float, tôi hay dùng https://www.h-schmidt.net/FloatConverter/IEEE754.html Điểm hay của trang này là nó còn cho thấy cả sai số chuyển đổi, nhưng không hỗ trợ double precision

    • Tôi cũng đã lướt qua bình luận để xem có ai nhắc chưa, và đúng là đây là một trang web rất hay Tuy vậy, trang được OP giới thiệu giải thích cấu trúc phân chia của không gian số bằng đồ thị theo cách cực kỳ trực quan Trục dọc là thang log, còn trục ngang là tuyến tính trong từng hàng nhưng được chuẩn hóa theo từng khoảng log Với ai đã quen hiểu float thì điều này có thể là hiển nhiên, nhưng với người mới học thì đó là phần cần thêm giải thích
  • Chưa thấy ai chia sẻ trong phần bình luận này, nhưng trang tôi thích nhất về floathttps://0.30000000000000004.com/

  • Với float 32 bit, “số nguyên thú vị nhất” là 16777217 (còn 64 bit là 9007199254740992) Đây là một edge case khá vui để biết khi viết test

    • Với float 64 bit, 9007199254740991Number.MAX_SAFE_INTEGER trong JavaScript Giá trị này không phải số chẵn, và giá trị kế tiếp 9007199254740992 tự nó vẫn còn an toàn, nhưng các giá trị rõ ràng không còn an toàn như 9007199254740993 sẽ bị làm tròn nên không thể phân biệt được

    • Với float 64 bit thì chính xác là ±9,007,199,254,740,993.0 :-) Nhân tiện, những giá trị như thế này chính là giá trị ngay sau giới hạn số nguyên lớn nhất mà float còn biểu diễn “chính xác” được Chẳng hạn với float 32 bit, sau ±16,777,216.0 thì giá trị có thể biểu diễn tiếp theo là ±16,777,218.0 ±16,777,217.0 không thể biểu diễn được nên thường sẽ bị làm tròn, chẳng hạn về phía zero Đây là một vùng mà giới hạn độ chính xác và hành vi làm tròn thường bị bỏ qua

  • Tôi mừng là IEEE754 tồn tại, nhưng tôi không nghĩ IEEE754 là hoàn hảo, và tôi cho rằng các giá trị như posit sẽ tốt hơn (nếu tạm bỏ qua chuyện hỗ trợ phần cứng) BigNum rational thì còn vượt trội hơn cả hai, nhưng tốc độ chậm nhất

    • IEEE754 là một sự thỏa hiệp để đáp ứng nhiều yêu cầu khác nhau Một số phương án thay thế có thể tốt hơn trong vài lĩnh vực nhất định, nhưng lại kém hơn ở những lĩnh vực khác
  • Sẽ rất tuyệt nếu hỗ trợ được các định dạng fp8 đa dạng gần đây được đưa vào GPU