83% 性能提升!CSV 至 JSON 轉換工具(fast-csv-to-json)優化記錄 - Ming Chang

83% 性能提升!CSV 至 JSON 轉換工具(fast-csv-to-json)優化記錄

bg 程式性能測試截圖

背景圖 by HitenKei

前言

由於這幾天是台灣的清明節連假,所以想說利用這個機會來 逃離工作時被逼著用的 JS 跟 TS 做點工作之外的事

正好我有個案子不想架後端與資料庫,想要利用 JSON 來儲存大量的資料,就計劃寫個 CSV 轉 JSON 的小工具來用

fast-csv-to-json GitHub Repository 連結 (原名 csv-to-json ,名字被人用走了只好換個名字 😥)

可利用 cargo install fast-csv-to-json 安裝不含 Rust nightly feature 的版本
Releases 裡也有已經編譯好的程式,沒有特別標註的話都是 for aarch64 macOS 的 binary

由於 Rust 的相關 crate 我都還算熟悉,結果只花了一個小時就搓出來了 😂 正在思考還有什麼事情可以做的時候,突然想到自己好像很久沒有優化程式了

原本我還胸有成竹,自己都用上了 BLAZINGLY FAST 🚀🚀🚀 的可愛 🦀 ,應該不會太慢才對?我賭兩秒以內!

meme1
  • 硬體:M1 MacBook Air, macOS 13.3
  • 測試用 CSV 檔:就是一個全都是字, 573.5 MB 的 CSV
  • 測試工具:hyperfine

測出來,3.3 秒(四捨五入)

meme2

於是這個我花了一個小時寫出來的 CLI Binary ,就和我一起開始了為期兩天的優化之旅

以下的每個章節都會附上與最初版程式相比的「總計性能進步百分比」跟「執行平均時間(秒)」,供讀者參考

第一步(18% - 2.8s):資料平行 Rayon

其實我很早就知道這個 crate 了,但由於我寫後端嘛, Tokio 就已經很香了,反而用不太到 Rayon

Rayon 是一個 data-parallelism library ,可以將傳統的迭代器 std::iter 能做到的事情,改為利用平行處理的迭代器 rayon::iter 去處理

而且寫法非常簡單:只需要把傳統迭代器 vec.iter() 換成 vec.par_iter() 就可以了

這邊要提醒一下,由於平行處理的本質就是開一堆 Thread 來處理資料,如果 closure 中有用到 scope 外面的變數,需要 impl Sync trait 或者用 Arc 包住才行。

Rayon 如何實現 Parallel Iterator

改完,我們利用 hyperfine 進行測試

測試結果
Benchmark 1: ./csv-to-json -i test.csv -o output-1.json
  Time (mean ± σ):      3.286 s ±  0.021 s    [User: 2.616 s, System: 0.585 s]
  Range (min … max):    3.260 s …  3.314 s    5 runs

Benchmark 2: ./csv-to-json-rayon -i test.csv -o output-2.json
  Time (mean ± σ):      2.794 s ±  0.014 s    [User: 4.537 s, System: 0.827 s]
  Range (min … max):    2.777 s …  2.814 s    5 runs

Summary
  './csv-to-json-rayon -i test.csv -o output-2.json' ran 1.18 ± 0.01 times faster than './csv-to-json -i test.csv -o output-1.json'

取得了 18% 的性能進步。

到這邊的程式碼:fast-csv-to-json 0.1.0

第二步(24% - 2.6s):用對的工具做對的事 indexmap

由於需要保留原始 CSV 的欄位排序,所以無法採用 std 中的 HashMap ,而是用了 BTreeMap

不過如果仔細看下 When Should You Use Which Collection? ,會發現 BTreeMap 根本不是幹這個的

所以我又去翻了萬能的 crates.io ,看到了 indexmap ,文件中宣稱 Fast to iterate 和 Preserves insertion order as long as you don’t call .remove()

啊呀,剛好符合我的需求啊!那就事不宜遲,改程式,測試!

