iT邦幫忙

2025 iThome 鐵人賽

DAY 5
0
Rust

30天解鎖 Rust 開發者工具箱系列 第 5

「Day 05」一目萬行,過目不忘:polars

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20250919/20177832qJ5QHpazDS.jpg
圖:“Rust 的吉祥物 Ferris the Crab 雙鉗狂寫卷軸文書”,gemini-2.5-flash-preview,2025年09月19日。

前言:告別 Pandas 龜速:用 Polars 擁抱極致效能

身為資料科學家、AI 工程師出生,長期泡在 Python 生態系中,一定知道 pandas 這個套件,但面對逐漸龐大的資料流量,一次性大量、大量且頻繁查詢或處理,pandas 實在是越來越吃不消了。大筆資料檢索、複雜邏輯操作,都會耗費掉大量的計算資源與記憶體,進而壓縮單位時間能夠處理的流量與請求,成本也增加,地獄螺旋,煩不完啊....

polars:我來解救你!

polars 是一個從頭開始用 Rust 撰寫的、極度快速的 DataFrame 函數庫,專為高效能的記憶體內 (in-memory) 資料處理與分析而設計。Polars 官方網站 強大的專案大概率都有強大技術文件,實際上他們的技術文件已經非常完整、淺顯易懂了,實作面也已經相當成熟,除了原生 Rust 生態,也有以 python wrapper 的方式推出 python 的函數庫,已成為當下資料處理界不能不知道的角色之一,pip install polars 就可使用。今天這篇文章,會揭露 polars 如此高效能的幾點核心原因。

效能!效能!效能!

核心一:Apache Arrow 列式儲存 (Columnar Format) - 換種思路嘛

這是理解 Polars 一切高效能的基石。

傳統的「列式儲存」(Row-based) 就像通訊錄:

  • 想像一個傳統的試算表或資料庫,記憶體中儲存的方式是:(張三, 30歲, 台北), (李四, 25歲, 高雄), (王五, 35歲, 台北)
  • 如果今天只想要計算所有人的「平均年齡」,電腦必須讀取每一筆完整的紀錄(包含姓名、城市),然後把不需要的姓名和城市資訊丟掉,只留下年齡。這造成了大量的記憶體頻寬浪費和 CPU 快取失效 (Cache Miss)。

Polars 的「行式儲存」(Columnar) 就像分頁的通訊錄:

  • Polars 底層採用 Apache Arrow 格式,資料在記憶體中是這樣儲存的:
  • 姓名欄:(張三, 李四, 王五)
  • 年齡欄:(30, 25, 35)
  • 城市欄:(台北, 高雄, 台北)
  • 現在,同樣的要計算所有人的「平均年齡」,電腦只需要讀取年齡欄那一塊連續的記憶體。這不僅讀取量大減,更重要的是,資料是連續且型別一致的,這就為 SIMD (Single Instruction, Multiple Data)和多核心並行處理鋪平了道路。

核心二:SIMD - CPU 平行計算不用白不用

單指令多資料流 (Single Instruction, Multiple Data) 可以想像成一個「超寬的計算通道」。

  • 傳統計算是:30 + 1,25 + 1,35 + 1,執行三次加法指令。
  • 有了 SIMD,CPU 可以用一條指令完成:(30, 25, 35, ... N個數字) + 1,一次性對多個數字執行相同的操作。
  • 因為 Arrow 的資料是連續且型別一致的(例如一整塊都是 i32),Polars 可以非常高效地利用 SIMD 指令集(如 AVX2, AVX-512)進行向量化計算,實現硬體層級的加速。

核心三:多核心並行 - 因為我是 Rust

基於 python 的 pandas,就受限於 Python 的全域直譯器鎖 (GIL),即使在多核心 CPU 上,同一個時間點也只有一個執行緒能真正執行 Python 位元組碼,這使得 Pandas 在並行化上舉步維艱。

Rust 就不同了,因為:

  • Rust 的所有權 (Ownership) 和借用檢查 (Borrow Checker) 在編譯時期就杜絕了資料競爭 (Data Race) 這類並行程式設計中最頭痛的問題。
  • 這讓 Polars 的開發者可以充滿信心地大量採用平行處理演算法(如 Rayon crate),將資料分塊(例如,一個 8 核心 CPU,就把一個大欄位切成 8 塊),然後交給不同核心同時計算,最後再安全地將結果合併。

總結

Apache Arrow 格式整理好數據,讓資料適合被分析 -> SIMD 在單一核心內進行向量化加速 -> 多核心並行,將任務分配到所有核心。三核心相輔相成,Polars 的極致效能是必然的。

實作範例

專案設定 - Cargo.toml 配置

[dependencies]
polars = { version = "0.51.0", features = [
    "lazy",      # 啟用懶載入功能
    "csv",       # CSV 檔案處理
    "dtype-full" # 完整資料類型支援
] }

今日四個範例:main 函數

use polars::prelude::*;

