สร้างยูทิลิตีบีบอัดแบบใช้รหัส Huffman ด้วย Haskell
(lazamar.github.io)- สาธิตการสร้างตัวบีบอัด Huffman coding ด้วย Haskell ราว 150 บรรทัด พร้อมโครงสร้างที่รองรับการเข้ารหัสและถอดรหัสไฟล์ไบนารีแบบกำหนดเองโดยใช้หน่วยความจำคงที่
- รหัส Huffman จะกำหนดลำดับบิตสั้นให้กับค่าที่พบบ่อย และใช้เงื่อนไข prefix-free code เพื่อให้ตัวถอดรหัสตีความลำดับบิตได้อย่างไม่กำกวม
- การติดตั้งใช้
FreqMap,HTree,CodeMapเพื่อสร้างต้นไม้จากตารางความถี่ และอาศัย lazy evaluation ของconcatMapกับตัวถอดรหัสแบบเรียกซ้ำเพื่อสร้างผลลัพธ์แบบค่อยเป็นค่อยไป - ไฟล์ไบนารีถูกจัดการผ่าน
Data.ByteString.Char8โดยมองไบต์เป็นCharและเก็บ ตารางความถี่ ไว้ก่อนข้อมูลบีบอัด พร้อมแพดลำดับบิตให้ครบระดับไบต์ก่อนบันทึก - ในการทดสอบ War and Peace ลดจาก 3.2M เหลือ 1.9M ส่วนไบนารี
ghcupขนาด 106M ลดเหลือ 84M และพบว่า maximum resident set size ต่ำกว่า 300KB
ไอเดียพื้นฐานของการบีบอัดด้วยรหัส Huffman
- เป้าหมายคือสร้างยูทิลิตีบีบอัดข้อมูลด้วย Huffman coding โดยใช้ Haskell ราว 150 บรรทัด
- โค้ดทั้งหมดเผยแพร่อยู่ใน GitHub repository
- รหัส Huffman จะกำหนด ลำดับบิต ที่ไม่ซ้ำกันให้กับอักขระหรือค่าแต่ละตัว
- ค่าที่พบได้บ่อยมักใช้ลำดับบิตที่สั้นกว่า
- ค่าที่พบได้น้อยจะใช้ลำดับบิตที่ยาวกว่า
- การที่ค่าที่พบบ่อยถูกแทนด้วยบิตน้อยกว่ารูปแบบเดิมทำให้เกิดการบีบอัด
- ตัวอย่าง
aaabหากกำหนดa = 1,b = 0จะได้ผลลัพธ์เป็น1110- เป็นตัวอย่างที่แสดงให้เห็นว่าสตริงซึ่งต้องใช้ 4 ไบต์ใน UTF-8 สามารถแทนได้ด้วยครึ่งไบต์
Prefix-free code และต้นไม้ Huffman
- เพื่อให้การถอดรหัสไม่กำกวม รหัสคำใด ๆ ต้องไม่เป็น คำนำหน้า ของรหัสคำอื่น
- ตัวอย่างเช่น ใน
aaabcหากกำหนดa = 1,b = 10,c = 01ค่า101อาจตีความเป็นacหรือbaก็ได้
- ตัวอย่างเช่น ใน
- prefix-free code สามารถสร้างเป็นต้นไม้ทวิภาคแบบสมบูรณ์ได้
- วางค่าทั้งหมดไว้ที่ใบของต้นไม้
- กำกับเส้นเชื่อมซ้ายเป็น
1และขวาเป็น0 - เส้นทางจากรากไปยังใบจะกลายเป็นรหัสคำของค่านั้น
- ต้นไม้ Huffman ถูกสร้างโดยค่อย ๆ รวม ค่าที่มีความถี่ต่ำกว่า จากด้านล่างขึ้นมา
- สร้างโหนดของแต่ละอักขระพร้อม weight ซึ่งคือจำนวนครั้งที่ปรากฏ
- นำสองโหนดที่มี weight ต่ำสุดมารวมเป็นต้นไม้เดียว
- weight ของต้นไม้ใหม่คือผลรวมของ weight ของทั้งสองโหนด
- ทำซ้ำจนเหลือต้นไม้เพียงต้นเดียว
- กระบวนการนี้ทำให้ค่าที่ปรากฏบ่อยกว่าอยู่ใกล้รากมากขึ้นและได้รหัสคำที่สั้นกว่า
โครงสร้างตัวเข้ารหัสใน Haskell
- ชนิดข้อมูลหลักของการติดตั้งคือ
Bit,Code,FreqMap,CodeMap,Weight,HTreeBitคือOneหรือZeroCodeคือ[Bit]FreqMapคือMap Char Intสำหรับเก็บจำนวนครั้งที่อักขระแต่ละตัวปรากฏCodeMapคือMap Char Codeสำหรับเก็บรหัสคำของอักขระแต่ละตัวHTreeคือLeaf Weight CharหรือFork Weight HTree HTree
HTreeถูกทำให้เปรียบเทียบตาม weight ได้ เพื่อให้การจัดเรียงและแทรกระหว่างการสร้างต้นไม้ง่ายขึ้นcountFrequencyใช้คำนวณจำนวนครั้งที่อักขระแต่ละตัวปรากฏในสตริงbuildTreeจะแปลงFreqMapเป็นรายการใบ จากนั้นเรียงลำดับและรวมสองโหนดที่เล็กที่สุดซ้ำ ๆ เพื่อสร้าง ต้นไม้ HuffmanbuildCodesจะเดินต้นไม้และต่อOneทางซ้ายZeroทางขวาเพื่อสร้างรหัสคำของอักขระแต่ละตัวencode :: FreqMap -> String -> [Bit]จะสร้างต้นไม้และ code map จากFreqMapแล้วแทนที่อักขระแต่ละตัวในสตริงอินพุตด้วยรหัสคำเพื่อสร้างรายการบิต
การประมวลผลแบบค่อยเป็นค่อยไปด้วย lazy evaluation
- การแปลงหลักของการเข้ารหัสคือ
concatMap codeFor str- ในเชิงแนวคิดคือแปลง
[Char]เป็น[[Bit]]แล้ว flatten กลับเป็น[Bit] - ด้วย lazy evaluation ของ Haskell มันจึงไม่ทำงานแบบเข้ารหัสอินพุตทั้งหมดก่อนแล้วค่อยรวมทีเดียว
- ในเชิงแนวคิดคือแปลง
- ลิสต์ขนาดเล็กจะถูกประมวลผลจากซ้ายไปขวาและ flatten ลงในลิสต์ผลลัพธ์ขนาดใหญ่
- tail ของลิสต์ผลลัพธ์จะยังคงเป็น thunk ที่ยังไม่ถูกประเมิน
- ส่วนถัดไปจะถูกคำนวณเมื่อมีการร้องขอค่าที่จำเป็น
- ตัวถอดรหัสก็สร้างผลลัพธ์แบบค่อยเป็นค่อยไปในลักษณะเดียวกัน
decode :: FreqMap -> [Bit] -> Stringจะเดินซ้ายหรือขวาในต้นไม้ตามค่าบิต- เมื่อถึงใบก็จะส่งออกอักขระและเริ่มใหม่จากราก
- ทำซ้ำจนกว่าจำนวนอักขระที่ถอดรหัสได้ทั้งหมดจะเท่ากับ weight ของต้นไม้ Huffman
- ตัวถอดรหัสหยุดโดยอิง จำนวนอักขระ ไม่ใช่จุดสิ้นสุดของรายการบิตอินพุต
- เพราะในขั้นตอน serialization มีการเติมบิตแพดด้านท้ายเพื่อให้จัดแนวเป็นระดับไบต์
- ฟังก์ชัน
goเมื่อถึงใบจะคืนลิสต์ที่รู้ head แล้วพร้อมกับการเรียกซ้ำที่อยู่ใน tail จึงสามารถประเมินผลลัพธ์ได้ก่อนที่การเรียกซ้ำทั้งหมดจะเสร็จสิ้น
การจัดการไฟล์ไบนารีและการทำ serialization
- ข้อมูลไบนารีสามารถมองได้ว่าเป็นการซ้ำกันของหนึ่งใน 256 ค่าไบต์ที่เป็นไปได้
Data.ByteString.Char8ช่วยให้จัดการByteStringด้วยการดำเนินการแบบCharได้ โดยCharทุกตัวจะถูกตัดให้เหลือ 8 บิต- คุณสมบัตินี้ทำให้สามารถนำตัวเข้ารหัสสำหรับข้อความมาใช้กับ ข้อมูลไบนารี ได้โดยแทบไม่ต้องแก้มาก
- ในไฟล์บีบอัดจะเก็บ
FreqMapที่จำเป็นต่อการถอดรหัสไว้ก่อน แล้วตามด้วยลำดับบิตที่เข้ารหัสแล้ว serializeFreqMapจะบันทึกตารางความถี่ในรูปแบบดังนี้- ความยาวของแมปถูกเก็บเป็น
Word8แต่ลบ 1 ก่อนเก็บเพื่อชดเชยช่วงค่าที่แทนได้ - แต่ละรายการจะเก็บคีย์เป็น
Word8และค่า frequency เป็นจำนวนเต็ม 64 บิตแบบ big-endian
- ความยาวของแมปถูกเก็บเป็น
serializeใช้Putmonad ของแพ็กเกจbinaryเพื่อสร้างByteString- อ่านบิตทีละตัวเพื่อเติมหนึ่งไบต์ให้ครบ
- เมื่อครบ 8 บิตก็เขียนด้วย
putWord8 - ไบต์สุดท้ายจะเติมพื้นที่ที่เหลือด้วย
Zero
การทำ deserialization และกลยุทธ์หน่วยความจำคงที่
deserializeFreqMapใช้Data.Binary.Getเพื่ออ่านตารางความถี่ที่ถูก serialize ไว้- เริ่มจากอ่านความยาวแล้วบวก 1 เพื่อคำนวณจำนวนรายการจริง
- จากนั้นอ่านคีย์
Word8และค่า frequency 64 บิตของแต่ละรายการเพื่อกู้คืนFreqMap
- อินพุตส่วนที่เหลือจะไม่ถูกประมวลผลด้วย
Getทั้งหมด แต่ดึงส่วนหลัง offset ออกจากByteStringแล้วแปลงเป็นรายการบิตแทน deserializeจะคืนค่า(FreqMap, [Bit])โดย[Bit]เป็น ลิสต์แบบ lazy ที่ไม่ได้ถูกคำนวณทั้งหมดในทันที- จึงควรหลีกเลี่ยงการขอความยาวของลิสต์นี้ เพราะจะบังคับให้ต้องประเมินทั้งลิสต์
- เหตุผลที่ไม่ใช้
Getกับอินพุตทั้งหมดคือ bind ของ monad จะบังคับลำดับการประมวลผล- ทำให้โครงสร้างกลายเป็นว่าต้องประมวลผลอินพุตทั้งหมดให้เสร็จก่อนจึงจะคืนลิสต์ได้
- กลยุทธ์หน่วยความจำคงที่คือประเมินอินพุตทีละส่วนเล็กถัดไปทุกครั้งที่ต้องเขียนบิตเอาต์พุต
ByteStringบางส่วนจะถูกประเมินและชิ้นไฟล์ส่วนนั้นจะถูกอ่าน- เอาต์พุตที่ประมวลผลแล้วจะถูกเขียนลงไฟล์
- ชิ้นอินพุตและรายการบิตที่ไม่ถูกอ้างถึงแล้วสามารถถูกเก็บกวาดโดย garbage collector ได้
FreqMapมีได้มากสุด 256 รายการในกรณีของไบต์ จึงมี overhead ขนาดคงที่
CLI สำหรับบีบอัดและคลายบีบอัดไฟล์
compressจะอ่านไฟล์อินพุตสองรอบ- รอบแรกสร้าง
FreqMap - รอบที่สองเข้ารหัสข้อมูลด้วย
FreqMapนั้น
- รอบแรกสร้าง
- หากอ่านไฟล์เพียงครั้งเดียวแล้วส่ง reference เดิมให้
encodeก็จะต้องเก็บ reference ถึงไฟล์อินพุตทั้งหมดไว้แม้หลังสร้างตารางความถี่เสร็จแล้ว ทำให้ต้องถือไฟล์ทั้งก้อนไว้ในหน่วยความจำ - วิธีอ่านสองรอบช่วยให้ทั้งตอนสร้างตารางความถี่และตอนเข้ารหัสสามารถปล่อยหน่วยความจำของส่วนที่ประมวลผลไปแล้วได้ระหว่างทาง
decompressจะอ่านไฟล์บีบอัด ใช้deserializeเพื่อดึงFreqMapกับรายการบิตออกมา แล้วบันทึกผลdecodeลงไฟล์- CLI รับอาร์กิวเมนต์ดังนี้
compress FILE FILEdecompress FILE FILE
- ใช้เฉพาะแพ็กเกจที่มาพร้อมกับ GHC จึงคอมไพล์ได้ด้วย
ghc -O2 Main.hs -o mainโดยไม่ต้องใช้cabal
ผลการรันและการใช้หน่วยความจำ
- ในการทดสอบกับไฟล์ข้อความ War and Peace ของ Tolstoy หลังบีบอัดและคลายบีบอัดแล้ว ผล
diffตรงกัน- ต้นฉบับ
WarAndPeace.txt: 3.2M - ไฟล์บีบอัด: 1.9M
- ไฟล์ที่คลายบีบอัดแล้ว: 3.2M
- ขนาดลดลงประมาณ 40%
- ต้นฉบับ
- การบีบอัดและคลายบีบอัดทำงานได้กับไฟล์ไบนารีขนาดใหญ่
ghcupเช่นกัน- ต้นฉบับ
ghcup: 106M - ไฟล์บีบอัด: 84M
- ไฟล์ที่คลายบีบอัดแล้ว: 106M
- เวลาในการบีบอัดประมาณ 15.173 วินาที และเวลาในการคลายบีบอัดประมาณ 14.555 วินาที
- ต้นฉบับ
- ตาม
+RTS -sค่า maximum resident set size ระหว่างประมวลผลghcupต่ำกว่า 300KB - ทั้งสองโปรเซสใช้หน่วยความจำต่ำกว่า 10MB ในการทำงาน
- จุดที่ใช้เวลาอยู่สามารถดูได้จาก โปรไฟล์ แยกต่างหาก
จุดที่ยังปรับปรุงได้เพิ่มเติม
- เป้าหมายของการติดตั้งนี้คือยูทิลิตีบีบอัดที่เรียบง่ายและชัดเจนที่สุดเท่าที่ทำได้ และหากต้องการประสิทธิภาพที่สูงขึ้นก็จำเป็นต้องเพิ่มความซับซ้อนของการติดตั้ง
- แนวทางที่อาจปรับปรุงได้มีดังนี้
- Multithreading: ถอดรหัสช่วงต่าง ๆ ของไฟล์แบบขนาน แต่เนื่องจากไม่สามารถรู้ขอบเขตรหัสคำจากตำแหน่งสุ่มได้ จึงต้องเพิ่มตารางขอบเขตช่วงและขนาดที่คาดว่าจะถอดรหัสได้ไว้ด้านหน้าของไฟล์บีบอัด
- Single-pass encoding: เริ่มจากตารางความถี่ตั้งต้นที่ทุกไบต์มีความถี่ 1 แล้วเมื่อพบไบต์แต่ละตัวก็เข้ารหัสก่อน จากนั้นค่อยอัปเดตตารางความถี่
- Canonical Huffman codes: แทนที่จะไล่ต้นไม้เพื่อถอดรหัสใน
O(log n)สามารถใช้รหัสเป็นดัชนีของเวกเตอร์เพื่อหวังการเข้าถึงแบบO(1)ได้ โดยดูรายละเอียดเพิ่มเติมได้ที่ Canonical Huffman code - การสร้างรหัสที่เร็วขึ้น: ในการเข้ารหัสแบบ single-pass จำเป็นต้องสร้าง
CodeMapให้เร็วขึ้นมาก และยังมีวิธีสร้างรหัสคำที่เร็วกว่าโดยไม่ต้องสร้างต้นไม้
- ในอนาคต หากใช้ LZ77 ซึ่งเป็นวิธีพจนานุกรมแบบปรับตัวได้ร่วมกับรหัส Huffman ก็จะสามารถสร้าง gzip ได้
1 ความคิดเห็น
ความคิดเห็นบน Hacker News
สำหรับงานนี้มี อัลกอริทึมแบบทำในที่เดิมโดยใช้อาร์เรย์ ที่ช่วยลดการจัดสรรทรีและการไล่ตามพอยน์เตอร์
ตอนเรียนแนวทางแบบใช้ทรีในมหาวิทยาลัย ผมไม่รู้ว่ามีวิธีอื่นอยู่ และสงสัยว่าคนอื่น ๆ ก็เป็นแบบนั้นหรือเปล่า
วิธีแบบทรีนั้นเข้าใจได้ง่ายและช่วยให้เห็นภาพ แต่สถานการณ์ที่การบีบอัดสำคัญที่สุดมักเป็นตอนที่มีข้อมูลจำนวนมากและอยากให้ทำงานเร็ว ดังนั้นการจัดการด้วยอาร์เรย์แบบ in-place อาจสมเหตุสมผลกว่า
In-Place Calculation of Minimum-Redundancy Codes, Moffat, Katajainen, 1995
http://hjemmesider.diku.dk/~jyrki/Paper/WADS95.pdf
Charles Bloom แนะนำอย่างหนักแน่น และภายหลังก็เขียนคำอธิบายเพิ่มเติมไว้ด้วย
https://cbloomrants.blogspot.com/2010/08/08-12-10-lost-huffm...
คำกล่าวที่ว่า “ต้องทำให้ไม่มี code word ใดเป็น prefix ของ code word อื่น จึงจะไม่กำกวม” นั้นพูดอย่างเคร่งครัดแล้วไม่ถูกต้อง
สิ่งที่เรียกว่า รหัสที่ถอดรหัสได้อย่างเอกเทศ นั้นไม่กำกวม และเป็นเซตที่กว้างกว่ารหัส prefix
ตัวอย่างง่าย ๆ คือรหัสที่กลับด้านจากรหัส prefix ถ้าใช้ตัวอย่างในบทความก็จะเป็น
a 1,b 00,c 10แม้รหัสของ
aจะเป็น prefix ของรหัสcแต่ถ้าประมวลผลลำดับรหัสย้อนกลับ ก็ยังถอดรหัสได้โดยไม่กำกวมอยู่ดี คงน่าสนใจถ้าได้เห็นรหัสที่ถอดรหัสได้อย่างเอกเทศซึ่งไม่ใช่ทั้งรหัส prefix และไม่ใช่แบบย้อนกลับของมันให้
A 0,B 01,C 11แล้วกำหนดต่อเป็นa A 0,b BA 010,c BB 0101,d BC 0111,e C 11ก็จะได้{a=0,b=010,c=0101,d=0111,e=11}เห็นได้ชัดว่าถอดรหัสได้อย่างเอกเทศ เพราะถอดรหัสจากด้านหลังแบบเอกเทศเช่น
0->Aแล้วถอดรหัสอีกครั้งจากด้านหน้าแบบเอกเทศเช่นA->aก็พอในแง่ความยาว มันเท่ากับรหัส prefix ที่เหมาะที่สุด
{a=0,b=110,c=1110,d=1111,e=10}ดังนั้นจึงเป็นหนึ่งในหลายรหัสที่เหมาะที่สุดสำหรับการแจกแจงความน่าจะเป็นเดียวกันขณะเดียวกัน เพราะมี
a=0,b=010จึงไม่ใช่ทั้งรหัส prefix และรหัส suffix จริง ๆ แล้วโดยทั่วไปไม่สามารถถอดรหัสแบบค่อยเป็นค่อยไปได้จากทิศทางใดเลย และในการแยกแยะcee...ee?กับbee...ee?,?cc...ccaกับ?cc...ccbอาจต้องมองล่วงหน้าอย่างไม่มีที่สิ้นสุดแม้เพียงเพื่อระบุสัญลักษณ์เดียวผมไม่รู้ว่าถ้านำรหัส prefix ที่เหมาะที่สุดโดยอิสระกับรหัส suffix ที่เหมาะที่สุดโดยอิสระมาประกอบกัน จะยังคงความเหมาะที่สุดเสมอหรือไม่ แต่ในกรณีที่ง่ายที่สุดที่นึกออก ยกเว้นรหัส 1:1 แบบเสื่อมสภาพ มันทำงานได้ดี
a 101,b 1เป็นไปได้แต่นั่นเป็นรหัสที่แย่ เพราะกำหนดเป็น
a=1,b=0เสมอย่อมดีกว่าอสมการ Kraft บอกเซตของความยาวรหัสที่สามารถทำให้ถอดรหัสได้อย่างเอกเทศ และการเข้ารหัส Huffman ก็ทำให้ได้ทุกเซตเช่นนั้น ดังนั้นถ้ากำลังเข้ารหัสสัญลักษณ์ ก็ไม่มีเหตุผลให้ใช้รหัสที่ไม่ใช่ prefix เว้นแต่จะเปลี่ยนไปใช้วิธีอื่นอย่าง ANS หรือ arithmetic coding
อย่างไรก็ดี ผมไม่รู้ว่ามีรหัสที่ถอดรหัสได้อย่างเอกเทศ ซึ่งมีเซตความยาวเดียวกับรหัส Huffman ที่เหมาะที่สุด แต่ไม่ใช่ทั้งรหัส prefix และไม่ใช่รหัส suffix ซึ่งเป็นแบบกลับด้านของมันหรือไม่
ถ้ามีเวลา ผมน่าจะดู https://en.wikipedia.org/wiki/Sardinas-Patterson_algorithm แล้วลองหา counterexample แบบ brute force หรือคิดบทพิสูจน์จากวิธีทำงานของอัลกอริทึม
a 1,b 101จะเป็นอย่างไรนะมันไม่ใช่ทั้ง prefix-free และ suffix-free แต่ทุกครั้งที่มี
0ปรากฏ ก็สอดคล้องกับการที่มีbปรากฏแน่นอนว่ามันไม่มีประสิทธิภาพอย่างชัดเจน ดังนั้นสุดท้ายสิ่งที่สงสัยคือมี รหัสที่เหมาะที่สุด ที่ไม่ใช่ทั้ง prefix-free และ suffix-free หรือไม่
ลองค้นดูแล้ว https://blog.plover.com/CS/udcodes.html ยกตัวอย่างรหัสที่ถอดรหัสได้อย่างเอกเทศว่า
a 0011,b 011,c 11,d 1110ความสัมพันธ์แบบ prefix มีเพียงกรณีเดียวคือ
cเป็น prefix ของdจึง “แทบจะ” prefix-free ถ้าข้อความเริ่มด้วย1ก็น่าจะหา0ตัวแรก แล้วดูว่าจำนวน1ก่อนหน้านั้นเป็นเลขคี่หรือเลขคู่ จึงพอเข้าใจได้ว่าถอดรหัสได้อย่างเอกเทศแต่ผมจำไม่ได้แล้วว่าจะพิสูจน์อย่างไรว่ามันเหมาะที่สุดสำหรับการแจกแจงความน่าจะเป็นแบบใด เพราะความรู้ด้านวิทยาการรหัสของผมขึ้นสนิมไปมาก
เช่นในกรณี
100000000000000001ถ้าจะรู้ว่ารหัสแรกคือaหรือcก็ต้องอ่านไปจนถึงจุดที่ศูนย์ทั้งหมดสิ้นสุดสงสัยว่ามีบทเรียนคล้าย ๆ กันที่ค่อย ๆ เขียนโปรแกรม Haskell แบบบทความนี้ แต่ครอบคลุมฟีเจอร์ขั้นสูงกว่าอย่าง monad transformer หรือ lens ไหม
ยังครอบคลุมฟีเจอร์ขั้นสูงอื่น ๆ เช่น Template Haskell, concurrency และมีบทเกี่ยวกับการใช้งานฐานข้อมูล SQL ใน Haskell ด้วย
เป็นบทเรียนสำหรับ ไลบรารี FRP Rhine และมีคอมเมนต์กับเทสต์เขียนไว้ดี
ในคอร์ส Functional Programming ที่ใช้ Scala ของ Coursera ก็มีโจทย์เกี่ยวกับ Huffman coding ที่ค่อนข้างคล้ายกัน และมีตัวตรวจให้คะแนนอัตโนมัติด้วย จึงเหมาะสำหรับคนที่อยากลองแก้เอง
https://www.coursera.org/learn/scala-functional-programming?...
ครั้งสุดท้ายที่ใช้ Huffman code คือใน macroprogram ของ โปรเซสเซอร์ MICMAC หรือก็คือเพื่อรันข้อความ assembly ด้วยจำนวน microcycle และ microinstruction น้อยที่สุด
เริ่มจาก histogram ของ macroinstruction ที่ถูกเรียกใช้ และเท่าที่จำได้ ตอนแรกเขียน interpreter ด้วย C เพื่อคอยนับว่าแต่ละคำสั่งถูกเรียกใช้กี่ครั้ง
จากนั้นจึงสร้างโปรแกรม microcode แบบถอดรหัสทีละขั้นที่ implement macro operation ของ ISA ที่จำเป็นทั้งหมด ISA ของ macroinstruction ที่สร้างขึ้นน่าจะเป็นแบบระดับบิต ไม่ใช่ byte-oriented
ในโลกจริงมันคงช้าและใช้งานไม่สะดวก แต่ข้อดีของ Huffman code คือสามารถปรับความลึกของ prefix ตามการกระจายของค่าได้ จึงไม่จำเป็นต้องสร้าง code ที่เอนเอียงไปด้านใดด้านหนึ่งเพราะ prefix 1 บิต
นอกจากนี้ microprogram ยังเป็นโมเดลโปรเซสเซอร์ pipeline แบบไม่ใช่ superscalar จึงต้องจัดการ branch prediction ด้วย หากทำนาย branch ผิด ก็จะเสีย cycle ไปกับ pipeline stall ระหว่างที่ branch ที่ถูกต้องถูกส่งต่อไปข้างหน้า
https://rosettacode.org/wiki/Huffman_coding
คิดว่าน่าจะมีโปรแกรมเมอร์ Haskell มารวมตัวกัน เลยอยากถามว่า ทุกวันนี้ Haskell เร็วแค่ไหนถ้าคนเขียนใส่ใจเรื่องการปรับแต่งประสิทธิภาพ?
โดยเฉพาะอยากรู้ประสิทธิภาพของงานที่ได้ประโยชน์จาก การคำนวณเชิงตัวเลข และ SIMD อย่างการคำนวณเมทริกซ์
แก่นสำคัญคือการประกอบหลายส่วนให้เป็นโปรแกรมที่สอดคล้องและจัดระเบียบดีได้ง่าย เรื่องนี้สำคัญกับทั้งโปรแกรม ไม่ใช่แค่ลูปแน่น ๆ เท่านั้น
Haskell มี FFI ที่ดี ดังนั้นส่วนที่โดยเนื้อแท้แล้วต้องการการปรับแต่งแบบ imperative ก็สามารถลงไปใช้ภาษาที่ไม่มี garbage collection ได้ ถ้าห่อส่วนนั้นเป็นไลบรารีที่มี type ดี ๆ โค้ด Haskell ที่ type ตรงกันตรงไหนก็สามารถใช้ประสิทธิภาพดิบระดับนั้นได้
ตอนทำแอปพลิเคชัน Haskell ประสิทธิภาพสูงที่ Meta ก็ทำแบบนั้น คือเขียนโปรแกรม Haskell ที่สวย ใหญ่ และเร็ว แต่ใส่คอมโพเนนต์ C++ ในบางส่วนที่เฉพาะทาง เวลา 99% ใช้ไปกับฝั่ง Haskell เพื่อประกอบให้เป็นแอปพลิเคชันที่มีประโยชน์มากขึ้น
ถึงอย่างนั้น Haskell ก็ไม่ถือว่าช้า ตัวอย่างเช่นโปรแกรมเล็ก ๆ ที่นับจำนวนบิต 1 ในไฟล์
ถ้าคอมไพล์ด้วย
-msse4.2ก็ใช้คำสั่งฮาร์ดแวร์popcountได้ถูกต้อง และประมวลผลไฟล์อินพุต 1GB ใน0m0,090sใช้ heap เป็น 0 เมื่อปัดเป็นหน่วย MBถ้าคอมไพล์โดยไม่มี
-msse4.2จะใช้เวลา0m0,293sการคำนวณเมทริกซ์ผมยังไม่ได้ลองเอง แต่ถ้าเป็นจุดเริ่มต้นคงไปดู
repa,accelerate,massivhttps://hackage.haskell.org/package/repa
https://hackage.haskell.org/package/accelerate
https://hackage.haskell.org/package/massiv
https://gitlab.haskell.org/ghc/ghc/-/issues/7741
อาจจะเข้า GHC 9.12 ก็ได้ แต่จะมุ่งเป้าแค่เวกเตอร์ 128 บิต และถ้าไม่มีคนอื่นมาช่วย contribute ก็น่าจะเน้นการคำนวณเลขทศนิยมเป็นหลัก
แพตช์อยู่ที่นี่
https://gitlab.haskell.org/ghc/ghc/-/merge_requests/12860
เมื่อถึงระดับหนึ่ง การเลือกภาษา host ก็ไม่ได้สำคัญมากนัก เพราะถ้าจริงจังกับความเร็วจริง ๆ สุดท้ายก็จะโยนการคำนวณออกไปให้ภายนอกอยู่ดี
เหตุผลเดียวกันนี้ทำให้โค้ด AI ซึ่งมีแนวโน้มจะเป็นหนึ่งในงานที่ใช้ทรัพยากรคำนวณมากที่สุดในโลก สามารถเขียนด้วย Python ได้ ยกเว้นไลบรารีคำนวณระดับต่ำ
ถ้าตอบตรง ๆ คอมไพเลอร์ GHC นั้นดีมาก โค้ดระดับสูงทำงานได้ค่อนข้างดี และในแอปพลิเคชันจริงส่วนใหญ่ คอขวดด้านประสิทธิภาพไม่ใช่เรื่องการคำนวณความกว้างเดี่ยวเทียบกับ SIMD แต่เป็นปัญหาด้านสถาปัตยกรรม “ความเป็นมิตรต่อเชิงกำกับด้านสถาปัตยกรรม” ของ Haskell ค่อนข้างได้เปรียบ
ผมคิดว่า GHC มีหรือกำลังจะมีการรองรับ SIMD แต่คงไม่เอาเรื่องนั้นเป็นจุดโฟกัสตอนประเมินประสิทธิภาพ
ผมคงไม่เขียนอัลกอริทึมคูณเมทริกซ์เองด้วย Haskell แต่ถ้าจริงจังเรื่องความเร็ว ผมก็จะไม่เขียนเองด้วย Rust หรือ C เช่นกัน
หลายคนมักใช้การคำนวณเชิงตัวเลขเป็นตัวชี้วัดประสิทธิภาพ แต่ในความเป็นจริงมีคนน้อยมากที่คอขวดไปอยู่ตรงนั้น และถ้ามันเป็นคอขวดจริง ๆ การใช้ภาษาระดับสูงภาษาไหนก็ไม่ค่อยสำคัญนัก
ประสิทธิภาพของสไตล์แบบนี้โดยทั่วไปเพียงพอสำหรับงาน CLI หรือ backend เว็บ มีเครื่องมือให้เขียนโค้ดระดับต่ำที่ค่อนข้างเร็วด้วย แต่ค่อนข้างกระด้าง ดังนั้นถ้าอยากใช้แค่นั้นอย่างเดียว ก็น่าจะไม่ใช่เครื่องมือที่ดีที่สุด
อย่างไรก็ตาม ถ้ามี hotspot กระจุกตัวอยู่ไม่กี่จุดที่ต้อง optimize ก็ถือว่าใช้ได้ดีทีเดียว
เครื่องมือ profiling CPU ดี ทำให้การหาและ optimize hotspot ของ CPU ค่อนข้างสบาย ในทางกลับกัน การตามรอย memory leak แปลก ๆ ที่เกิดได้ง่ายขึ้นเพราะ lazy evaluation อาจชวนหงุดหงิดมาก
ดูผลจาก benchmarks game แล้ว implementation Haskell ที่เร็วที่สุดส่วนใหญ่โดยทั่วไปช้ากว่าเวอร์ชัน C ที่เร็วที่สุดประมาณ 2–5 เท่า และเขียนในสไตล์ imperative มาก
https://benchmarksgame-team.pages.debian.net/benchmarksgame/...
ตารางในส่วน “Creating prefix-free codes” ดูเหมือนจะมีพิมพ์ผิด D ควรเป็น
0010ไม่ใช่01100110จะไม่กำกวมได้ยังไง ตอนนี้เข้าใจแล้วสงสัยว่าบนเสื้อของผู้หญิงในภาพคืออะไร
ลิงก์โดยตรง: https://lazamar.github.io/images/data-compressor.svg