3 คะแนน โดย GN⁺ 2025-09-13 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • บทความนี้อธิบายว่า ค่า 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
      → เป็นการประเมินค่าตัวเลขผ่านนิพจน์ฐานสอง
  • ใน Base-10 (นิพจน์ประเมินค่าแบบฐานสิบ) จะอยู่ในรูปแบบนี้:
    • 1×​210×​1.4967041015625
      → แสดงเป็นผลคูณของ 2 ยกกำลัง 10 กับส่วนทศนิยม
  • ยังแสดง ค่าเลขฐานสิบที่แม่นยำจริง หลังการแปลงด้วย:
    • แสดงในรูปอย่าง 1.532625×​103
    โฆษณา

การคำนวณระยะห่างจากค่าข้างเคียง (Delta)

  • Delta (ช่วงห่าง) ระหว่างค่าที่สามารถแทนได้มีความสำคัญอย่างมาก
  • มีการให้ค่า ระยะห่างถึงค่าที่แทนได้ถัดไปหรือก่อนหน้า (Delta to Next/Previous Representable Value) แยกกัน
    • ตัวอย่าง: ±1.220703125×​10-4
  • ช่วงห่างนี้เกี่ยวข้องกับ จำนวนหลักนัยสำคัญ/ความแม่นยำ ของค่าจุดลอยตัว

สรุป

  • อธิบาย การแทนค่าในหน่วยความจำ ของ floating-point และหลักการแปลงเป็นฐานสองและฐานสิบ
  • อธิบายโครงสร้าง sign, exponent, significand
  • สรุปทั้ง ช่วงค่าที่แทนได้และข้อมูลช่วงห่างกับค่าข้างเคียง ไว้ด้วย

1 ความคิดเห็น

 
GN⁺ 2025-09-13
ความคิดเห็นจาก 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 หลักไปเรื่อย ๆ เพื่อหาการแทนแบบฐานสิบที่สั้นที่สุด อัลกอริทึมของฉันเป็นแบบนี้

    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 หนึ่งค่าเป็นสตริงแบบ "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 จะย้อนกลับได้โดยไม่สูญเสียข้อมูล

      double f = 0.0/0.0; // ในบางคอมไพเลอร์อาจต้องใช้ soft error flag
      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));
      

      ถ้าต้องการรูปแบบที่สั้นกว่านี้ ก็ใช้ 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)

      let mut left = self.to_bits() as i32;
      let mut right = other.to_bits() as i32;
      
      // สำหรับค่าติดลบ ให้กลับบิตทั้งหมดนอกจากบิตเครื่องหมาย
      // เพื่อให้จัดเรียงคล้ายกับการเปรียบเทียบจำนวนเต็มแบบ two's complement
      
      left ^= (((left >> 31) as u32) >> 1) as i32;
      right ^= (((right >> 31) as u32) >> 1) as i32;
      
      left.cmp(&right)
      

      ดูอัลกอริทึมเต็ม

  • ฉันเคยเจอประเด็นนี้จากคอร์ส 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

    • ฉันเองก็ไล่อ่านคอมเมนต์ดูแล้วว่าใครพูดถึงไปหรือยัง มันเป็นเว็บที่ดีมากจริง ๆ เพียงแต่เว็บที่ OP แนะนำอธิบายโครงสร้างการแบ่งพื้นที่ของสเปซตัวเลขผ่านกราฟได้อย่างเป็นภาพมาก แกนตั้งเป็นสเกลลอการิทึม ส่วนแกนนอนเป็นเชิงเส้นในแต่ละแถว แต่ถูกทำ normalization ให้สอดคล้องกับช่วงลอการิทึม สำหรับคนที่เข้าใจ float อยู่แล้วอาจดูเป็นเรื่องธรรมดา แต่สำหรับคนเพิ่งเริ่มเรียน มันเป็นจุดที่ควรมีคำอธิบายเพิ่ม
  • ยังไม่มีใครแชร์ในคอมเมนต์นี้ แต่เว็บเรื่อง 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 (จำนวนตรรกยะแบบหลายหลัก) เหนือกว่าทั้งคู่ แต่ช้าที่สุด

    • IEEE754 เป็นการประนีประนอมที่ครอบคลุมหลายความต้องการ วิธีทางเลือกบางแบบอาจดีกว่าในบางด้าน แต่ก็แย่กว่าในอีกด้าน
  • คงจะเจ๋งมากถ้ารองรับ fp8 หลายรูปแบบที่เพิ่งถูกนำมาใช้บน GPU ช่วงหลัง