- fast inverse square root ซึ่งโด่งดังจาก Quake 3 คือวิธีแก้ปัญหาด้านประสิทธิภาพในยุคนั้น ที่ประมาณค่า
1 / sqrt(x) อย่างรวดเร็วด้วยการตีความบิตของ float ใหม่และการปรับแก้แบบ Newton-Raphson
- แก่นสำคัญคือรูปแบบบิตแบบจำนวนเต็มของ IEEE-754 32-bit float สามารถถูกมองเหมือนเป็น ค่าประมาณ
log2(x) ที่ถูกสเกลและเลื่อนตำแหน่ง ได้
0x5f3759df - (i >> 1) คือรูปแบบที่ย้าย log2(x^-0.5) = -0.5 * log2(x) มาเป็นการเลื่อนบิตและการลบแบบจำนวนเต็ม ส่วนค่าคงที่มหัศจรรย์มาจาก 3/2 * 2^23 * (127 - σ)
- จากนั้นใช้ การปรับแก้แบบ Newton-Raphson หนึ่งครั้งด้วย
y = y * (1.5 - 0.5x * y * y) และในโค้ด Quake การวนซ้ำครั้งที่สองถูกคอมเมนต์ไว้
- ในปี 1999 inverse square root จำเป็นต้องใช้หลายร้อยถึงหลายพันครั้งต่อวินาทีสำหรับการจัดแสงและการทำ normalization ของเวกเตอร์ 3D แต่บนฮาร์ดแวร์สมัยใหม่ ประโยชน์เชิงปฏิบัติของทริกเดียวกันนี้ลดลงเพราะมีหน่วยประมวลผล floating point เฉพาะทาง
โค้ด Quake ทำอะไร
float Q_rsqrt(float number) {
long i;
float x2, y;
const float threehalfs = 1.5F;
x2 = number * 0.5F;
y = number;
i = *(long*)&y;
i = 0x5f3759df - ( i >> 1 );
y = *(float*)&i;
y = y * ( threehalfs - ( x2 * y * y ) );
return y;
}
- ฟังก์ชันนี้คำนวณค่าประมาณของ inverse square root
1 / sqrt(number) สำหรับ number
- ส่วนที่โด่งดังที่สุดคือการตีความค่า float เหมือนเป็น
long แล้วทำการจัดการบิต 0x5f3759df - (i >> 1)
- ในปี 1999 ที่ Quake 3 เปิดตัว inverse square root เป็นการคำนวณที่ช้าและมีต้นทุนสูง และจำเป็นต้องใช้หลายร้อยถึงหลายพันครั้งต่อวินาทีในการคำนวณเวกเตอร์ 3D ที่ต้องใช้สมการแสงและการทำ normalization
- บนฮาร์ดแวร์สมัยใหม่ การคำนวณแบบนี้อาจไม่ได้ทำบน CPU หรือแม้จะรันบน CPU ก็เร็วขึ้นด้วยฮาร์ดแวร์ floating point เฉพาะทางที่พัฒนามาแล้ว
การแทนค่า IEEE-754 32-bit float
- 32-bit float ประกอบด้วยสามส่วน
- Sign: 1 บิต แสดงว่าเป็นค่าบวกหรือลบ
- Exponent: 8 บิต กำหนดช่วงที่ค่าตกอยู่
- Mantissa: 23 บิต แสดงตำแหน่งภายในช่วงนั้นแบบเชิงเส้น
- ค่าทั่วไปถูกตีความในรูปแบบต่อไปนี้
N = (-1)^S * 2^(E - 127) * (1 + M / 2^23)
B = 127 คือค่า bias ที่ใช้กับ biased exponent และเลขชี้กำลังจริงคือ e = E - B
- mantissa ไม่ได้ใช้โดยการคูณ
m ตรง ๆ แต่ใช้ในรูป 1 + m
- ถ้า
m = 0 จะเป็น 2^e
- เมื่อ
m เข้าใกล้ 1 จะสามารถแทนค่าได้จนเกือบถึงช่วง exponent ถัดไปคือ 2^(e+1)
- ถ้า exponent เป็น 0 ทั้งหมด จะเป็นตัวเลข sub-normal และสูตรจะเปลี่ยนไป
N = (-1)^S * 2^-126 * m
- sub-normal จำเป็นสำหรับการแทนค่า 0 และค่าขนาดเล็กที่ใกล้ 0 มาก ๆ
- ถ้า exponent เป็น 1 ทั้งหมด จะถูกจัดการเป็นค่าพิเศษ
- ถ้า
E = 255, M = 0 จะเป็น Infinity หรือ -Infinity
- ถ้า
M != 0 จะเป็น NaN
ความสัมพันธ์แบบลอการิทึมเมื่อมองบิตของ float เป็นจำนวนเต็ม
- หากมองการแทนค่าภายในของ float เหมือนเป็นจำนวนเต็ม 32 บิต จะเขียนได้เป็นสูตรต่อไปนี้
I_x = 2^31 S + 2^23 E + M
- inverse square root ใช้กับอินพุตที่เป็นบวก ดังนั้นถ้ากำหนด
S = 0 สูตรจะง่ายขึ้น
L = 2^23
I_x = L E + M
- ภายในช่วง exponent เดียวกัน mantissa แสดงตำแหน่งแบบเชิงเส้น แต่เมื่อ exponent เพิ่มขึ้น จำนวนขั้นของ mantissa ที่เท่ากันจะครอบคลุมช่วงบนเส้นจำนวนที่กว้างกว่า
E = 127 หรือ e = 0 อยู่ประมาณช่วง [1, 2)
E = 128 หรือ e = 1 อยู่ประมาณช่วง [2, 4)
- ทั้งสองช่วงมีจำนวนขั้น mantissa เท่ากัน แต่ช่วงที่สองกว้างเป็นสองเท่า
- เพราะโครงสร้างนี้ เมื่อมองรูปแบบบิตดิบของ float เป็นจำนวนเต็ม จึงเกิด ความสัมพันธ์แบบลอการิทึม ขึ้น
บิตดิบคือค่าประมาณของ log2(x)
- หากตีความรูปแบบบิตของ float เป็นจำนวนเต็ม
I_x จะมองได้เหมือนเป็นค่าประมาณเชิงเส้นแบบแบ่งช่วงของ log2(x)
- ความสัมพันธ์นี้เขียนเป็นสูตรประมาณได้ดังนี้
log2(x) ≈ I_x / L - B
- เมื่อนำจำนวนเต็มของบิตดิบไปหารด้วยขนาด mantissa
L = 2^23 แล้วลบ exponent bias B = 127 จะได้ค่าที่ใกล้กับ log2(x)
- ลอการิทึมภายในช่วง mantissa ถูกจัดการเป็นค่าประมาณเชิงเส้น
log2(1 + x) ≈ x + σ
σ คือ พารามิเตอร์ปรับจูน สำหรับปรับค่าประมาณ และ x แสดงตำแหน่งภายในช่วง exponent ในช่วง [0, 1]
เปลี่ยน inverse square root เป็นเอกลักษณ์ลอการิทึม
- เป้าหมายคือการหาค่าต่อไปนี้
y = 1 / sqrt(x)
- สามารถเปลี่ยนเป็นรูปเลขชี้กำลังได้ดังนี้
y = x^-0.5
- เมื่อใช้เอกลักษณ์ลอการิทึม การคำนวณ inverse square root จะกลายเป็นความสัมพันธ์ต่อไปนี้
log2(1 / sqrt(x)) = log2(x^-0.5) = -0.5 * log2(x)
- โดยใช้ข้อเท็จจริงที่ว่าบิตของ float ทำงานเหมือนค่าประมาณของ
log2(x) จึงสามารถประมาณรูปแบบบิตแบบจำนวนเต็ม I_y ของ y ได้โดยตรงจากรูปแบบบิตแบบจำนวนเต็ม I_x ของ x
I_y ≈ -0.5 I_x + 1.5 L (B - σ)
- สูตรนี้นำไปสู่บรรทัดหลักของโค้ด Quake
i = 0x5f3759df - ( i >> 1 );
i >> 1 ทำหน้าที่เลื่อนบิตจำนวนเต็มไปทางขวา 1 บิต ซึ่งเทียบได้กับการคูณด้วย 1/2
- ค่าคงที่ด้านหน้า
0x5f3759df สอดคล้องกับ 1.5 * L * (B - σ)
ตัวตนของค่าคงที่ 0x5f3759df
- ถ้ากำหนด
σ = 0 ค่าคงที่จะคำนวณได้ดังนี้
1.5 * 2^23 * 127 = 1598029824
- ค่าในรูปเลขฐานสิบหกคือ
0x5f400000
- ค่านี้ต่างจากค่าคงที่จริงของ Quake คือ
0x5f3759df อยู่ 566817
- จากความต่างนี้ สามารถคำนวณค่า
σ ที่สอดคล้องกับโค้ด Quake ได้ดังนี้
σ = 377878 / 2^23
σ = 0.04504656
- ใน C สามารถคำนวณค่าคงที่เดียวกันได้ดังนี้
int32_t compute_magic(void) {
double sigma = 0.0450465;
double expression = 1.5 * pow(2.0, 23.0) * (127.0 - sigma);
int32_t i = expression;
return i;
}
// -> 0x5f3759df
- ในที่นี้ใช้
double และการแปลงเป็นจำนวนเต็มเป็นการ cast ตามปกติ ไม่ใช่การตีความบิตใหม่
- ค่า
σ นี้ถูกเลือกเพื่อปรับปรุงค่าประมาณ แต่ไม่ใช่ค่าที่เหมาะที่สุดจริง ๆ และยังไม่แน่ชัดว่าใครเป็นผู้สร้าง
เหตุผลที่ไม่ใช่แค่แฮ็กง่าย ๆ
0x5f3759df - (i >> 1) คือสูตรที่ใช้ข้อเท็จจริงว่าบิตดิบของ float เป็นค่าประมาณลอการิทึม เพื่อสร้าง ค่าเริ่มต้นของ inverse square root
- แม้จะอิงกับความสัมพันธ์ทางคณิตศาสตร์ที่ซับซ้อน แต่ในขั้นตอนการรันจริงใช้เพียงการดำเนินการที่เร็ว เช่น การเลื่อนบิตและการลบ
- ในยุคนั้นต้องประมวลผลการคำนวณราคาแพงหลายพันครั้งต่อวินาที วิธีนี้จึงเป็นการออกแบบเชิงวิศวกรรมที่เหมาะกับข้อจำกัดของฮาร์ดแวร์
- อย่างไรก็ตาม อัลกอริทึมนี้ทำงานเฉพาะกับ normal float เท่านั้น
- ค่า sub-normal ไม่เป็นไปตามสมมติฐานของค่าประมาณ
log2(1 + x) ≈ x + σ
- ใน sub-normal จะมีรูปแบบที่ใกล้กับ
0 + x จริง ๆ ทำให้ค่าประมาณพังลง
ลดความคลาดเคลื่อนด้วยการปรับแก้แบบ Newton-Raphson
- ค่าเริ่มต้นที่ได้จากการจัดการบิตค่อนข้างดี แต่ยังมีความคลาดเคลื่อนที่วัดได้อยู่
- บรรทัดต่อไปนี้ช่วยปรับปรุงค่าประมาณได้อย่างมาก
y = y * ( threehalfs - ( x2 * y * y ) );
- บรรทัดนี้เป็นรูปแบบที่ใช้ Newton-Raphson method
- เพื่อปรับปัญหา inverse square root ให้เข้ากับ Newton method จึงเปลี่ยนเป็นปัญหาการหารากของฟังก์ชันต่อไปนี้
f(y) = 1 / y^2 - x = 0
- Newton method จะสร้างค่าประมาณที่ดีกว่า
y_(n+1) จากค่าประมาณปัจจุบัน y_n ดังนี้
y_(n+1) = y_n - f(y_n) / f'(y_n)
- อนุพันธ์ของ
f(y) = y^-2 - x คือ
f'(y) = -2y^-3 = -2 / y^3
สูตรปรับแก้ Newton ที่ไม่ใช้การหาร
- หากใช้สูตร Newton ตรง ๆ จะมีการหาร floating point หลายครั้ง
- หนึ่งในเหตุผลที่อัลกอริทึมนี้เร็วคือ การหลีกเลี่ยงการหาร floating point
- เมื่อจัดรูปทางพีชคณิต จะได้รูปที่ใช้เฉพาะการคูณโดยไม่ต้องหาร
y_(n+1) = y_n * (1.5 - 0.5x * y_n^2)
- ในโค้ด Quake มีการคำนวณ
0.5x ไว้ล่วงหน้าด้วย x2 = number * 0.5F และใช้ในบรรทัดต่อไปนี้
y = y * ( threehalfs - ( x2 * y * y ) );
- หลังจากวนซ้ำหนึ่งครั้ง ค่าคลาดเคลื่อนสัมบูรณ์สูงสุดคือ 0.175% และในหลายกรณีค่าคลาดเคลื่อนต่ำกว่านั้น
- ในโค้ดต้นฉบับมีการวนซ้ำ Newton ครั้งที่สอง แต่ถูกคอมเมนต์ไว้
// y = y * ( threehalfs - ( x2 * y * y ) ); // 2nd iteration, this can be removed
ที่มาและอัลกอริทึมที่เกี่ยวข้อง
- อัลกอริทึมนี้ไม่ได้ถูกคิดค้นโดย John Carmack และที่มาที่แน่ชัดก็ไม่มั่นใจได้ 100%
- มีบทความของ Beyond3D ที่เกี่ยวข้อง: The truth is the exact origin is not 100% certain
- Chris Lomont เขียนบทความวิชาการเพื่อหา ค่า sigma ที่เหมาะที่สุด ในขั้นตอนการประมาณลอการิทึม: InvSqrt.pdf
- CORDIC เป็นอัลกอริทึมที่คำนวณ sine และ cosine ด้วยการบวกและการเลื่อนบิตเท่านั้นโดยไม่ใช้ floating point และมีรายละเอียดวิธีการต่างจาก fast inverse square root อย่างมาก
- อัลกอริทึมทั้งสองมีจุดร่วมคือการนำข้อสังเกตทางคณิตศาสตร์มาประยุกต์ใช้อย่างมีประสิทธิภาพให้เข้ากับข้อจำกัดของฮาร์ดแวร์ในยุคนั้น
2 ความคิดเห็น
โค้ดชวนทึ่งที่โผล่ขึ้นมาทุกครั้งพอเริ่มจะลืม..ฮ่า
ความคิดเห็นจาก Hacker News
คอมพิวเตอร์ที่ผลิตหลังปี 1999 โดยทั่วไปจะรองรับ ชุดคำสั่ง SSE และในนั้นมี
_mm_rsqrt_psที่คำนวณส่วนกลับของรากที่สองได้ 4 ค่าในครั้งเดียวเร็วกว่า: https://www.intel.com/content/www/us/en/docs/intrinsics-guid...ถึงอย่างนั้น เทคนิคที่พูดถึงในที่นี้ก็ยังไม่ได้ไร้ความหมายไปเสียทีเดียว การแปลง float/int นั้นเร็ว แต่ยังมีฮาร์ดแวร์ที่ไม่มีคำสั่ง
rsqrt,sqrt,pow,logอยู่ และการคำนวณเหล่านี้สามารถประมาณค่าได้ด้วยทริกนี้sqrt(x)เป็นx * 1/sqrt(x)จะเร็วกว่าในชุดคำสั่ง GPU, ARM, RISC-V, AVR, PIC, 8051, FPGA ฯลฯ มักมีการดำเนินการประมาณค่าส่วนกลับของรากที่สองในตัว แต่ก็น่าจะเป็นไปได้สูงว่าใช้อัลกอริทึมทำนองนี้ในการ implement
ถ้าจะจับผิดบทความนิดหน่อย คำอธิบายที่สื่อว่าการคำนวณแบบนี้ไม่ได้เกิดขึ้นบน CPU ยุคนี้แล้วนั้นไม่ถูกต้อง เป็นความเข้าใจผิดที่พบบ่อยว่าเกมหรือแอปที่มีการคำนวณ floating-point หนัก ๆ อยากส่งการคำนวณ floating-point ทั้งหมดไปให้ GPU
ในความเป็นจริง งานที่เหมาะจะส่งไป GPU คือเฉพาะงานขนาดใหญ่และสม่ำเสมอเท่านั้น ถ้าเป็น การ normalize เวกเตอร์แบบครั้งเดียว เช่น การสร้างเมทริกซ์หมุนเพื่อให้อ็อบเจกต์หนึ่งหันไปมองอีกอ็อบเจกต์หนึ่ง ปล่อยไว้บน CPU จะเร็วกว่า ต่อให้ไม่นับเวลาส่งข้อมูลไป GPU การคำนวณ floating-point เดี่ยว ๆ ก็ยังเร็วกว่าเมื่อทำบน CPU เพราะ GPU มักมี clock ต่ำกว่า และได้จำนวน FLOP สูงจาก parallelism
ลองเขียน implementation สำหรับ MMIX แล้ว และตั้งสมมติฐานว่า input เดิมมีค่ามากกว่า
2^-1021ถ้าสนใจ Wikipedia ก็มีคำอธิบายที่ดีเกี่ยวกับฟังก์ชันนี้และประวัติของมัน: https://en.wikipedia.org/wiki/Fast_inverse_square_root
ผมรวบรวมของพวกนี้ไว้หลายอย่าง: https://github.com/ncruces/fastmath/blob/main/fast.go
มีโพสต์ StackOverflow ที่เกี่ยวข้องด้วย: https://stackoverflow.com/questions/32042673/optimized-low-a...
fastmathด้วยได้เวลาจับผิดแล้ว มี typo ในสูตร float และควรเป็น
(-1)^Sไม่ใช่-1^Sแบบหลังจะได้-1เสมอคำอธิบายที่ว่าการตีความ raw bit pattern เป็นการประมาณเชิงเส้นเป็นช่วง ๆ ของลอการิทึมก็ไม่ถูกต้องเช่นกัน เส้นระหว่างจุดข้อมูลในกราฟสีน้ำเงินไม่ได้มีอยู่จริง และบิตไม่สามารถตั้งเป็น 1 ได้แค่ครึ่งเดียว มันใกล้เคียงกับ เวอร์ชันแบบไม่ต่อเนื่อง ของลอการิทึมมากกว่า และจุดข้อมูลที่มีอยู่จริง ซึ่งก็คือจุดที่เส้นสีแดงกับสีน้ำเงินตัดกันนั้น แท้จริงแล้วเท่ากับลอการิทึมที่ถูกสเกลและเลื่อนตำแหน่งพอดี นอกนั้นเป็นบทความที่ดี
[010000, 010111]จะบรรจุค่า 2, 2.25, 2.5, 2.75, 3, 3.25, 3.5, 3.75แต่ significand ที่ implied จากลอการิทึมฐาน 2 ของตัวเลขเหล่านี้คือ
.0000000,.0010101,.0101001,.0111010,.1001010,.1011001,.1100111,.1110100ตามลำดับ และยกเว้นตัวแรกแล้วไม่เหมือนกับ001,010ฯลฯ ของ float เลย เพราะ float ในช่วง[2,4)มีระยะห่างแบบเชิงเส้น แต่ลอการิทึมที่สอดคล้องกันไม่เป็นแบบนั้น จึงมองได้ว่า float เป็น การประมาณเชิงเส้นเป็นช่วง ๆ ของลอการิทึมตามที่บทความว่าไว้ถ้าเป็นกราฟเต็ม ๆ ก็จะมีตัวเลือก
2^32ค่าอยู่ในแพตเทิร์นเชิงเส้นเป็นช่วง ๆ แต่สิ่งที่บทความต้นฉบับวาดไม่ใช่กราฟเต็มแบบนั้น ในเมื่อบทความพูดถึงจำนวนเต็ม 32 บิตและการคำนวณ IEEE-754 32-bit float ผมคิดว่าการละคำว่า “ไม่ต่อเนื่อง” ในคำอธิบายก็พอรับได้เป็นบทความที่ดีและอธิบายแนวคิดน่าสนใจไว้มาก แต่ การแปลงพีชคณิต ในส่วนหนึ่งแย่อย่างน่าตกใจ
หลังข้อความ “ขั้นตอนที่แน่นอนจากรูปแรกไปยังรูปนี้มีหลายขั้น แต่เพื่อความครบถ้วนจึงใส่ไว้ทั้งหมด” การแปลงมีขั้นตอนที่ไม่จำเป็นเยอะ และมีข้อผิดพลาดเรื่องเครื่องหมายที่หักล้างกันเองหลายจุด โดยเฉพาะตอนจากบรรทัดที่สองไปบรรทัดที่สาม เครื่องหมายลบไม่ได้ถูกแจกแจงอย่างถูกต้อง ถ้าเริ่มหลังบรรทัดที่สอง ก็สามารถเริ่มจาก
y_n+1 = y_n + (1 - x * y_n^2) / y_n^2 * (y_n^3 / 2)แล้วไปถึงy_n+1 = y_n (1.5 * y_n - 0.5 * x * y_n * y_n)ได้สั้นกว่ามาก และขั้นตอนกลางก็ถูกต้องด้วย ผมคิดว่ามีแต่ขั้นตอนที่ชัดเจนอยู่แล้วสำหรับคนที่เข้าใจพีชคณิตmagic number ในโค้ดสั้น ๆ อันโด่งดังนั้นไม่ใช่ค่าคงที่ที่เหมาะที่สุด ถ้าใช้ค่าคงที่อื่น น่าจะลด relative error ได้อีกราว 0.5%
ตอนนั้นการหาค่าที่เหมาะที่สุดแบบสัมบูรณ์อาจยาก แต่ตอนนี้ค่อนข้างง่าย ผมเองก็เคยหลงลง rabbit hole นี้อยู่พักหนึ่ง และมี Jupyter notebook สำหรับหา magic number ที่เหมาะที่สุดของ
(1/x^2)กับ(1/x)สิ่งที่น่าสนใจที่สุดสำหรับผมในบทความนี้คือลิงก์ “How Java's Floating-Point Hurts Everyone Everywhere”: https://people.eecs.berkeley.edu/~wkahan/JAVAhurt.pdf
ผู้เขียนคือ William Kahan ซึ่งเป็นที่รู้จักในชื่อ “Old Man of Floating-Point” ด้วย: https://news.ycombinator.com/item?id=29042853 - An Interview with the Old Man of Floating-Point (1998)
JAVAhurtแล้วพบว่า การจัดตัวอักษรแย่มาก เหมือนใช้แพ็กเกจ TeX ที่ถ่างช่องว่างระหว่างคำมากเกินไป แถมไม่สม่ำเสมอ หรือเหมือน OCR เอกสารอื่นแล้วมีช่องว่างเกินติดมาแม้แต่ส่วนที่เป็นฟอนต์ monospace ก็มีช่องว่างแปลก ๆ เพิ่มเข้ามา อ่านแล้วมีสมาธิยากมาก และถึงจะรู้ว่าไม่ใช่จริง ๆ แต่แทบให้ความรู้สึกเหมือนแถลงการณ์ของพวก geek สายวิทย์เลย
วิดีโอนี้ที่เคยดูเมื่อก่อนดีมากจริง ๆ: https://www.youtube.com/watch?v=p8u_k2LIZyo