Float Exposed
(float.exposed)- 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
- (−12)02×102(100010012 − 011111112)×1.011111110010100000000002
- 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
- 1×210×1.4967041015625
- 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
Ý 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ị
floatthành chuỗi thập phân vừa ngắn nhất vừa rõ ràng” Ví dụ, khi dùngfloatđơ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ịfloatVì vậy phải dùng mẫuprintfkiểu%.9gNhưng như vậy thì0.1sẽ bị in ra thành những giá trị xấu xí như0.100000001Thế 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%.6gthì 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ư sauTô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/scanfhay 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
floatnhấ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ệndouble-conversioncủa Google cũng triển khai hai thuật toán đầu tiênCó cách tốt hơn để làm việc đó mà không cần vòng lặp
printf/scanfChỉ riêngprintf("%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_digits10https://en.cppreference.com/w/cpp/types/numeric_limits/max_digits10.htmlVô 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 tinNế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ảia,bnhư 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.0float 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ỏ)nextaftercũ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ăngfloatlên nhưinttừ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 đúngNhâ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ả
NaNcũng so sánh được, trông như sau (khuyến nghị IEEE 751)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ì
floatcà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ểmThú 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
floatKhi cộng nhiều số
floatnằ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 trongfloatnghiê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ềfloatnhưa + (b + c) ≠ (a + b) + cNgoà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ằngfloat, 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ỗifloat(độ 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
float32 bit Có rất nhiều bài viết và video liên quan, cực kỳ đáng xemHì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 precisionfloatthì đ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íchChư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ề
floatlà https://0.30000000000000004.com/Với
float32 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 testVới
float64 bit,9007199254740991làNumber.MAX_SAFE_INTEGERtrong JavaScript Giá trị này không phải số chẵn, và giá trị kế tiếp9007199254740992tự 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ư9007199254740993sẽ bị làm tròn nên không thể phân biệt đượcVới
float64 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àfloatcòn biểu diễn “chính xác” được Chẳng hạn vớifloat32 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ỏ quaTô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
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