Float Exposed
(float.exposed)- บทความนี้อธิบายว่า ค่า floating-point (float) ถูก จัดเก็บ และแสดงผล ในหน่วยความจำ อย่างไร
- เน้นวิธีแปลงค่าจาก รูปแบบเลขฐานสิบหกและฐานสิบ ไปเป็นค่าตัวเลขจริง
- อธิบายการกำหนดส่วน Sign, Exponent, Significand และหน้าที่ของแต่ละส่วน
- มีตัวอย่างวิธีตีความว่า float ค่าหนึ่งๆ แทนค่า ฐานสองและฐานสิบที่แน่นอน อะไร
- กล่าวถึงการคำนวณ ความต่าง (Delta) ระหว่างค่าที่สามารถแทนได้ด้วย
การวิเคราะห์โครงสร้างการจัดเก็บค่าจุดลอยตัว
- มี รูปแบบ floating-point หลายแบบ เช่น "halfb float float double"
- ค่าของแต่ละตัวสามารถตรวจสอบเป็นค่าที่เก็บอยู่ในหน่วยความจำได้ เช่น Raw Hexadecimal Integer Value (ค่าเลขจำนวนเต็มฐานสิบหก) และ Raw Decimal Integer Value (ค่าเลขจำนวนเต็มฐานสิบ)
- ข้อมูลเลขฐานสิบหกเชื่อมโยงกับการเขียนค่าจุดลอยตัวจริงผ่าน Hexadecimal Form (
%a) - ตำแหน่งของค่าแต่ละตัวแสดงด้วย Significand–Exponent Range (ตำแหน่งบนช่วงกว้างของกัยสำคัญ–เลขชี้กำลัง)
วิธีตีความค่าฐานสองและฐานสิบ
- จำนวนจุดลอยตัวสามารถเขียนในรูป Base-2 (นิพจน์ประเมินค่าแบบฐานสอง) ได้ดังนี้:
- (−12)02×102(100010012 − 011111112)×1.011111110010100000000002
→ เป็นการประเมินค่าตัวเลขผ่านนิพจน์ฐานสอง
- (−12)02×102(100010012 − 011111112)×1.011111110010100000000002
- ใน Base-10 (นิพจน์ประเมินค่าแบบฐานสิบ) จะอยู่ในรูปแบบนี้:
- 1×210×1.4967041015625
→ แสดงเป็นผลคูณของ 2 ยกกำลัง 10 กับส่วนทศนิยม
- 1×210×1.4967041015625
- ยังแสดง ค่าเลขฐานสิบที่แม่นยำจริง หลังการแปลงด้วย:
- แสดงในรูปอย่าง 1.532625×103
การคำนวณระยะห่างจากค่าข้างเคียง (Delta)
- Delta (ช่วงห่าง) ระหว่างค่าที่สามารถแทนได้มีความสำคัญอย่างมาก
- มีการให้ค่า ระยะห่างถึงค่าที่แทนได้ถัดไปหรือก่อนหน้า (Delta to Next/Previous Representable Value) แยกกัน
- ตัวอย่าง: ±1.220703125×10-4
- ช่วงห่างนี้เกี่ยวข้องกับ จำนวนหลักนัยสำคัญ/ความแม่นยำ ของค่าจุดลอยตัว
สรุป
- อธิบาย การแทนค่าในหน่วยความจำ ของ floating-point และหลักการแปลงเป็นฐานสองและฐานสิบ
- อธิบายโครงสร้าง sign, exponent, significand
- สรุปทั้ง ช่วงค่าที่แทนได้และข้อมูลช่วงห่างกับค่าข้างเคียง ไว้ด้วย
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
สำหรับหัวข้อนี้ คำอธิบายนี้ดีที่สุดแล้ว: https://fabiensanglard.net/floating_point_visually_explained/ ตอนเริ่มอ่าน Hacker News ฉันได้เจอบทความนี้ และมันทำให้ฉันมีแรงจูงใจอยากให้เนื้อหาแบบนี้ยังคงอยู่บนแพลตฟอร์มต่อไป: https://news.ycombinator.com/item?id=29368529
ฉันอาจจะเอนเอียงไปทางคณิตศาสตร์มากไปหน่อย แต่คำอธิบายนั้นก็ไม่ได้เข้าใจง่ายขนาดนั้น ถ้าอยากได้คำอธิบายเรื่อง floating point แบบง่ายมากจริง ๆ: มันให้ความละเอียดประมาณจำนวนบิตเท่า ๆ กันโดยไม่ขึ้นกับขนาดของตัวเลข นั่นคือ ไม่ว่าจะเป็นเลขที่เล็กกว่า 1 มาก ๆ, ใกล้ 1, หรือใหญ่มาก คุณก็คาดหวังความแม่นยำได้พอ ๆ กันในบิตด้านหน้า นี่คือคุณสมบัติสำคัญ แต่ไม่ใช่เรื่องง่ายที่จะซึมซับให้เข้าใจจริง ๆ
เข้ากันได้ดีกับบริบทของบล็อกที่ทีมวิจัย TM เขียนเมื่อเร็ว ๆ นี้ https://news.ycombinator.com/item?id=45200925
ฉันไม่เคยเห็นอะไรที่อธิบายได้ดีขนาดนี้มาก่อน เลยอยากขอบคุณที่แชร์
ปัญหาหนึ่งที่ฉันคิดอยู่นานมากคือ "จะเขียนค่า float ให้เป็นสตริงฐานสิบที่สั้นที่สุดแต่ยังชัดเจนได้อย่างไร" ตัวอย่างเช่น ถ้าใช้ float แบบ single precision จะต้องใช้ความละเอียดฐานสิบสูงสุด 9 หลักเพื่อระบุค่า float นั้นแบบไม่ซ้ำกัน ดังนั้นจึงต้องใช้รูปแบบ printf อย่าง %.9g แต่ถ้าทำแบบนั้น 0.1 จะถูกพิมพ์ออกมาเป็นค่าที่ดูไม่น่ามองอย่าง 0.100000001 เลยปกติจะปัดให้เหลือ 6 หลัก แต่ถ้าใช้ %.6g ค่าฐานสิบที่ป้อนเข้ามาไม่เกิน 6 หลักจะยังพิมพ์กลับออกมาเท่ากับค่าที่เก็บไว้ได้ แต่สำหรับค่าที่เกิดจากผลลัพธ์ของการคำนวณ มันจะไม่ปลอดภัยต่อการ round-trip เรื่องนี้สำคัญมากโดยเฉพาะเวลาเราต้องเปรียบเทียบค่า float แบบตรงเป๊ะ (เช่น ตรวจว่าข้อมูลเปลี่ยนหรือยัง) ไอเดียที่ฉันเคยคิดคือ ลองพิมพ์ออกมาที่ 6 หลักก่อน ถ้า parse กลับแล้วได้ค่าไบนารีเดิมก็ใช้ค่านั้น ถ้าไม่ใช่ค่อยลอง 7 หลัก, 8 หลัก, 9 หลักไปเรื่อย ๆ เพื่อหาการแทนแบบฐานสิบที่สั้นที่สุด อัลกอริทึมของฉันเป็นแบบนี้
ฉันสงสัยว่ามีวิธีหาการแทนที่สั้นที่สุดที่มีประสิทธิภาพกว่านี้ไหม โดยไม่ต้องวน
printf/scanfซ้ำ ๆปัญหานี้สำคัญจริง ๆ มองได้ว่าเป็นปัญหาการทำให้ float หนึ่งค่าเป็นสตริงแบบ "canonical" ภายใต้เงื่อนไขว่าต้องเป็นการแทนที่ใกล้ที่สุด เพราะงั้นจึงมีอัลกอริทึมที่มีประสิทธิภาพหลายแบบ เช่น Dragon4, Grisu3, Ryu, Dragonbox ไลบรารี double-conversion ของ Google ก็ implement สองตัวแรกไว้
มีวิธีที่ดีกว่าโดยไม่ต้องวน
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/types/numeric_limits/max_digits10.htmlไม่มีความหมาย และไม่ควรใช้
sscanf()เด็ดขาด แปลงเป็นจำนวนเต็มแบบไม่มีเครื่องหมายแล้ว serialize/restore จะย้อนกลับได้โดยไม่สูญเสียข้อมูลถ้าต้องการรูปแบบที่สั้นกว่านี้ ก็ใช้ heuristic ที่กู้คืนกลับได้แบบวงกลมปิด แต่ต้องเป็นวิธีที่รับประกันความแม่นยำของต้นฉบับ (เช่น idempotence)
ทิปเกี่ยวกับ FP ที่ฉันชอบที่สุดคือ การเปรียบเทียบ float เกือบจะใช้งานเหมือนการเปรียบเทียบจำนวนเต็มได้ เวลาเช็กว่า a > b ไหม แค่ตีความ a, b เป็นจำนวนเต็มมีเครื่องหมายแล้วเปรียบเทียบได้เลย วิธีนี้ใช้ได้ (เกือบ) ดี พูดอีกแบบคือ ค่า float ถัดไปที่มากกว่าเดิมก็คือเอาแพตเทิร์นบิตไปมองเป็นจำนวนเต็มแล้วบวก 1 ตัวอย่างเช่น ถ้าเริ่มจาก float 0.0 แล้วบวก 1 แบบจำนวนเต็ม นั่นก็คือค่า float ถัดไปทันที (denormal, ค่าจิ๋วที่สุด) หลักการนี้เองที่ใช้ implement
nextafterพอรู้ว่าค่าของ float เรียงลำดับเหมือนกับลำดับการเปรียบเทียบจำนวนเต็ม มันจะรู้สึกเป็นธรรมชาติมากขึ้น แน่นอนว่ามีข้อยกเว้น: NaN, infinity, negative zero ฯลฯ ไม่เหมือนกัน มันมีประโยชน์อยู่บ้าง แต่ใช้ไม่ได้กับทุกอย่างพูดให้ตรงคือ ข้อความนี้ไม่จริงทั้งหมด มันถูกสำหรับจำนวนบวก หรือการเปรียบเทียบระหว่างบวกกับลบ แต่สำหรับจำนวนลบเทียบกันเองจะไม่เหมือน float มาตรฐานใช้รูปแบบ sign-magnitude ส่วนจำนวนเต็มมีเครื่องหมายสมัยใหม่ใช้ two's complement ในช่วงจำนวนลบ ทิศทางของการเปรียบเทียบขนาดจะกลับด้านกัน ถ้าคุณเพิ่มค่า float ทีละ 1 แบบมองเป็น int ปกติแล้วจะเคลื่อนไปยังค่าที่มี "ขนาด" มากขึ้นภายในเครื่องหมายเดิม กล่าวคือ ฝั่งบวกจะเพิ่มขึ้น แต่ฝั่งลบจะลดลงไปทางค่าติดลบมากกว่าเดิม สำหรับจำนวนเต็ม มันจะเพิ่มขึ้นเสมอหรือไม่ก็ overflow ถ้าจะพูดให้แม่นกว่านั้น ควรบอกว่ามันเหมือนกับการเปรียบเทียบจำนวนเต็มแบบ sign-magnitude แน่นอนว่า caveat ที่กล่าวไว้ก็ยังใช้ได้อยู่
เผื่ออ้างอิง อัลกอริทึมเปรียบเทียบขนาด total-order สำหรับ floating point ที่ compare NaN ได้ของ Rust standard library เป็นแบบนี้ (ตามคำแนะนำของ IEEE 751)
ดูอัลกอริทึมเต็ม
ฉันเคยเจอประเด็นนี้จากคอร์ส OMSCS วิชา game AI ของฉัน ซึ่งยกกรณีเรื่องข้อควรระวังเวลานำ floating point มาใช้แทนตำแหน่งของวัตถุในเกม ยิ่งอยู่ไกลจากจุดกำเนิดหรือจุดอ้างอิงมากเท่าไร float ก็ยิ่งต้องใช้ไปกับการเก็บค่าที่ใหญ่ขึ้น ทำให้ความละเอียดลดลงและกลายเป็นความเสี่ยง
น่าสนใจที่ปรากฏการณ์นี้กลายเป็นตำนานแบบ Minecraft อย่าง Far Lands คือยิ่งเดินออกห่างจากจุดกำเนิดของโลกมากเท่าไร การสร้างภูมิประเทศหรือฟิสิกส์ก็จะเริ่มเพี้ยนทีละน้อย และถ้าไปไกลมากจริง ๆ มันจะพังแบบหมดรูป มันให้ความรู้สึกเกือบจะลึกลับแบบอาถรรพ์ เหมือนกฎของความจริงค่อย ๆ สลายไป ทั้งหมดนี้เกิดจากขีดจำกัดด้านความแม่นยำของ float
เวลาบวกเลข float จำนวนมากที่อยู่ในช่วง 0 ถึง 1 ถ้าเทียบระหว่างการบวกเรียงไปทีละตัว กับการจับคู่บวกทีละสองตัวแล้วค่อยรวมผลต่อ วิธีแบบจับคู่จะแม่นยำกว่ามาก นี่เป็นตัวอย่างว่าความคลาดเคลื่อนสะสมของ float ส่งผลรุนแรงได้แค่ไหน ในโลกจริงก็มีกรณีที่ปัญหาจาก float ถูกมองข้ามจนเกิดผลเสีย ใน "The Art of Computer Programming" ของ Donald Knuth มีการอธิบายความจริงพื้นฐานของ float อย่างเช่น a + (b + c) ≠ (a + b) + c ในโลกจริงก็เคยมีปัญหา เช่น ระบบขีปนาวุธ Patriot ที่ใช้ float เก็บเวลาสะสมจนความคลาดเคลื่อนค่อย ๆ สะสมและเล็งเป้าพลาดไปมาก จนต้องรีสตาร์ตระบบ ต้องรีบูตทุก ๆ 24 ชั่วโมง และสุดท้ายซอฟต์แวร์ของระบบก็ถูกแก้ไขเพิ่มเติม ยังมีกรณีที่โครงสร้างขนาดใหญ่พังลงเพราะข้อผิดพลาดจาก float ด้วย (เช่น ค่าความหนาถูกคำนวณออกมาบางเกินไป)
ควรกำหนดกรณีขอบเขตก่อน เพื่อให้รู้ว่าต้องการความแม่นยำระดับไหน จากนั้นก็จะคำนวณระยะต่ำสุด/สูงสุดได้ล่วงหน้า ถ้าโลกมีขนาดใหญ่เกินไป ก็ควรแบ่งเป็นเซกเตอร์หรือแยกพิกัด global/local ออกจากกัน (เช่น 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
ยังไม่มีใครแชร์ในคอมเมนต์นี้ แต่เว็บเรื่อง float ที่ฉันชอบที่สุดคือ https://0.30000000000000004.com/
สำหรับ 32bit float "จำนวนเต็มที่น่าสนใจที่สุด" คือ 16777217 (ส่วน 64 บิตคือ 9007199254740992) เป็นเคสขอบที่รู้ไว้แล้วเอาไปใช้ทดสอบได้สนุกดี
สำหรับ 64-bit float ค่า 9007199254740991 คือ
Number.MAX_SAFE_INTEGERใน JavaScript ค่านี้ไม่ใช่เลขคู่ และค่าถัดไป 9007199254740992 ก็ยังเป็นค่าที่ปลอดภัยในตัวมันเอง แต่ค่าที่ชัดเจนว่าไม่ปลอดภัยอย่าง 9007199254740993 จะถูกปัดจนแยกไม่ออกสำหรับ 64-bit float จริง ๆ คือ ±9,007,199,254,740,993.0 :-) อ้างอิงไว้ว่า ค่าพวกนี้หมายถึงค่าถัดจากขีดจำกัดของจำนวนเต็มที่ float ยังแทนค่าได้ "อย่างแม่นยำ" เช่น ใน 32-bit float หลังจาก ±16,777,216.0 แล้ว ค่าถัดไปที่แทนได้คือ ±16,777,218.0 ค่า ±16,777,217.0 แทนไม่ได้ จึงมักถูกปัดไปทางศูนย์หรือค่าข้างเคียงอื่น ข้อจำกัดด้านความแม่นยำและปัญหาการปัดแบบนี้เป็นเรื่องที่มักถูกมองข้าม
ฉันดีใจที่มี IEEE754 อยู่ แต่ก็คิดว่า IEEE754 ไม่ได้สมบูรณ์แบบ และค่าประเภท posit น่าจะดีกว่าเมื่อสมมติว่าไม่มีการรองรับจากฮาร์ดแวร์ big-num rational (จำนวนตรรกยะแบบหลายหลัก) เหนือกว่าทั้งคู่ แต่ช้าที่สุด
คงจะเจ๋งมากถ้ารองรับ fp8 หลายรูปแบบที่เพิ่งถูกนำมาใช้บน GPU ช่วงหลัง