Tạo tiện ích nén dựa trên mã Huffman bằng Haskell
(lazamar.github.io)- Trình bày cấu trúc triển khai bộ nén Huffman coding bằng khoảng 150 dòng Haskell, xử lý mã hóa/giải mã tệp nhị phân bất kỳ với bộ nhớ hằng số
- Mã Huffman gán chuỗi bit ngắn cho các giá trị xuất hiện thường xuyên, và nhờ điều kiện prefix-free code, bộ giải mã có thể diễn giải chuỗi bit một cách không nhập nhằng
- Phần triển khai tạo cây từ bảng tần suất bằng
FreqMap,HTree,CodeMap, rồi tạo kết quả dần dần bằng đánh giá lười củaconcatMapvà bộ giải mã đệ quy - Tệp nhị phân được xử lý bằng
Data.ByteString.Char8, xem byte nhưChar; kết quả nén tuần tự hóa bảng tần suất ở phần đầu, còn chuỗi bit được padding theo đơn vị byte để lưu - Trong thử nghiệm, War and Peace giảm từ 3.2M xuống 1.9M, tệp nhị phân
ghcup106M giảm xuống 84M, và resident set size tối đa quan sát được dưới 300KB
Ý tưởng cơ bản của nén bằng mã Huffman
- Mục tiêu là triển khai một tiện ích nén dữ liệu bằng khoảng 150 dòng Haskell sử dụng Huffman coding
- Toàn bộ mã nguồn được công khai trong kho GitHub
- Mã Huffman gán một chuỗi bit duy nhất cho từng ký tự hoặc giá trị
- Giá trị xuất hiện thường xuyên dùng chuỗi bit ngắn
- Giá trị hiếm xuất hiện dùng chuỗi bit dài
- Hiệu quả nén xuất hiện khi các giá trị phổ biến được biểu diễn bằng ít bit hơn biểu diễn ban đầu
- Trong ví dụ
aaab, nếu đặta = 1,b = 0thì kết quả là1110- Đây là ví dụ biểu diễn một chuỗi cần 4 byte theo UTF-8 chỉ bằng nửa byte
Prefix-free code và cây Huffman
- Để việc giải mã không nhập nhằng, không codeword nào được là tiền tố của codeword khác
- Ví dụ, nếu với
aaabcta gána = 1,b = 10,c = 01, thì101có thể trở nên nhập nhằng, không rõ làachayba
- Ví dụ, nếu với
- Prefix-free code có thể được tạo bằng cây nhị phân đầy đủ
- Đặt tất cả giá trị ở các lá
- Gán nhãn cạnh trái là
1, cạnh phải là0 - Đường đi từ gốc tới lá trở thành codeword của giá trị đó
- Cây Huffman được tạo theo cách nhóm dần từ dưới lên, bắt đầu với các giá trị có tần suất thấp
- Tạo mỗi ký tự thành một nút kèm weight là số lần xuất hiện
- Ghép hai nút có weight nhỏ nhất thành một cây
- Weight của cây mới là tổng weight của hai nút
- Lặp lại cho đến khi chỉ còn một cây
- Qua quá trình này, các giá trị xuất hiện thường xuyên hơn sẽ nằm gần gốc hơn và có codeword ngắn hơn
Cấu trúc encoder Haskell
- Các kiểu chính trong phần triển khai là
Bit,Code,FreqMap,CodeMap,Weight,HTreeBitlàOnehoặcZeroCodelà[Bit]FreqMaplàMap Char Int, lưu số lần xuất hiện theo từng ký tựCodeMaplàMap Char Code, lưu codeword theo từng ký tựHTreelàLeaf Weight CharhoặcFork Weight HTree HTree
HTreeđược làm cho có thể so sánh theo weight, giúp đơn giản hóa việc sắp xếp và chèn trong quá trình tạo câycountFrequencytính số lần xuất hiện của từng ký tự trong chuỗibuildTreechuyểnFreqMapthành danh sách lá rồi sắp xếp, sau đó liên tục hợp nhất hai nút nhỏ nhất để tạo cây HuffmanbuildCodesduyệt cây, gắnOneở bên trái vàZeroở bên phải để tạo codeword cho từng ký tựencode :: FreqMap -> String -> [Bit]tạo cây và code map từFreqMap, rồi thay từng ký tự trong chuỗi đầu vào bằng codeword để tạo danh sách bit
Xử lý dần dần bằng đánh giá lười
- Phép biến đổi cốt lõi của mã hóa là
concatMap codeFor str- Về mặt khái niệm, đây là quá trình chuyển
[Char]thành[[Bit]]rồi làm phẳng lại thành[Bit] - Nhờ đánh giá lười của Haskell, nó không hoạt động theo cách mã hóa toàn bộ đầu vào trước rồi mới ghép lại
- Về mặt khái niệm, đây là quá trình chuyển
- Các danh sách nhỏ được xử lý từ trái sang phải và làm phẳng vào danh sách kết quả lớn
- Tail của danh sách kết quả vẫn là thunk chưa được đánh giá
- Khi giá trị cần thiết được yêu cầu, phần tiếp theo mới được tính
- Bộ giải mã cũng tạo kết quả dần dần theo cùng cách
decode :: FreqMap -> [Bit] -> Stringdi chuyển sang trái hoặc phải trong cây tùy theo bit- Khi tới lá, nó xuất ký tự rồi bắt đầu lại từ gốc
- Lặp cho đến khi tổng số ký tự đã giải mã bằng weight của cây Huffman
- Bộ giải mã dừng dựa trên số ký tự, chứ không phải cuối danh sách bit đầu vào
- Vì ở bước tuần tự hóa có thêm bit padding ở cuối để căn theo byte
- Hàm
gokhi tới lá sẽ trả về một danh sách có head đã biết cùng lời gọi đệ quy ở tail, nên có thể đánh giá kết quả trước khi toàn bộ đệ quy kết thúc
Xử lý tệp nhị phân và tuần tự hóa
- Dữ liệu nhị phân có thể được xem là sự lặp lại của một trong 256 byte khả dĩ
Data.ByteString.Char8cho phép xử lýByteStringbằng các phép toánChar, và mọiCharđều bị cắt xuống 8 bit- Nhờ đặc tính này, có thể áp dụng cho dữ liệu nhị phân mà không cần thay đổi nhiều bộ mã hóa dành cho văn bản
- Trong tệp nén,
FreqMapcần cho giải mã được đặt trước, sau đó là chuỗi bit đã mã hóa serializeFreqMapghi bảng tần suất theo định dạng sau- Độ dài map được lưu dưới dạng
Word8, nhưng lưu giá trị đã trừ 1 để hiệu chỉnh phạm vi biểu diễn - Mỗi mục lưu khóa dưới dạng
Word8, và giá trị tần suất dưới dạng số nguyên 64-bit big-endian
- Độ dài map được lưu dưới dạng
serializetạoByteStringbằng monadPutcủa packagebinary- Đọc từng bit để lấp đầy một byte
- Khi đủ 8 bit thì ghi bằng
putWord8 - Byte cuối được padding phần còn lại bằng
Zero
Giải tuần tự hóa và chiến lược bộ nhớ hằng số
deserializeFreqMapđọc bảng tần suất đã tuần tự hóa bằngData.Binary.Get- Đầu tiên đọc độ dài, cộng thêm 1 để tính số mục thực tế
- Với mỗi mục, đọc khóa
Word8và giá trị tần suất 64-bit để khôi phụcFreqMap
- Phần còn lại của toàn bộ đầu vào không được xử lý bằng
Get; thay vào đó, phần sau offset được lấy từByteStringvà chuyển thành danh sách bit deserializetrả về(FreqMap, [Bit]), trong đó[Bit]là danh sách lười không được tính toàn bộ ngay lập tức- Nếu yêu cầu độ dài của danh sách này thì phải đánh giá toàn bộ danh sách, nên cần tránh
- Lý do không dùng
Getcho toàn bộ đầu vào là bind của monad sẽ ép thứ tự- Cấu trúc sẽ chỉ có thể trả về danh sách sau khi xử lý xong toàn bộ đầu vào
- Chiến lược bộ nhớ hằng số là mỗi khi ghi bit đầu ra, chỉ đánh giá phần nhỏ tiếp theo của đầu vào
- Một phần
ByteStringđược đánh giá và mảnh tệp tương ứng được đọc - Đầu ra đã xử lý được ghi vào tệp
- Các mảnh đầu vào và danh sách bit không còn được tham chiếu có thể được garbage collector thu hồi
- Một phần
FreqMapcó tối đa 256 mục theo byte, nên chỉ tạo overhead kích thước hằng số
CLI nén và giải nén tệp
compressđọc tệp đầu vào hai lần- Lượt đầu tạo
FreqMap - Lượt thứ hai mã hóa dữ liệu bằng
FreqMapđó
- Lượt đầu tạo
- Nếu chỉ đọc tệp một lần và truyền cùng tham chiếu cho
encode, sau khi tạo bảng tần suất vẫn phải giữ tham chiếu tới toàn bộ tệp đầu vào, nên cần giữ cả tệp trong bộ nhớ - Cách đọc hai lần cho phép giải phóng bộ nhớ đã xử lý trong cả hai bước tạo bảng tần suất và mã hóa
decompressđọc tệp nén, lấyFreqMapvà danh sách bit bằngdeserialize, rồi lưu kết quảdecodera tệp- CLI nhận các đối số sau
compress FILE FILEdecompress FILE FILE
- Vì chỉ dùng các package đi kèm GHC, có thể biên dịch bằng
ghc -O2 Main.hs -o mainmà không cầncabal
Kết quả chạy và mức dùng bộ nhớ
- Trong thử nghiệm với tệp văn bản War and Peace của Tolstoy, kết quả
diffsau nén và giải nén là giống nhau- Bản gốc
WarAndPeace.txt: 3.2M - Tệp nén: 1.9M
- Tệp giải nén: 3.2M
- Kích thước giảm khoảng 40%
- Bản gốc
- Việc nén và giải nén cũng hoạt động với tệp nhị phân lớn hơn
ghcup- Bản gốc
ghcup: 106M - Tệp nén: 84M
- Tệp giải nén: 106M
- Thời gian nén khoảng 15.173 giây, thời gian giải nén khoảng 14.555 giây
- Bản gốc
- Theo
+RTS -s, resident set size tối đa trong khi xử lýghcuplà dưới 300KB - Hai tiến trình dùng dưới 10MB bộ nhớ khi chạy
- Có thể xem vị trí tiêu tốn thời gian trong profile riêng
Những điểm có thể cải thiện thêm
- Mục tiêu triển khai là một tiện ích nén đơn giản và rõ ràng nhất có thể; để tăng hiệu quả sẽ cần triển khai phức tạp hơn
- Các hướng cải thiện khả dĩ gồm
- Đa luồng: giải mã song song các đoạn tệp, nhưng vì không thể biết ranh giới codeword tại vị trí tùy ý, cần thêm vào đầu tệp nén một bảng chứa ranh giới đoạn và kích thước giải mã dự kiến
- Mã hóa một lượt: bắt đầu với bảng tần suất ban đầu trong đó mọi byte có tần suất 1, và mỗi khi thấy một byte thì mã hóa trước rồi cập nhật bảng tần suất
- Canonical Huffman codes: thay vì duyệt cây trong
O(log n)khi giải mã, có thể nhắm tới truy cậpO(1)bằng cách dùng code làm chỉ mục vector; nội dung liên quan có trong Canonical Huffman code - Tạo code nhanh hơn: với mã hóa một lượt, cần tạo
CodeMapnhanh hơn nhiều, và có những cách nhanh hơn để tạo codeword mà không dựng cây
- Trong tương lai, nếu dùng LZ77, một phương pháp từ điển thích ứng, có thể triển khai gzip cùng với mã Huffman
1 bình luận
Các ý kiến trên Hacker News
Có một thuật toán tại chỗ dựa trên mảng cho việc này, giúp giảm việc cấp phát cây và lần theo con trỏ
Khi học cách tiếp cận dựa trên cây ở đại học, tôi không biết là có cách khác, và tôi cũng tò mò liệu những người khác có như vậy không
Cách dùng cây trực quan và giúp dễ hiểu, nhưng những tình huống mà nén là quan trọng nhất thường là khi có nhiều dữ liệu và muốn chạy nhanh, nên xử lý bằng mảng tại chỗ có thể hợp lý hơn
In-Place Calculation of Minimum-Redundancy Codes, Moffat, Katajainen, 1995
http://hjemmesider.diku.dk/~jyrki/Paper/WADS95.pdf
Charles Bloom đã khuyến nghị rất mạnh, và sau đó còn viết thêm phần giải thích
https://cbloomrants.blogspot.com/2010/08/08-12-10-lost-huffm...
Nói rằng “không được để bất kỳ code word nào là tiền tố của một code word khác thì mới không mơ hồ” thì xét nghiêm ngặt là không chính xác
Cái gọi là mã giải mã duy nhất là không mơ hồ, và là siêu tập của mã tiền tố
Một ví dụ đơn giản là mã thu được bằng cách đảo ngược mã tiền tố. Với ví dụ trong bài thì sẽ là
a 1,b 00,c 10Mã của
alà tiền tố của mãc, nhưng nếu xử lý chuỗi mã theo thứ tự ngược, vẫn có thể giải mã mà không mơ hồ. Sẽ thú vị nếu xem các mã giải mã duy nhất không phải là mã tiền tố, cũng không phải là dạng đảo ngược của nóĐặt
A 0,B 01,C 11, rồi tiếp tục đặta A 0,b BA 010,c BB 0101,d BC 0111,e C 11, ta được{a=0,b=010,c=0101,d=0111,e=11}Rõ ràng là có thể giải mã duy nhất, vì có thể giải mã duy nhất từ phía sau theo kiểu
0->A, rồi lại giải mã duy nhất từ phía trước theo kiểuA->aVề độ dài thì giống mã tiền tố tối ưu
{a=0,b=110,c=1110,d=1111,e=10}, nên với cùng một phân bố xác suất, đây là một trong nhiều mã tối ưuĐồng thời, do
a=0,b=010, nó không phải là mã tiền tố cũng không phải là mã hậu tố. Thực ra nói chung không thể giải mã tăng dần theo cả hai chiều; để phân biệtcee...ee?vớibee...ee?, hay?cc...ccavới?cc...ccb, có thể cần nhìn trước vô hạn chỉ để xác định một ký hiệuTôi không biết việc hợp thành một mã tiền tố tối ưu độc lập với một mã hậu tố tối ưu độc lập có luôn giữ được tính tối ưu hay không, nhưng trong những trường hợp đơn giản nhất tôi nghĩ ra, trừ các mã suy biến 1:1, thì nó hoạt động tốt
a 101,b 1Nhưng đó là mã tệ. Vì luôn đặt
a=1,b=0thì tốt hơnBất đẳng thức Kraft cho biết các tập độ dài mã có thể được tạo sao cho giải mã duy nhất, và mã hóa Huffman có thể đạt được tất cả các tập như vậy. Vì vậy, nếu đang mã hóa ký hiệu, thì không có lý do để dùng mã không phải tiền tố, trừ khi chuyển sang phương pháp khác như ANS hoặc mã hóa số học
Tuy nhiên, tôi không biết liệu có tồn tại mã giải mã duy nhất có cùng tập độ dài với một mã Huffman tối ưu, nhưng không phải là mã tiền tố cũng không phải là mã hậu tố đảo ngược của nó hay không
Nếu bỏ thời gian, có lẽ tôi sẽ xem https://en.wikipedia.org/wiki/Sardinas-Patterson_algorithm, rồi tìm phản ví dụ bằng brute force hoặc nghĩ ra chứng minh từ cách thuật toán hoạt động
a 1,b 101thì saoNó không prefix-free cũng không suffix-free, nhưng mỗi khi
0xuất hiện thì tương ứng với việcbxuất hiệnTất nhiên rõ ràng là không hiệu quả, nên cuối cùng điều đáng tò mò là liệu có mã tối ưu nào không prefix-free cũng không suffix-free hay không
Tìm thử thì thấy https://blog.plover.com/CS/udcodes.html đưa ra ví dụ mã giải mã duy nhất
a 0011,b 011,c 11,d 1110Quan hệ tiền tố duy nhất chỉ là trường hợp
clà tiền tố củad, nên nó “gần như” prefix-free. Nếu thông điệp bắt đầu bằng1, có vẻ chỉ cần tìm số0đầu tiên rồi xem số lượng1trước đó là lẻ hay chẵn, nên tôi hiểu được tính giải mã duy nhấtNhưng kiến thức mật mã học của tôi đã quá rỉ sét nên không nhớ cách chứng minh mã này là tối ưu cho phân bố xác suất nào
Ví dụ với
100000000000000001, để biết mã đầu tiên làahayc, phải đọc hết đến chỗ các số 0 kết thúcTôi tò mò liệu có tài liệu nào tương tự kiểu tutorial viết dần một chương trình Haskell như bài này, nhưng xử lý các tính năng nâng cao hơn như monad transformer hoặc lens không
Sách cũng đề cập các tính năng nâng cao khác như Template Haskell, concurrency, và có cả chương về xử lý cơ sở dữ liệu SQL trong Haskell
Đây là tutorial cho thư viện FRP Rhine, có chú thích và test được viết tốt
Khóa lập trình hàm dựa trên Scala của Coursera cũng có một bài tập mã hóa Huffman khá tương tự, và có cả hệ thống chấm tự động, nên phù hợp cho những ai muốn tự thử giải
https://www.coursera.org/learn/scala-functional-programming?...
Lần cuối tôi dùng mã Huffman là cho macroprogram của bộ xử lý MICMAC, tức để thực thi văn bản assembly với số microcycle và số microinstruction tối thiểu
Tôi bắt đầu từ histogram các macroinstruction đã được thực thi, và theo trí nhớ thì trước tiên tôi viết một interpreter bằng C để đếm mỗi lệnh được chạy bao nhiêu lần
Sau đó, tôi tạo một chương trình microcode giải mã tiệm tiến triển khai tất cả các phép toán macro ISA cần thiết. Macroinstruction ISA tôi tạo ra hình như không hướng byte mà theo từng bit
Trong thực tế có lẽ nó chậm và bất tiện, nhưng ưu điểm của mã Huffman là có thể điều chỉnh độ sâu tiền tố theo phân bố giá trị, nên không cần tạo mã bị lệch về một phía vì tiền tố 1 bit
Ngoài ra, microprogram là mô hình bộ xử lý pipeline không superscalar, nên cũng phải xử lý dự đoán nhánh. Nếu dự đoán sai nhánh, sẽ lãng phí chu kỳ do pipeline bị dừng trong lúc nhánh đúng được truyền về phía trước
https://rosettacode.org/wiki/Huffman_coding
Có vẻ sẽ có nhiều lập trình viên Haskell tụ họp ở đây nên tôi muốn hỏi: Haskell ngày nay nhanh đến mức nào nếu được dùng bởi một lập trình viên chú ý đến tối ưu hóa?
Đặc biệt tôi tò mò về hiệu năng của các tác vụ hưởng lợi từ SIMD và tính toán số học, như phép toán ma trận
Điểm mấu chốt là dễ lắp ghép nhiều phần thành một chương trình nhất quán và được tổ chức tốt. Điều này quan trọng cho toàn bộ chương trình, không chỉ những vòng lặp chặt chẽ
Haskell có FFI tốt, nên những phần về bản chất cần tối ưu hóa theo kiểu mệnh lệnh có thể hạ xuống một ngôn ngữ không có garbage collection. Nếu bọc những phần đó trong một thư viện có kiểu tốt, bạn có thể tận dụng hiệu năng thô đó ở bất cứ đâu trong mã Haskell khớp kiểu
Khi xây dựng các ứng dụng Haskell hiệu năng cao ở Meta, chúng tôi đã làm như vậy. Viết một chương trình Haskell đẹp, lớn và nhanh, nhưng chèn các thành phần C++ vào một số phần chuyên biệt. 99% thời gian được dành ở phía Haskell để ghép chúng thành một ứng dụng hữu ích hơn
Dù vậy Haskell không phải là chậm. Có thể lấy một chương trình nhỏ đếm số bit 1 trong một tệp làm ví dụ
Khi biên dịch với
-msse4.2, nó dùng đúng lệnh phần cứngpopcountvà xử lý tệp đầu vào 1GB trong0m0,090s. Heap dùng 0 nếu làm tròn theo đơn vị MBNếu biên dịch không có
-msse4.2, mất0m0,293sTôi chưa tự thử tính toán ma trận, nhưng nếu làm điểm khởi đầu thì có lẽ tôi sẽ xem
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
Có thể sẽ vào GHC 9.12. Tuy nhiên, nó chỉ nhắm tới vector 128-bit, và trừ khi có người khác đóng góp, có lẽ chủ yếu sẽ xoay quanh phép toán số thực dấu phẩy động
Bản vá ở đây
https://gitlab.haskell.org/ghc/ghc/-/merge_requests/12860
Ở một mức nào đó, lựa chọn ngôn ngữ chủ không còn quá quan trọng. Vì nếu thật sự nghiêm túc về tốc độ, phần tính toán dù sao cũng sẽ được giao ra bên ngoài
Đó cũng là lý do mã AI, vốn rất có thể thuộc nhóm tiêu tốn nhiều tài nguyên tính toán nhất thế giới, vẫn có thể được viết bằng Python ngoại trừ các thư viện tính toán cấp thấp
Trả lời trực tiếp thì compiler GHC rất tốt. Mã cấp cao chạy khá ổn, và trong hầu hết ứng dụng thực tế, nút thắt hiệu năng là vấn đề kiến trúc chứ không phải chuyện phép toán độ rộng đơn so với SIMD. “Độ tiệm cận về kiến trúc” của Haskell khá có lợi
Tôi nghĩ GHC đã có hoặc đang dần có hỗ trợ SIMD, nhưng tôi sẽ không tập trung vào đó khi đánh giá hiệu năng
Tôi sẽ không tự viết thuật toán nhân ma trận bằng Haskell, nhưng nếu nghiêm túc về tốc độ thì tôi cũng sẽ không tự viết bằng Rust hay C
Tính toán số học thường được xem như chỉ số hiệu năng, nhưng trên thực tế rất ít người bị nghẽn ở đó; còn nếu đúng là nghẽn ở đó thì việc dùng ngôn ngữ cấp cao nào cũng không quan trọng lắm
Hiệu năng của phong cách này thường là đủ cho các tác vụ CLI hoặc backend web. Cũng có các công cụ để viết mã cấp thấp khá nhanh, nhưng chúng hơi thô, nên nếu chỉ muốn dùng chúng thì nhiều khả năng đây không phải công cụ tốt nhất
Tuy nhiên, khi có vài hotspot tập trung cần tối ưu hóa thì nó khá ổn
Công cụ profiling CPU tốt, nên việc tìm và tối ưu hóa hotspot CPU tương đối dễ chịu. Ngược lại, việc truy vết các rò rỉ bộ nhớ kỳ lạ dễ phát sinh hơn do đánh giá lười có thể rất bực bội
Nhìn vào kết quả benchmarks game, các bản triển khai Haskell nhanh nhất nhìn chung chậm hơn bản C nhanh nhất khoảng 2–5 lần và được viết theo phong cách rất mệnh lệnh
https://benchmarksgame-team.pages.debian.net/benchmarksgame/...
Có vẻ bảng trong phần “Creating prefix-free codes” có lỗi đánh máy. D phải là
0010, không phải01100110lại có thể không mơ hồ, giờ thì đã hiểuTôi tò mò thứ trên áo của cô gái trong ảnh là gì
Link trực tiếp: https://lazamar.github.io/images/data-compressor.svg