測試結果
Benchmark 1: ./csv-to-json -i test.csv -o output-1.json
  Time (mean ± σ):      3.248 s ±  0.014 s    [User: 2.614 s, System: 0.573 s]
  Range (min … max):    3.224 s …  3.259 s    5 runs

Benchmark 2: ./csv-to-json-rayon -i test.csv -o output-2.json
  Time (mean ± σ):      2.806 s ±  0.016 s    [User: 4.547 s, System: 0.842 s]
  Range (min … max):    2.786 s …  2.826 s    5 runs

Benchmark 3: ./csv-to-json-indexmap -i test.csv -o output-3.json
  Time (mean ± σ):      2.621 s ±  0.029 s    [User: 4.574 s, System: 0.701 s]
  Range (min … max):    2.599 s …  2.671 s    5 runs

Summary
  './csv-to-json-indexmap -i test.csv -o output-3.json' ran
    1.07 ± 0.01 times faster than './csv-to-json-rayon -i test.csv -o output-2.json'
    1.24 ± 0.01 times faster than './csv-to-json -i test.csv -o output-1.json'

性能進步從 18% 增加到 24% 。

到這邊的程式碼:fast-csv-to-json 0.2.0

第三步(55% - 2.1s):無心插柳柳成蔭 Polars

性能提升總是讓人開心,但……還能不能再更快一點呢?離兩秒內的目標還有蠻大的差距

其實我原本並沒有打算要把 CSV 轉成其他格式,但網際網路就是這麼的神奇,常常在亂晃的時候看到可能有價值的東西

這邊用了 Polars ,可以將 CSV 轉換為 DataFrame ,號稱 lightning fast

由於我本質上並沒有要對 CSV 中的資料做任何的查詢,所以我當初對這個套件,是抱持著「就算沒有性能進步也沒關係」的心情測試的

沒想到,竟然產生了意想不到的結果?

測試結果
Benchmark 1: ./csv-to-json -i test.csv -o output-1.json
  Time (mean ± σ):      3.258 s ±  0.024 s    [User: 2.630 s, System: 0.563 s]
  Range (min … max):    3.235 s …  3.292 s    5 runs

Benchmark 2: ./csv-to-json-rayon -i test.csv -o output-2.json
  Time (mean ± σ):      2.757 s ±  0.015 s    [User: 4.480 s, System: 0.787 s]
  Range (min … max):    2.735 s …  2.774 s    5 runs

Benchmark 3: ./csv-to-json-indexmap -i test.csv -o output-3.json
  Time (mean ± σ):      2.569 s ±  0.019 s    [User: 4.537 s, System: 0.650 s]
  Range (min … max):    2.547 s …  2.594 s    5 runs

Benchmark 4: ./csv-to-json-polar -i test.csv -o output-4.json
  Time (mean ± σ):      2.106 s ±  0.013 s    [User: 2.552 s, System: 0.573 s]
  Range (min … max):    2.086 s …  2.118 s    5 runs

Summary
'./csv-to-json-polar -i test.csv -o output-4.json' ran
    1.22 ± 0.01 times faster than './csv-to-json-indexmap -i test.csv -o output-3.json'
    1.31 ± 0.01 times faster than './csv-to-json-rayon -i test.csv -o output-2.json'
    1.55 ± 0.01 times faster than './csv-to-json -i test.csv -o output-1.json'

性能進步再次從 24% 增加到 55% 。

根據 Polars API 文件中標示的說明: get_row 這個 method 因性能不好而不建議使用,所以這邊應該還有改進的可能(?)

第四步(60% - 2.0s):不如預期 SIMD

正當我滿意的準備 git push 時, Polars 的文件有個小段落引起了我的注意

SIMD ??? SIMD 還能拿來加速這種運算?

下面沒有特別標註的話,都是在 M1 MacBook 上用 ARM NEON 指令集測出來的結果

我沒有支援 AVX-512 的 x86_64 PC 可以測試,要不然我還蠻想知道 AVX-512 到底有多快