fn main() -> Result<(), PolarsError> {
    demo_lazy()?;              // 範例一:Lazy API 
    demo_aggregation()?;       // 範例二:資料聚合操作
    demo_with_columns()?;      // 範例三:列操作
    demo_groupby_and_window()?; // 範例四:分組與視窗操作
    Ok(())
}

範例一: demo_lazy() - Lazy API

函數原始碼

fn demo_lazy() -> Result<(), PolarsError> {
    // 建立以 polars 的 df! 宏建立 DataFrame
    let df = df![
        "category" => &["A", "A", "B", "C", "B", "A"],
        "values" => &[10, 20, 5, 40, 50, 60],
        "counts" => &[1, 2, 3, 1, 5, 3]
    ]?;

    // 建立查詢計算(尚未執行任何計算)
    let lazy_plan = df.lazy()
        .filter(col("category").eq(lit("A"))) // 過濾條件一:取得等於 “A” 的資料
        .filter(col("counts").gt(lit(1))) // 過濾條件二:取得大於 1 的資料  
        .select([col("values").sum()]); // 選擇條件:“values” 欄位進行累加

    // Polars 會對上面定義好的鏈路邏輯進行重新排列等優化操作,
    // 亦不會建立中間結果,所以能夠取得最佳化效能(計算+記憶體)
    let result_lazy = lazy_plan.collect()?;
    let sum_lazy = result_lazy.column("values")?.get(0)?.try_extract::<i32>()?;
    println!("[Lazy 模式] 'A' 類別且 'counts' > 1 的 'values' 總和: {:?}", sum_lazy);

    Ok(())
}

結果輸出

[Lazy 模式] 'A' 類別且 'counts' > 1 的 'values' 總和: 80

範例二: demo_aggregation() - 資料聚合操作

函數原始碼

fn demo_aggregation() -> Result<(), PolarsError> {
    // 建立以 polars 的 df! 宏建立 DataFrame
    let df = df![
        "category" => &["A", "A", "B", "B", "C", "C"],
        "value" => &[10, 20, 30, 40, 50, 60]
    ]?;

    // 使用 Lazy API 進行分組聚合(定義->優化->一次執行)
    let result = df.lazy()
        .group_by(["category"])                    // 按照 “category” 欄位進行分組
        .agg([                                     // 開始聚合操作,針對同類別進行一下邏輯操作
            col("value").sum().alias("total"),     // 加總後欄位名為 “total”
            col("value").mean().alias("average"),  // 平均後欄位名為 “average”
            col("value").count().alias("count")    // 計數後欄位名為 “count”
        ])
        .sort(["category"], Default::default())    // 排序
        .collect()?;                               // 優化,一次執行

    println!("\n[Aggregation] 分組統計結果:\n{}", result);
    Ok(())
}

結果輸出

[Aggregation] 分組統計結果:
shape: (3, 4)
┌──────────┬───────┬─────────┬───────┐
│ category ┆ total ┆ average ┆ count │
│ ---      ┆ ---   ┆ ---     ┆ ---   │
│ str      ┆ i32   ┆ f64     ┆ u32   │
╞══════════╪═══════╪═════════╪═══════╡
│ A        ┆ 30    ┆ 15.0    ┆ 2     │
│ B        ┆ 70    ┆ 35.0    ┆ 2     │
│ C        ┆ 110   ┆ 55.0    ┆ 2     │
└──────────┴───────┴─────────┴───────┘

範例三 demo_with_columns() - 列操作

函數原始碼

fn demo_with_columns() -> Result<(), PolarsError> {
    // 建立以 polars 的 df! 宏建立 DataFrame
    let df = df![
        "A" => &[1.0, 2.0, 3.0, 4.0, 5.0],
        "B" => &[5.0, 4.0, 3.0, 2.0, 1.0],
        "text" => &["hello", "world", "polars", "is", "fast"]
    ]?;

    // 在一個 with_columns 上下文中,同時定義多個新欄位的產生邏輯
    let df_transformed = df.lazy()
        .with_columns(&[
            // 算術操作
            (col("A") + col("B")).alias("A_plus_B"),
            // 條件邏輯
            when(col("A").gt(lit(3.0)))
                .then(lit("high"))
                .otherwise(lit("low"))
                .alias("A_category"),
        ])
        .collect()?;

    println!("\n[with_columns] 一次性新增多個欄位:\n{}", df_transformed);
    Ok(())
}

結果輸出

[with_columns] 一次性新增多個欄位:
shape: (5, 5)
┌─────┬─────┬────────┬──────────┬────────────┐
│ A   ┆ B   ┆ text   ┆ A_plus_B ┆ A_category │
│ --- ┆ --- ┆ ---    ┆ ---      ┆ ---        │
│ f64 ┆ f64 ┆ str    ┆ f64      ┆ str        │
╞═════╪═════╪════════╪══════════╪════════════╡
│ 1.0 ┆ 5.0 ┆ hello  ┆ 6.0      ┆ low        │
│ 2.0 ┆ 4.0 ┆ world  ┆ 6.0      ┆ low        │
│ 3.0 ┆ 3.0 ┆ polars ┆ 6.0      ┆ low        │
│ 4.0 ┆ 2.0 ┆ is     ┆ 6.0      ┆ high       │
│ 5.0 ┆ 1.0 ┆ fast   ┆ 6.0      ┆ high       │
└─────┴─────┴────────┴──────────┴────────────┘

