8.5 pandas 的記憶體邊界:資料與運算過程的佔用估算,推回資料庫、換引擎與分散式的代價
pandas 把整批資料放進記憶體才開始算,所以記憶體容量是它的硬邊界。這個邊界可以事先估算,而越過之後有三個方向可以走,各自付不同的代價。
資料本身的佔用量估算
數值欄位每一格佔固定的位元組數,所以列數乘欄數乘每格的位元組數就是估算值。int64 與 float64 各佔 8 個位元組。
五百萬列、兩個數值欄的表格實測佔 76 MB,與 5,000,000 × 2 × 8 = 80 MB 相符。這個量級對現在的機器不構成問題,往上推一個量級(五千萬列)到 760 MB 仍然可行,再往上就要看機器。
字串欄位不適用這個乘法,它的佔用取決於實際的字元內容與 pandas 的字串儲存方式——這一欄只能量,不能算,量的方式是 df.memory_usage(deep=True)。
運算過程的中間結果佔用的空間
估算容易漏掉的是中間結果。合併與分組會產生暫時的陣列,而它們與原始資料同時存在於記憶體裡。
實測一次 merge:兩張表本身合計 38 MB,合併結果 46 MB,而合併期間的額外配置峰值是 136 MB——大約是那兩張表合計 38 MB 的三倍半。
所以判斷標準是「資料量的數倍要放得下」,而不是「資料量放得下」。倍數隨操作而變,合併與排序這類要重排全部資料的操作最高。
越過記憶體邊界之後的方向:推回資料庫、換引擎、分散式
把運算推回資料庫。 資料如果本來就在資料庫裡,先用 SQL 把量壓下來再載入。這條路不需要換工具,代價是那段運算必須用 SQL 表達得出來。
換成不需要全部載入的引擎。 DuckDB 接受 SQL,而查詢的對象可以直接是磁碟上的檔案。實測查一個 129 MB 的 CSV 算出列數與平均值,Python 端的配置峰值是 0.0 MB;同一件事用 pandas 先載入再算,峰值 114.5 MB、載入後的 DataFrame 佔 76 MB。
這裡的 0.0 MB 要照字面讀:量的是 Python 自己配置的記憶體,DuckDB 的運算發生在它自己的引擎裡、記憶體不經過 Python 的配置器。它逐段讀檔案,任何時刻只持有其中一段。
Polars 走的是另一條類似的路:介面接近 DataFrame,而它的延遲執行模式讓整段運算先被規劃過再執行,可以只讀用得到的欄位。
換成分散式。 資料量大到單機放不下時才走這個方向。Spark 把同一份運算拆到多台機器上,代價是叢集本身的維運成本與除錯難度,兩者都比單機高一個量級。
記憶體不足與跑得慢的分辨
記憶體不足有它自己的訊號:程序被作業系統終止,或者機器開始把記憶體換出到磁碟而整體速度崩落。這兩種現象都來自同一件事——需要的空間超過實體記憶體,作業系統只能終止程序或改用磁碟頂替。
跑得慢的成因在別處。逐列套用函式而沒有用向量化的操作,慢的是每一列都要繞一趟 Python 直譯器;反覆複製整張表,慢的是記憶體頻寬。這兩種都與容量無關,所以資料量再小它們也照樣發生,換到容量更大的工具上也照樣發生。
分開這兩者的意義在於推回資料庫、換引擎與分散式這幾個方向只解記憶體不足那一種。
延伸閱讀:運算發生的位置、SQL 與 pandas 的寫法對應、商業分析指標
推回資料庫這個方向的另一端:8.2 運算發生的位置 寫兩端的分工由哪三個條件決定,以及 DuckDB 為什麼同時站在兩邊。
推回去之後要用的語法對照:8.4 SQL 子句與 pandas 方法的對應:篩選、連接、分組彙總、視窗函數,以及空值、索引與順序的語意差異 給篩選、連接、分組彙總與視窗函數在兩邊的實測比對,以及翻譯時最容易靜默出錯的空值處理。
這批資料算出來之後拿去做什麼:monitoring 模組八 寫留存、分群與轉換率各自怎麼定義,以及事件當初的屬性設計決定了哪些指標事後算得出來。