Haskell ile Huffman kodu tabanlı bir sıkıştırma aracı yapmak
(lazamar.github.io)- Yaklaşık 150 satır Haskell ile bir Huffman coding sıkıştırıcısı geliştirerek, rastgele ikili dosyaların sabit bellekle kodlanıp çözülebildiği bir yapıyı gösteriyor
- Huffman kodu, sık görülen değerlere kısa bit dizileri atar ve prefix-free code koşulu sayesinde çözücü bit dizilerini belirsizlik olmadan yorumlar
- Uygulama,
FreqMap,HTree,CodeMapile frekans tablosundan ağaç oluşturur;concatMapve özyinelemeli çözücünün lazy evaluation özelliğiyle sonuçları kademeli olarak üretir - İkili dosyalar
Data.ByteString.Char8ile baytlarınChargibi ele alınmasıyla işlenir; sıkıştırılmış çıktının başına frekans tablosu serileştirilir ve bit dizileri bayt hizasında padding eklenerek saklanır - Testlerde War and Peace 3.2M'den 1.9M'ye, 106M'lik
ghcupikilisi ise 84M'ye düştü; en yüksek resident set size ise 300KB'nin altında gözlendi
Huffman kodu sıkıştırmanın temel fikri
- Amaç, Huffman coding kullanarak yaklaşık 150 satır Haskell ile bir veri sıkıştırma aracı geliştirmektir
- Tüm kod GitHub deposunda açık olarak paylaşılmıştır
- Huffman kodu, her karaktere veya değere benzersiz bir bit dizisi atar
- Sık geçen değerler kısa bit dizileri kullanır
- Nadir geçen değerler uzun bit dizileri kullanır
- Sık görülen değerler özgün gösterimlerinden daha az bitle ifade edildiği için sıkıştırma etkisi oluşur
aaabörneğindea = 1,b = 0seçilirse sonuç1110olur- UTF-8 ile 4 bayt gerektiren bir dizgenin yarım baytla ifade edilmesine örnektir
Prefix-free code ve Huffman ağacı
- Çözmenin belirsiz olmaması için hiçbir kod sözcüğü başka bir kod sözcüğünün öneki olmamalıdır
- Örneğin
aaabciçina = 1,b = 10,c = 01atanırsa101,acmibamı belirsiz hale gelebilir
- Örneğin
- prefix-free code, tam ikili ağaç olarak kurulabilir
- Tüm değerler yapraklara yerleştirilir
- Sol kenar
1, sağ kenar0ile etiketlenir - Kökten yaprağa giden yol, ilgili değerin kod sözcüğü olur
- Huffman ağacı, düşük frekanslı değerleri alttan başlayarak birleştirme yöntemiyle oluşturulur
- Her karakter, görünme sayısı olan weight ile birlikte düğüm yapılır
- En küçük weight'e sahip iki düğüm bir ağaçta birleştirilir
- Yeni ağacın weight değeri iki düğümün weight toplamıdır
- Tek bir ağaç kalana kadar bu işlem tekrarlanır
- Bu süreç sonunda daha sık görülen değerler köke daha yakın olur ve daha kısa kod sözcükleri alır
Haskell kodlayıcısının yapısı
- Uygulamanın temel tipleri
Bit,Code,FreqMap,CodeMap,Weight,HTree'dirBit,OneveyaZerodurCode,[Bit]'tirFreqMap, karakter başına görünme sayısını tutanMap Char Int'dirCodeMap, karakter başına kod sözcüğünü tutanMap Char Code'durHTree,Leaf Weight CharveyaFork Weight HTree HTree'dir
HTree, weight temelinde karşılaştırılabilir yapılarak ağaç oluşturma sürecindeki sıralama ve ekleme basitleştirilircountFrequency, bir dizgedeki her karakterin görünme sayısını hesaplarbuildTree,FreqMap'i yaprak listesine çevirir, sıralar ve en küçük iki düğümü tekrar tekrar birleştirerek Huffman ağacını oluştururbuildCodes, ağacı dolaşırken solaOne, sağaZeroekleyerek her karakterin kod sözcüğünü üretirencode :: FreqMap -> String -> [Bit],FreqMap'ten ağaç ve kod haritası oluşturur; giriş dizgesindeki her karakteri kod sözcüğüyle değiştirerek bir bit listesi üretir
Lazy evaluation ile kademeli işleme
- Kodlamadaki temel dönüşüm
concatMap codeFor strifadesidir- Kavramsal olarak bu,
[Char]'ı[[Bit]]'e dönüştürüp ardından tekrar[Bit]olarak düzleştirme sürecidir - Haskell'in lazy evaluation özelliği sayesinde bu işlem, tüm girdiyi önce tamamen kodlayıp sonra birleştirerek çalışmaz
- Kavramsal olarak bu,
- Küçük listeler soldan sağa ilerlerken büyük sonuç listesine düzleştirilir
- Sonuç listesinin tail kısmı henüz değerlendirilmemiş thunk olarak kalır
- Gereken değer istendiğinde sonraki kısım hesaplanır
- Çözücü de aynı şekilde sonucu kademeli üretir
decode :: FreqMap -> [Bit] -> String, bitlere göre ağaçta sola veya sağa gider- Yaprağa ulaşıldığında karakter çıktı olarak verilir ve yeniden kökten başlanır
- Huffman ağacının weight değeri kadar karakter çözümlenene dek tekrar eder
- Çözücü, giriş bit listesinin sonuna göre değil karakter sayısına göre durur
- Çünkü serileştirme aşamasında bayt hizalaması için sona padding bitleri eklenir
gofonksiyonu, yaprağa ulaştığında head'i belli olan bir liste ile tail için özyinelemeli çağrıyı döndürdüğünden, tüm özyineleme bitmeden de sonuç değerlendirilebilir
İkili dosya işleme ve serileştirme
- İkili veri, olası 256 bayttan birinin tekrarlandığı bir yapı olarak görülebilir
Data.ByteString.Char8,ByteStringüzerindeCharişlemleri yapmayı sağlar ve tümChardeğerleri 8 bit'e kırpılır- Bu özellik sayesinde metin odaklı kodlayıcı büyük değişiklik olmadan ikili veriye de uygulanabilir
- Sıkıştırılmış dosyada önce çözme için gerekli
FreqMapyer alır, ardından kodlanmış bit dizisi gelir serializeFreqMap, frekans tablosunu şu biçimde yazar- Harita uzunluğu
Word8olarak saklanır, ancak ifade aralığını ayarlamak için 1 çıkarılarak yazılır - Her öğede anahtar
Word8, frekans değeri ise 64 bit big-endian tamsayı olarak saklanır
- Harita uzunluğu
serialize,binarypaketindekiPutmonadı ileByteStringüretir- Bitleri teker teker okuyup bir baytı doldurur
- 8 bit tamamlanınca
putWord8ile yazar - Son baytta kalan boşluk
Zeroile padding yapılarak doldurulur
Deserileştirme ve sabit bellek stratejisi
deserializeFreqMap, serileştirilmiş frekans tablosunuData.Binary.Getile okur- Önce uzunluğu okur ve gerçek öğe sayısını bulmak için 1 ekler
- Her öğeden
Word8anahtarını ve 64 bit frekans değerini okuyarakFreqMap'i geri oluşturur
- Girdinin kalan tamamı
Getile işlenmez; bunun yerineByteStringiçinden offset sonrasındaki bölüm alınır ve bit listesine dönüştürülür deserialize,(FreqMap, [Bit])döndürür; buradaki[Bit], tamamı hemen hesaplanmayan lazy listedir- Bu listenin uzunluğunu istemek, tüm listeyi değerlendirmeyi gerektireceği için bundan kaçınılmalıdır
- Tüm girdi üzerinde
Getkullanılmamasının nedeni, monaddaki bind'in sırayı zorunlu kılmasıdır- Böylece tüm giriş işlenmeden listenin döndürülmesi mümkün olmazdı
- Sabit bellek stratejisi, çıktı bitleri yazıldıkça girdinin yalnızca bir sonraki küçük parçasını değerlendirmeye dayanır
ByteString'in bir kısmı değerlendirilir ve ilgili dosya parçası okunur- İşlenen çıktı dosyaya yazılır
- Artık başvurulmayan giriş parçaları ve bit listeleri çöp toplayıcı tarafından geri alınabilir
FreqMap, bayt bazında en fazla 256 öğe içerdiğinden ek yük sabit boyutta kalır
Dosya sıkıştırma/açma CLI'ı
compress, giriş dosyasını iki kez okur- İlk geçişte
FreqMapoluşturulur - İkinci geçişte veri bu
FreqMapile kodlanır
- İlk geçişte
- Dosyayı yalnızca bir kez okuyup aynı referansı
encode'a vermek, frekans tablosu oluşturulduktan sonra da tüm giriş dosyasına referansı korumayı gerektirir; bu da tüm dosyanın bellekte tutulması anlamına gelir - İki kez okuma yaklaşımı, hem frekans tablosu oluştururken hem de kodlama yaparken işlenmiş belleğin süreç içinde serbest bırakılmasına olanak tanır
decompress, sıkıştırılmış dosyayı okur;deserializeileFreqMapve bit listesini alır, ardındandecodesonucunu dosyaya kaydeder- CLI şu argümanları alır
compress FILE FILEdecompress FILE FILE
- Yalnızca GHC ile gelen paketleri kullandığı için
cabalolmadanghc -O2 Main.hs -o mainile derlenebilir
Çalışma sonuçları ve bellek kullanımı
- Tolstoy'un War and Peace metin dosyası testinde, sıkıştırma ve açma sonrası
diffsonucu aynıdır- Özgün
WarAndPeace.txt: 3.2M - Sıkıştırılmış dosya: 1.9M
- Açılmış dosya: 3.2M
- Boyut yaklaşık %40 azalır
- Özgün
- Daha büyük bir ikili dosya olan
ghcupüzerinde de sıkıştırma ve açma çalışır- Özgün
ghcup: 106M - Sıkıştırılmış dosya: 84M
- Açılmış dosya: 106M
- Sıkıştırma süresi yaklaşık 15.173 saniye, açma süresi yaklaşık 14.555 saniyedir
- Özgün
+RTS -sölçümüne göreghcupişlenirken en yüksek resident set size 300KB'nin altındadır- Her iki süreç de çalışırken 10MB'den az bellek kullanır
- Zamanın nerede harcandığı ayrı bir profilde görülebilir
Daha da geliştirilebilecek noktalar
- Uygulamanın amacı olabildiğince basit ve anlaşılır bir sıkıştırma aracı sunmaktı; verimliliği artırmak için daha karmaşık bir uygulama gerekir
- Olası iyileştirmeler şunlardır
- Çok iş parçacıklılık: Dosya bölümleri paralel çözülebilir; ancak rastgele bir konumda kod sözcüğü sınırları bilinmediği için sıkıştırılmış dosyanın başına bölüm sınırları ve beklenen çözülmüş boyut tablosu eklenmelidir
- Tek geçişli kodlama: Tüm baytların frekansı 1 olan başlangıç frekans tablosuyla başlanır; her bayt görüldüğünde önce kodlanır, sonra frekans tablosu güncellenir
- Canonical Huffman codes: Çözmede ağacı
O(log n)ile dolaşmak yerine kodu bir vektör indeksi olarak kullanıpO(1)erişim hedeflenebilir; ayrıntılar için Canonical Huffman code sayfasına bakılabilir - Daha hızlı kod üretimi: Tek geçişli kodlamada
CodeMapüretimi çok daha hızlı olmalıdır ve ağacı oluşturmadan kod sözcüğü üretmenin daha hızlı yolları vardır
- İleride uyarlamalı sözlük yaklaşımı olan LZ77 kullanılarak Huffman kodlarıyla birlikte gzip de geliştirilebilir
1 yorum
Hacker News yorumları
Bu iş için ağaç ayırmayı ve işaretçileri takip etmeyi azaltan dizi tabanlı yerinde çalışan bir algoritma var
Üniversitede ağaç tabanlı yaklaşımı öğrendiğimde başka bir yöntem olduğunu bilmiyordum; başkaları için de böyle olup olmadığını merak ediyorum
Ağaç yöntemi sezgisel ve anlamaya yardımcı oluyor, ama sıkıştırmanın en önemli olduğu durumlar genellikle verinin çok olduğu ve hızlı çalışmasını istediğiniz durumlardır; bu yüzden yerinde dizilerle ele almak daha mantıklı olabilir
In-Place Calculation of Minimum-Redundancy Codes, Moffat, Katajainen, 1995
http://hjemmesider.diku.dk/~jyrki/Paper/WADS95.pdf
Charles Bloom bunu güçlü biçimde tavsiye etmiş, daha sonra açıklama da eklemişti
https://cbloomrants.blogspot.com/2010/08/08-12-10-lost-huffm...
“Hiçbir kod sözcüğünün başka bir kod sözcüğünün öneki olmaması gerekir ki belirsiz olmasın” ifadesi, sıkı anlamda doğru değil
Sözde tekil çözülebilir kodlar belirsiz değildir ve önek kodlarının üst kümesidir
Basit bir örnek, bir önek kodunu ters çevirmektir. Yazıdaki örnek için bu
a 1,b 00,c 10oluranın koduckodunun öneki olsa da, kod dizisini ters sırayla işlerseniz hâlâ belirsizlik olmadan çözülebilir. Ne önek kodu ne de bunun tersi olan tekil çözülebilir kodlar görmek ilginç olurduA 0,B 01,C 11olsun; sonraa A 0,b BA 010,c BB 0101,d BC 0111,e C 11olarak alırsanız{a=0,b=010,c=0101,d=0111,e=11}elde edilirArkadan başlayarak
0->Agibi tekil biçimde çözüp, sonra önden başlayarakA->agibi tekrar tekil biçimde çözmek yeterli olduğundan, bunun tekil çözülebilir olduğu açıkUzunluk açısından en iyi önek kodu
{a=0,b=110,c=1110,d=1111,e=10}ile aynıdır; yani aynı olasılık dağılımı için birden fazla en iyi koddan biridirAynı zamanda
a=0,b=010nedeniyle ne önek kodu ne de sonek kodudur. Aslında genel durumda her iki yönde de kademeli çözme mümkün değildir;cee...ee?ilebee...ee?veya?cc...ccaile?cc...ccbayrımını yapmak için tek bir sembolü belirlerken bile sonsuz ileri/geri bakış gerekebilirBağımsız olarak en iyi olan bir önek kodu ile bağımsız olarak en iyi olan bir sonek kodunu bileştirmenin her zaman en iyiliği koruyup korumadığını bilmiyorum; ama dejeneratif 1:1 kodlar hariç aklıma gelen en basit durumlarda iyi çalıştı
a 101,b 1gibi bir örnek mümkünAma bu kötü bir kod. Çünkü her zaman
a=1,b=0yapmak daha iyidirKraft eşitsizliği, tekil çözülebilir yapılabilecek kod uzunlukları kümelerini söyler ve Huffman kodlama ile bu kümelerin tamamına ulaşılabilir. Dolayısıyla sembol kodluyorsanız, ANS veya aritmetik kodlama gibi başka bir yönteme geçmediğiniz sürece önek olmayan kod kullanmak için bir neden yoktur
Yine de en iyi Huffman koduyla aynı uzunluk kümesine sahip olup ne önek kodu ne de onun tersi olan sonek kodu olan tekil çözülebilir bir kod var mı bilmiyorum
Zaman ayırsaydım https://en.wikipedia.org/wiki/Sardinas-Patterson_algorithm’a bakıp kaba kuvvetle bir karşı örnek arar ya da algoritmanın çalışma biçiminden bir kanıt çıkarırdım
a 1,b 101nasıl olur diye düşünüyorumNe önek serbest ne de sonek serbest, ama
0her göründüğünde bu birbnin görünmesiyle eşleşiyorElbette açıkça verimsiz; bu yüzden asıl merak ettiğim, önek serbest de sonek serbest de olmayan en iyi kod var mı
Arayınca https://blog.plover.com/CS/udcodes.html içinde
a 0011,b 011,c 11,d 1110şeklinde tekil çözülebilir bir kod örneği verildiğini gördümTek önek ilişkisi yalnızca
cnindnin öneki olması olduğundan “neredeyse” önek serbest sayılır. Mesaj1ile başlıyorsa ilk0ı bulup, ondan önceki1sayısının tek mi çift mi olduğuna bakmak yeterli gibi; bu yüzden tekil çözülebilirliğini anlıyorumAncak bunun hangi olasılık dağılımı için en iyi olduğunu nasıl göstereceğimi kriptografi bilgim fazla paslandığı için hatırlamıyorum
Örneğin
100000000000000001gibi bir durumda, ilk kodunamı yoksacmi olduğunu anlamak için sıfırların bittiği yere kadar hepsini okumak gerekirBu yazıdaki gibi Haskell programını adım adım yazdıran benzer eğitimlerden, monad transformer veya lens gibi daha ileri özellikleri ele alan kaynaklar var mı merak ediyorum
Template Haskell, eşzamanlılık gibi başka ileri özellikleri de kapsıyor; ayrıca Haskell’de SQL veritabanlarıyla çalışmayı anlatan bir bölüm de var
FRP kütüphanesi Rhine için bir eğitim; yorumları ve testleri iyi hazırlanmış
Coursera'nın Scala tabanlı fonksiyonel programlama kursunda da oldukça benzer bir Huffman kodlama ödevi var; otomatik değerlendirme aracı da bulunduğundan, kendisi çözmek isteyenler için iyi bir seçenek
https://www.coursera.org/learn/scala-functional-programming?...
Huffman kodunu son kullandığım yer MICMAC işlemcisinin makroprogramıydı; yani assembly metnini en az mikrodöngü ve en az mikrokomutla çalıştırmak içindi
Çalıştırılan makrokomutların histogramından başladım ve hatırladığım kadarıyla önce C ile bir yorumlayıcı yazarak her komutun kaç kez çalıştırıldığını saydım
Sonra gerekli tüm ISA makro işlemlerini uygulayan, aşamalı kod çözme yapan bir mikrokod programı oluşturdum. Oluşturduğum makrokomut ISA'sı byte odaklı değil, sanırım bit düzeyindeydi
Gerçekte yavaş ve kullanışsız olurdu; ancak Huffman kodunun avantajı, değer dağılımına göre önek derinliğini ayarlayabilmesi ve böylece 1 bitlik önek yüzünden bir tarafa yığılmış bir kod oluşturmak zorunda bırakmamasıdır
Ayrıca mikroprogram, süperskaler olmayan işlem hattı işlemci modeli olduğu için dal tahminini de ele almak zorundaydı. Dal yanlış tahmin edilirse, doğru dal ileriye yayılırken işlem hattı durması nedeniyle döngüler boşa harcanır
https://rosettacode.org/wiki/Huffman_coding
Haskell programcıları burada toplanacak gibi, o yüzden sormak istiyorum: Günümüzde Haskell, optimizasyona önem veren bir programcının elinde ne kadar hızlı?
Özellikle matris işlemleri gibi sayısal hesaplama ve SIMD’den fayda sağlayan işlerdeki performansını merak ediyorum
Birçok parçayı tutarlı ve iyi düzenlenmiş bir program olarak bir araya getirmeyi kolaylaştırması asıl mesele. Bu yalnızca sıkı döngüler için değil, programın bütünü için önemli
Haskell’in FFI’ı iyi olduğu için, doğası gereği imperatif optimizasyon gerektiren kısımlarda çöp toplayıcısı olmayan dillere inilebilir. Bu kısımlar iyi tiplere sahip bir kütüphaneyle sarılırsa, tipleri uyan Haskell kodunun her yerinde o ham performanstan yararlanılabilir
Meta’da yüksek performanslı Haskell uygulamaları geliştirirken bunu yaptık. Güzel, büyük ve hızlı Haskell programları yazdık; ancak bazı özelleşmiş bölümlere C++ bileşenleri koyduk. Zamanın %99’unu Haskell tarafında bunları daha kullanışlı bir uygulama hâline getirmeye harcadık
Yine de Haskell yavaş sayılmaz. Dosyadaki 1 bitlerinin sayısını sayan küçük bir program örnek verilebilir
-msse4.2ile derleyince donanımpopcountkomutunu düzgün kullanıyor ve 1 GB’lık giriş dosyasını0m0,090siçinde işliyor. MB düzeyinde yuvarlamayla heap kullanımı 0-msse4.2olmadan derleyince0m0,293ssürüyorMatris hesaplamasını kendim denemedim ama başlangıç noktası olarak
repa,accelerate,massivpaketlerine bakardımhttps://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’ye girebilir. Ancak yalnızca 128 bit vektörleri hedefliyor ve başkaları katkı vermezse ağırlıkla kayan nokta işlemlerine yönelik olacak gibi
Yama burada
https://gitlab.haskell.org/ghc/ghc/-/merge_requests/12860
Belli bir seviyede ana dil seçimi çok da önemli değildir. Hızı gerçekten ciddiye alıyorsanız hesaplamayı zaten dışarıya devredersiniz
Dünyada hesaplama kaynağını en çok tüketen alanlardan biri olması muhtemel yapay zeka kodlarının, düşük seviye hesaplama kütüphaneleri dışında Python ile yazılabilmesinin nedeni de bu
Doğrudan yanıt vermek gerekirse, GHC derleyicisi çok iyi. Yüksek seviyeli kod oldukça iyi çalışıyor ve gerçekçi uygulamaların çoğunda performans darboğazı tek genişlikli işlem ile SIMD arasındaki fark değil, mimari sorunlar oluyor. Haskell’in “mimari asimptotiği” oldukça avantajlı
GHC’nin SIMD desteğine sahip olduğunu ya da bu desteği edinmekte olduğunu düşünüyorum, ama performansı değerlendirirken buna odaklanmazdım
Haskell’de matris çarpma algoritmasını kendim yazmazdım; ama hızı ciddiye alıyorsam Rust veya C’de de kendim yazmazdım
Sayısal hesaplama çoğu zaman performans ölçütü olarak görülüyor, ancak pratikte gerçekten orada darboğaz yaşayan çok az kişi var; eğer darboğaz oradaysa da hangi yüksek seviyeli dili kullandığınız pek önemli değil
Bu tarzın performansı genelde CLI veya web backend işleri için yeterli. Oldukça hızlı düşük seviyeli kod yazmak için araçları da var, ama biraz hantal; sadece onu yapmak istiyorsanız muhtemelen en iyi araç değildir
Yine de optimize edilmesi gereken birkaç yoğun hotspot olduğunda oldukça iyi
CPU profilleme araçları iyi olduğu için CPU hotspot’larını bulup optimize etmek nispeten rahat. Buna karşılık lazy evaluation yüzünden ortaya çıkması daha kolay olan tuhaf bellek sızıntılarını izlemek çok bunaltıcı olabilir
benchmarks game sonuçlarına bakınca, en hızlı Haskell uygulamaları genelde en hızlı C sürümlerinden 2–5 kat yavaş ve oldukça imperatif bir tarzda yazılmış
https://benchmarksgame-team.pages.debian.net/benchmarksgame/...
“Creating prefix-free codes” bölümündeki tabloda bir yazım hatası var gibi. D,
0110değil0010olmalı0110ın nasıl belirsiz olamayacağını uzun süre düşündüm; şimdi anladımGörseldeki kadının tişörtünde ne olduğunu merak ediyorum
Doğrudan bağlantı: https://lazamar.github.io/images/data-compressor.svg