範例四: demo_groupby_and_window() - 分組與視窗操作

函數原始碼

fn demo_groupby_and_window() -> Result<(), PolarsError> {
    // 建立以 polars 的 df! 宏建立 DataFrame
    let df = df![
        "department" => &["HR", "IT", "IT", "HR", "Sales", "Sales", "IT"],
        "employee" => &["Alice", "Bob", "Charlie", "David", "Eve", "Frank", "Grace"],
        "salary" => &[60000, 80000, 95000, 75000, 120000, 110000, 150000]
    ]?;

    // --- GroupBy 操作 ---
    // 對 'department' 進行分組,並一次性計算多個聚合指標
    let aggregated_df = df.clone().lazy()
        .group_by(["department"])
        .agg(&[
            col("salary").sum().alias("total_salary"),
            col("salary").mean().alias("average_salary"),
            col("employee").count().alias("employee_count"),
        ])
        .sort(["department"], Default::default())
        .collect()?;
    println!("\n[GroupBy] 按部門聚合計算:\n{}", aggregated_df);

    // --- Window Function 操作 ---
    // 在不改變 DataFrame 形狀的情況下,計算每個員工薪水佔其部門總薪水的百分比
    let window_df = df.lazy()
        .with_columns(&[
            // over([..]) 定義了計算的 "視窗" 或 "分區"
            (col("salary") * lit(100.0) / col("salary").sum().over(["department"]))
                .alias("salary_%_of_department"),
            // 簡化排名計算 - 移除複雜的排名功能
            (col("salary") / col("salary").max().over(["department"]))
                .alias("salary_ratio_in_department"),
        ])
        .sort(["department", "salary"], Default::default())
        .collect()?;

    println!("\n[Window Function] 在部門內計算薪水佔比與排名:\n{}", window_df);

    Ok(())
}

結果輸出

[GroupBy] 按部門聚合計算:
shape: (3, 4)
┌────────────┬──────────────┬────────────────┬────────────────┐
│ department ┆ total_salary ┆ average_salary ┆ employee_count │
│ ---        ┆ ---          ┆ ---            ┆ ---            │
│ str        ┆ i32          ┆ f64            ┆ u32            │
╞════════════╪══════════════╪════════════════╪════════════════╡
│ HR         ┆ 135000       ┆ 67500.0        ┆ 2              │
│ IT         ┆ 325000       ┆ 108333.333333  ┆ 3              │
│ Sales      ┆ 230000       ┆ 115000.0       ┆ 2              │
└────────────┴──────────────┴────────────────┴────────────────┘

[Window Function] 在部門內計算薪水佔比與排名:
shape: (7, 5)
┌────────────┬──────────┬────────┬────────────────────────┬───────────────────────┐
│ department ┆ employee ┆ salary ┆ salary_%_of_department ┆ salary_ratio_in_depar │
│ ---        ┆ ---      ┆ ---    ┆ ---                    ┆ tment                 │
│ str        ┆ str      ┆ i32    ┆ f64                    ┆ ---                   │
│            ┆          ┆        ┆                        ┆ i32                   │
╞════════════╪══════════╪════════╪════════════════════════╪═══════════════════════╡
│ HR         ┆ Alice    ┆ 60000  ┆ 44.444444              ┆ 0                     │
│ HR         ┆ David    ┆ 75000  ┆ 55.555556              ┆ 1                     │
│ IT         ┆ Bob      ┆ 80000  ┆ 24.615385              ┆ 0                     │
│ IT         ┆ Charlie  ┆ 95000  ┆ 29.230769              ┆ 0                     │
│ IT         ┆ Grace    ┆ 150000 ┆ 46.153846              ┆ 1                     │
│ Sales      ┆ Frank    ┆ 110000 ┆ 47.826087              ┆ 0                     │
│ Sales      ┆ Eve      ┆ 120000 ┆ 52.173913              ┆ 1                     │
└────────────┴──────────┴────────┴────────────────────────┴───────────────────────┘

結語:工具+1

Rust 開發者工具箱累積的第一個超實用工具,用了幾個簡單的範例初探了 polars 的新型態的資料處理邏輯,身為資料科學家、AI 工程師這一定不能錯過,是當代處理大數據的一大基石工具。持續增加我們手中的工具吧!
https://github.com/liren0907/rust_one


上一篇
「Day 04」萬物展開:cargo workspace
下一篇
「Day 06」Rust 之眼,開!
系列文
30天解鎖 Rust 開發者工具箱9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言