看了HBO神劇《硅谷》之后一直對壓縮算法很感興趣。里面的Richard Hendricks和他的middle out壓縮算法當然是假的,但是努力谷歌了一番后發現現實生活中也有這么一位壓縮算法天才。
Yann Collet 在2011年發明了LZ4壓縮算法。LZ4算法當然沒有middle out那么牛逼得無死角,但在能保證一定壓縮率的情況下,它以它無敵的壓縮速度和更快的解壓速度著稱。
在此不再贅述對它壓縮速度的測評 有興趣可以看這篇對比文: https://blog.csdn.net/leilonghao/article/details/73200859
另附 LZ4 的github地址: https://github.com/lz4/lz4/
本文着重解釋LZ4壓縮算法的原理。之前有位大神寫過關於LZ4算法的解釋: https://blog.csdn.net/zhangskd/article/details/17282895 但之前在學習的時候感覺這篇文章對新手不大友好, 所以另起一篇面向像我這樣新手的文章。
如果一句話概括LZ4:LZ4就是一個用16k大小哈希表儲存字典並簡化檢索的LZ77。
那么LZ77又是什么呢?
LZ77壓縮與原理
LZ77是一個應用了字典來進行壓縮的算法。通俗來說,就是讓程序觀察(看字典)當前看到的數據是否和之前有重復, 如果有的話,我們就保存兩個重復字段的距離(offset)和重復的長度,以替代重復的字段而以此來壓縮數據。
參考https://msdn.microsoft.com/en-us/library/ee916854.aspx
對於一串字母 A A B C B B A B C, 在讀到第二個A的時候, 程序就會保存 (1,1) (距離上一個重復1位,長度為1),同理,在讀到第二個B的時候,程序就會保存(2,1)(距離2,長度1)。
但是假如字符串更長些,並且程序把數據一直都記錄進字典,那檢索重復字段的工作就會變得異常耗時,因為最壞情況下程序每讀到一個新的字母就會歷遍整個字典。 LZ77使用了滑動窗口的方法來解決這個問題。
與TCP使用滑動窗口的出發點相似,滑動窗口可以縮小緩存區來避免同時處理過多緩存數據。在LZ77中,字典不是一直增加的,而是在超過字典規定的最大大小時舍棄最早加入字典的字符,以此來保證字典的大小始終維持在規定的最大大小。
假設字典長度為3
| dictionary |
| A | A B C B B A B C
輸出(0,0,A)
| A A | B C B B A B C
輸出(1,1)
| A A B | C B B A B C
輸出(0,0,B)
A | A B C | B B A B C
輸出(0,0,C)
A A | B C B | B A B C
輸出(2,1)
滑動窗口的另外一部分則是待搜索緩存(look ahead buffer沒有正規翻譯)。 待搜索緩存就是離字典最近的還未經壓縮的一部分長度。 LZ77算法會在這部分字符中匹配出與字典相同的最長字符串。上一個例子可以視作look ahead buffer 為 1。
假設字典長度為5,待搜索緩存大小為3
| dictionary | look ahead buffer |
A | A B C B B | A B C |
輸出(5,3)
匹配出的最長字符串為ABC
完整的壓縮過程:
假設字典長度為5,待搜索緩存大小為3
| dictionary | look ahead buffer |
| A A B | C B B A B C
輸出(0,0,A)
| A | A B C | B B A B C
輸出(1,1)
| A A | B C B | B A B C
輸出(0,0,B)
| A A B | C B B | A B C
輸出(0,0,C)
| A A B C | B B A | B C
輸出(2,1)
| A A B C B | B A B | C
輸出(1,1)
A | A B C B B | A B C |
輸出(5,3)
A A B C | B B A B C | 。。。 |
在壓縮程序的輸出文件中無需保存字典,因為解壓程序會通過輸出的匹配單元來還原字典。
解壓過程
LZ77算法的一大優勢便是它的解壓非常快捷。
第一個匹配單元是 (0,0,A),則輸出A。
第二個匹配單元是 (1,1),則復制輸出字符串中前一位,復制長度為1,則輸出A。
。。。
最后一個匹配單元是 (5,3),則復制輸出字符串中往回看5位,復制長度為3,則輸出A,B,C.
在LZ77算法進行壓縮時,耗時最多的部分是在字典中找到待搜索緩存中最長的匹配字符。若是字典和待搜索緩存過短,則能找到匹配的幾率就會很小。所以LZ4對LZ77針對匹配算法進行了改動。
首先,LZ4算法的字典是一張哈希表。 字典的key是一個4字節的字符串,每個key只對應一個槽,槽里的value是這個字符串的位置。
LZ4沒有待搜索緩存, 而是每次從輸入文件讀入四個字節, 然后在哈希表中查找這字符串對應的槽,下文稱這個字符串為現在字符串。
如果已經到最后12個字符時直接把這些字符放入輸出文件。
如果槽中沒有賦值,那就說明這四個字節第一次出現, 將這四個字節和位置加入哈希表, 然后繼續搜索。
如果槽中有賦值,那就說明我們找到了一個匹配值。
如果槽中的值加滑動窗口的大小<現在字符的位置,那就說明匹配項位置超出了這個塊的大小,那程序將哈希槽中的值更新成現在字符串的位置。
LZ4會檢查一下有沒有發生過hash沖突。如果槽中值所在位置的4字節與現在字符串的不相同,那一定是發生了hash沖突。作者自己編的xxhash也是以高效著稱,但還是不可避免的會有沖突。遇到沖突, 程序也將哈希槽中的值更新成現在字符串的位置
最后我們能確認這個匹配項是有效的,程序會繼續看匹配字符串后續的字符是不是也相同。最后復制從上一個有效匹配項結束到本次有效匹配項開始前的所有字符到壓縮文件,並加上本次有效匹配項的匹配序列。
Collet 稱LZ4輸出的匹配單元為匹配序列(sequence),他們組成了LZ4的壓縮文件。具體如下圖:
令牌(token)長為1字節,其中前4個字為字面長度(literal length),而其后4個字為匹配長度(match length)。前文中講到會將上一個有效匹配項結束到本次有效匹配項開始前的所有字符復制到壓縮文件,這些未經壓縮的文件就是字面(literal),而他們的長度就是字面長度。
字面之后是偏差。和LZ77中偏差和匹配長度一樣,偏差指的是現在字符串離它匹配項的長度,而匹配長度指的是現在字符串與字典中相同字符串的匹配長度。在解壓是需要通過它來尋找需要復制的字符串和要復制的長度。偏差的大小是固定的。
圖中literal length+和match length+ 是如果令牌中的字面或者匹配長度的4個字不夠用了就在相應位置繼續增加。4個字能表示0-15,再多的話就在增加一個字節,也就是大小加上255,直到字節中不滿255。在匹配長度中,0代表4個字節。
最后12個字節在字面之后就結束了,因為它是直接被復制過去的。
我們來看下代碼(python 比較抽象)
[sourcecode language='python' padlinenumbers='true'] def find_match(table, val, src_buf, src_ptr): #find if there is a match in the hash table 從哈希表里看現在字符串是否有賦值 pos = table.get_position(val) #get the value of table[] at index hashed val if pos is not None and val == read_le_uint32(src_buf, pos): # see if val == the value of src buf at index pos if src_ptr - pos > MAX_OFFSET: # if the found match is too far away return None else: return pos else: return None def count_match(buf, front_idx, back_idx, max_idx): #在確認匹配項有效之后,繼續看匹配字符串后續的字符是不是也相同 cnt = 0 while back_idx <= max_idx: #until back goes to max if buf[front_idx] == buf[back_idx]: #check if match found in buf cnt += 1 else: break front_idx += 1 back_idx += 1 #since it is very likely that two sentences are repeated return cnt #dest_buffer, dst_ptr,memoryview(src_buffer),[literal_head:src_ptr],(src_ptr - match_pos, length) def copy_sequence(dst_buf, dst_head, literal, match): #literal is uncompressed, strait put in dest file將seqence寫入輸出文件 lit_len = len(literal) dst_ptr = dst_head # write literal length寫入令牌(字面長度和匹配長度) token = memoryview(dst_buf)[dst_ptr:dst_ptr + 1] #token is a list of two pointers to match len and literal length dst_ptr += 1 if lit_len >= 15: #如果字面長度不夠寫 token[0] = (15 << 4) remain_len = lit_len - 15 while remain_len >= 255: #until lit_len reach a num smaller than 255(a byte) store the literal length dst_buf [dst_ptr] = 255 dst_ptr += 1 remain_len -= 255 dst_buf[dst_ptr] = remain_len dst_ptr += 1 else: token[0] = (lit_len << 4)#moving space ffor match len # write literal寫入字面 dst_buf[dst_ptr: dst_ptr + lit_len] = literal dst_ptr += lit_len offset, match_len = match if match_len > 0: # write match offset write_le_uint16(dst_buf, dst_ptr, offset) dst_ptr += 2 # write match length如果匹配長度不夠寫 match_len -= MIN_MATCH if match_len >= 15: token[0] = token[0] | 15 match_len -= 15 while match_len >= 255: dst_buf[dst_ptr] = 255 dst_ptr += 1 match_len -= 255 dst_buf[dst_ptr] = match_len dst_ptr += 1 else: token[0] = token[0] | match_len return dst_ptr - dst_head def lz4_compress_sequences(dest_buffer, src_buffer): ''' Scan src_buffer, split it into sequences, store sequences to dest_buffer. A sequence is a pair of literal and match ''' src_len = len(src_buffer) if src_len > MAX_BLOCK_INPUT_SIZE: return 0 pos_table = PositionTable() #creates hash Table 創造哈希表 src_ptr = 0 # the front of match指針表示現在字符串的位置 literal_head = 0 # store the literal head postition is the dst_ptr = 0 # number of bytes writen to dest buffer MAX_INDEX = src_len - MFLIMIT # remained buffer less than MFLIMIT will not be compressed while src_ptr < MAX_INDEX: #loop until 12 remaining left直到最后12位 current_value = read_le_uint32(src_buffer, src_ptr) #reads 4 bytes讀4字節 match_pos = find_match(pos_table, current_value, src_buffer, src_ptr) #check if there exists a found position previously (a match) in the hash table 查詢哈希表 if match_pos is not None: length = count_match(src_buffer, match_pos, src_ptr, MAX_INDEX) #see how long the match is 看看總共匹配有多長 if length < MIN_MATCH: # because of MAX_INDEX break #I think never reached as each match is 4 byte dst_ptr += copy_sequence(dest_buffer, dst_ptr, memoryview(src_buffer)[ literal_head:src_ptr], (src_ptr - match_pos, length)) #將字面和匹配寫入壓縮文件 src_ptr += length #skipping over the matched word literal_head = src_ptr else: pos_table.set_position(current_value, src_ptr) #沒有匹配就把現在字符串的位置放進哈希表, src_ptr += 1 #if the 4 byte value not found in hash table, put it in and see if sliding by 1 matches # last literal最后的匹配項 dst_ptr += copy_sequence(dest_buffer, dst_ptr, memoryview(src_buffer)[literal_head:src_len], (0, 0)) return dst_ptr [/sourcecode]總結 說了這么多,還是沒有總結一下為什么LZ4這么快。 我們首先來對比一下LZ77和LZ4對字典的檢索。原生LZ77對字典的檢索方式是在待搜索緩存區和字典中尋找最大匹配項。 這是一道在兩個字符串中找最大相同字串的問題。 如果我們不使用動態規划,那最壞情況下就要考慮一個字符串的所有子串,然后還要在另一個字符串中進行匹配。 這樣算下來,就需要O(m^2×n).如果使用動態規划,那我們則會使用一個表來保存動態的最長匹配項,而這樣也只能讓我們在O(m*n)的情況下完成匹配。 而且,這只是對一對搜索緩存區和字典來說。在最壞情況下, 如果沒有任何匹配項,那LZ77就要算(整個文件的長度-待搜索緩存區長度)那么多道這樣的匹配運算題。 而LZ4其實運用了一個更大層面上的動態規划:它將4字節與其位置保存在一個哈希表中,然后每次匹配新的4字節數據只需看哈希表中的值是不是有效。而找到有效匹配值之后看兩組匹配值的后續數據時候也匹配則可以則可以找到它的最長匹配。由於LZ4的哈希表的每個key只對應1個槽,所以查找和增改哈希表的工作只需要O(1).如果在匹配后續找到了更多匹配,則需要更多組比較,但是在總時間里這些比較會代替更多的查哈希表的時間,所以LZ4算法的總時間只有O(n).不得不贊嘆Collet的算法的美感啊!為了讓算法的速度更上一層樓,Collet 設置默認的哈希表大小為16k,推薦不要超過32k,這樣就能把它放進幾乎所有cpu(intel)的一級緩存里。大家都知道cpu一級緩存的速度和內存比是天差地別的,所以LZ4的飛快速度也不足為奇,更何況LZ4的哈希表使用的哈希方程還是最快速的xxhash。 當然,這樣的設計也有弊端。哈希表越小,其key也會越少。這就表示會有更多的哈希沖突發生,這是不可避免的。而更多的哈希沖突則代表了更少的匹配項。 並且哈希表越小也代表了滑動窗口也會更小,也就是說,更多的匹配項會由於距離太遠而被舍棄。更少的匹配項就會代表較小的壓縮比例,這就是為什么LZ4的壓縮比例更不突出的原因。 展望 LZ4的應用場景非常廣泛。 如《硅谷》中middle out被應用於VR中一樣,LZ4由於壓縮速度非常快,LZ4可以以非常小的延遲為代價帶來更少的IO數量,以此來增加對帶寬的利用。下一個項目就打算編一個 minimum IO protocol。 還有一點點猜想,如果出了1級緩存更大的cpu,那LZ4是不是能在不損害速度的情況下提升壓縮比例呢?