裝上 nightly Rust toolchain ,features 裝好,RUSTFLAGS="-C target-cpu=native" cargo +nightly build --release

測試結果
Benchmark 1: ./csv-to-json -i test.csv -o output-1.json
  Time (mean ± σ):      3.271 s ±  0.009 s    [User: 2.617 s, System: 0.566 s]
  Range (min … max):    3.260 s …  3.284 s    5 runs

Benchmark 2: ./csv-to-json-rayon -i test.csv -o output-2.json
  Time (mean ± σ):      2.780 s ±  0.010 s    [User: 4.516 s, System: 0.789 s]
  Range (min … max):    2.770 s …  2.792 s    5 runs

Benchmark 3: ./csv-to-json-indexmap -i test.csv -o output-3.json
  Time (mean ± σ):      2.582 s ±  0.013 s    [User: 4.558 s, System: 0.649 s]
  Range (min … max):    2.571 s …  2.598 s    5 runs

Benchmark 4: ./csv-to-json-polar -i test.csv -o output-4.json
  Time (mean ± σ):      2.114 s ±  0.023 s    [User: 2.561 s, System: 0.574 s]
  Range (min … max):    2.083 s …  2.142 s    5 runs

Benchmark 5: ./csv-to-json-simd -i test.csv -o output-5.json
  Time (mean ± σ):      2.046 s ±  0.066 s    [User: 2.483 s, System: 0.554 s]
  Range (min … max):    2.004 s …  2.163 s    5 runs

Summary
  './csv-to-json-simd -i test.csv -o output-5.json' ran
    1.03 ± 0.04 times faster than './csv-to-json-polar -i test.csv -o output-4.json'
    1.26 ± 0.04 times faster than './csv-to-json-indexmap -i test.csv -o output-3.json'
    1.36 ± 0.04 times faster than './csv-to-json-rayon -i test.csv -o output-2.json'
    1.60 ± 0.05 times faster than './csv-to-json -i test.csv -o output-1.json'
</code>

性能進步只從 55% 小幅增加到 60% ,遠遠不如我預期中的秒天秒地,有種被騙的感覺

但我覺得可能不能怪 SIMD ,第一是誰也說不準 Polars 用了哪些 SIMD ,第二是搞不好這種操作就不是 SIMD 的強項

誰知道呢?

在 Intel Core i7 12700(支援 AVX2 指令集,AVX-512 不支援)測試的結果
Benchmark 1: ./csv-to-json -i test.csv -o output-1.json
  Time (mean ± σ):      2.644 s ±  0.010 s    [User: 2.039 s, System: 0.605 s]
  Range (min … max):    2.637 s …  2.660 s    5 runs

Benchmark 2: ./csv-to-json-simd -i test.csv -o output-2.json
  Time (mean ± σ):      1.978 s ±  0.007 s    [User: 2.702 s, System: 1.082 s]
  Range (min … max):    1.969 s …  1.989 s    5 runs
Summary
  './csv-to-json-simd -i test.csv -o output-2.json' ran
    1.34 ± 0.01 times faster than './csv-to-json -i test.csv -o output-1.json'

可以看到,即使是在支援更多 SIMD 指令集的電腦上,也沒有顯著的性能進步

到這邊的程式碼:fast-csv-to-json 0.3.0

第五步(83% - 1.8s):看圖優化,對症下藥 flamegraph (CLI 工具)

當我仔細的觀察程式的 CPU 佔用時,其實有個點一直讓我很不解:明明都用上了 Rayon ,怎麼在全核心跑完後,程式還會繼續用單線程執行一小段時間呢?

重新閱讀了一次程式,再跑了下 flamegraph ,發現 serde_json 的 to_string 似乎可以優化一下

Screenshot 2023-04-04 at 3 58 09 PM 在 Linux PC 跑出來的 flamegraph ,可以發現 `to_string` 裡面用的是普通的 iterator

用單線程跑 570 幾 MB 的資料確實不是很理想,但我花了一整天都沒看出來,太尷尬了 😅

