如果用配套程式碼來解碼 1920 * 1080 的圖片,大約會需要將近一秒(我用的 CPU 是 AMD Ryzen 2700X),但是使用一般的開圖軟體(例如 eog, eye of gnome),幾乎是瞬間解開,這代表目前的解碼器還有很多的優化空間。
經過簡單的性能分析之後,大約可以發現效能瓶頸的前兩名是
- 反離散餘弦變換
- 霍夫曼解碼
其中反離散餘弦變換的計算量是霍夫曼解碼的好幾倍,建議優先優化。
解碼器使用到的系統呼叫就只有讀檔寫檔,只會佔總體運行時間的幾 % 而已,如果用 time 指令觀察到的系統時間佔比不太尋常,那應該先嘗試優化 IO ,或許加上一些緩衝區,避免一直系統呼叫,就會有很好的提升。
先將反離散餘弦變換的公式再列出一次,以方便讀者查閱:
$ result[i][j] = \frac{1}{4}\sum{{x=0} ^7}\sum{{y=0} ^7}C_x C_y cos(\frac{(2i + 1)x\pi}{16}) cos (\frac{(2j + 1)y\pi}{16}) block[x][y] $
其中的
來計算以下直接照定義展開展開的時間複雜度,我們有
觀察式中的
反離散餘弦變換的公式在兩層
而其實
原式就可以寫爲:
$ result[i][j] = \frac{1}{4}\sum{{x=0} ^7}\sum{{y=0} ^7} r(i,x) r(j,y) block[x][y] = \frac{1}{4}\sum{{x=0} ^7} r(i,x) \sum{{y=0} ^7}r(j,y) block[x][y] $
注意到等式最右側的第二個
計算一個 s(x, j) 的複雜度爲
藉由兩步驟一維變換來得到二維變換,我們將複雜度由$O(n^4)$降至$O(n^3)$。
在 JPEG 解碼過程中,$n = 8$,但一維的要做兩次,故大略可提昇$8 / 2 = 4$倍效能。
有另一個更清晰的方式來理解上述的變換,觀察
$\sum{{x=0} ^7}\sum{{y=0} ^7} r(i,x) r(j,y) block[x][y]$
欸?是不是有點像矩陣乘法?若令矩陣
其中包含了兩次矩陣乘法,分別對應前述的兩次一維的反離散餘弦變換(記得照定義展開的矩陣乘法時間複雜度也是$O(n^3)$),但將它表示成矩陣乘法之後,就能夠利用計算矩陣乘法的算法,進一步降低時間複雜度。
TODO
配套程式碼裡,儲存霍夫曼表的資料結構爲求實作簡單,直接使用了 rust 標準庫裡的 HashMap 。雖然查詢的複雜度是 O(1) ,但常數項一定相當大,畢竟要執行過一個 hash 函數。
一個很自然的優化方式是把它寫成二元樹,但是利用範式霍夫曼表的特性,我們能寫出一種更簡單,效能也許也更好的實作。
回顧一下範式霍夫曼表中碼字的公式:
- 若高度相等:$leaf[n] = leaf[n - 1] + 1$
- 若高度差 1 :$leaf[n] = (leaf[n - 1] + 1) * 2$
- 若高度差 k :$leaf[n] = (leaf[n - 1] + 1) * 2^k$
可以得到一個重要的觀察,當碼字長度(高度)相等時,它們的值是連續的(這裏的連續是指整數上的連續),因爲每次都恰好增加 1。
有了這個特性,我們就不再需要 hash 函數,爲不同長度的碼字各建立一個陣列(也就是 16 個陣列),直接將碼字的二進位值當做索引所得到的位置,就能夠拿來儲存該碼字所對應的信源編碼。
當然,爲了節省空間,可以將索引減去該長度最小碼字的值,使得最小碼字的索引從 0 開始。