這時我想到了兩種解法:

  1. 自己看看 serde_json 是如何實現這支程式的,然後寫多線程版
  2. 在處理資料的時候就先把資料序列化,最後再將資料組合起來

解法二目前是比較好的解法: 第一是我可以直接利用資料處理的 parallel iterator ,不需要重新迭代 第二是這樣也比較簡單

所以就實作一下,跑個 hyperfine 吧

測試結果
Benchmark 1: ./csv-to-json -i test.csv -o output-1.json
  Time (mean ± σ):      3.223 s ±  0.019 s    [User: 2.605 s, System: 0.533 s]
  Range (min … max):    3.204 s …  3.245 s    5 runs

Benchmark 2: ./csv-to-json-rayon -i test.csv -o output-2.json
  Time (mean ± σ):      2.738 s ±  0.015 s    [User: 4.500 s, System: 0.760 s]
  Range (min … max):    2.724 s …  2.757 s    5 runs

Benchmark 3: ./csv-to-json-indexmap -i test.csv -o output-3.json
  Time (mean ± σ):      2.525 s ±  0.012 s    [User: 4.557 s, System: 0.613 s]
  Range (min … max):    2.512 s …  2.541 s    5 runs

Benchmark 4: ./csv-to-json-polar -i test.csv -o output-4.json
  Time (mean ± σ):      2.070 s ±  0.021 s    [User: 2.550 s, System: 0.537 s]
  Range (min … max):    2.042 s …  2.089 s    5 runs

Benchmark 5: ./csv-to-json-simd -i test.csv -o output-5.json
  Time (mean ± σ):      2.001 s ±  0.035 s    [User: 2.482 s, System: 0.540 s]
  Range (min … max):    1.972 s …  2.057 s    5 runs

Benchmark 6: ./csv-to-json-parallel-serialize  -i test.csv -o output-6.json
  Time (mean ± σ):      1.761 s ±  0.010 s    [User: 3.689 s, System: 0.497 s]
  Range (min … max):    1.749 s …  1.771 s    5 runs

Summary
  './csv-to-json-parallel-serialize  -i test.csv -o output-6.json' ran
    1.14 ± 0.02 times faster than './csv-to-json-simd -i test.csv -o output-5.json'
    1.18 ± 0.01 times faster than './csv-to-json-polar -i test.csv -o output-4.json'
    1.43 ± 0.01 times faster than './csv-to-json-indexmap -i test.csv -o output-3.json'
    1.56 ± 0.01 times faster than './csv-to-json-rayon -i test.csv -o output-2.json'
    1.83 ± 0.01 times faster than './csv-to-json -i test.csv -o output-1.json'

比起原始版快了 83% ,平均用時也壓到兩秒以內了!

再來看看 flamegraph

  • Before

    1
  • After

    2

同樣以 “serde” 做為關鍵字(紫色區塊是查詢結果),可以清楚的看到序列化的工作已經被分散開來了。

未來可能會找個時間嘗試實作 serde_json 搭配 Rayon 進行平行序列化吧,看起來提升挺大的。

到這邊的程式碼:fast-csv-to-json 0.3.1

後記

這幾天這麼寫下來,雖然程式碼不長(才 74 行),但讓我學到了很多平常寫程式時學不到的事:

  1. Rayon 讓 Rust 開發平行運算變得簡單自然,未來會在生產環境中多用。
  2. 每一個 struct 都有自己擅長處理的內容,如果要以性能為優先考量的話,需要仔細閱讀文件中的內容,並按照內容選用最適合的那個。
  3. 永遠不要因為麻煩而不去嘗試。(當然,如果有時程壓力,那還是以產出成品為主)
  4. SIMD 不是萬靈丹。
  5. flamegraph 是個好工具,不僅可以幫助自己找出程式的性能瓶頸,也能藉此理解每個 function 下的實現方式。

從去年開始,我每個連假都學到了一點東西,未來也會繼續學習!

感謝您的閱讀!