Python生态下并行计算:Polars、Dask、Modin、Joblib

本文汇总介绍几个Python生态并行计算类库:

  • Dask:带任务调度的并行计算框架
  • Joblib:并行计算框架

Ray

有待学习,另起一篇。

Polars

官网中文官网,开源(GitHub,38.9K Star,2.9K Fork)数据处理库,用Rust编写,提供Python绑定,英文文档中文文档

核心技术原理:

  • Apache Arrow内存格式:使用列式内存格式,实现CPU缓存友好和零拷贝数据交换。
  • 查询优化器:对整个查询进行优化,并利用多核进行向量化处理。
  • 惰性执行:通过LazyFrame构建查询计划并整体优化,然后并行执行。

核心价值:在单机环境下,提供比Pandas快数倍到数十倍的性能,尤其擅长复杂的数据聚合和连接操作。

核心基础数据结构是Series(一维同构数据结构)和Dataframe(二维异构数据结构),支持多种数据类型:

  • 数值数据类型:有符号整数、无符号整数、浮点数和Decimal
  • 嵌套数据类型:列表、结构体和数组
  • 时间类型:日期、日期时间、时间和时间差
  • 杂项:字符串、二进制数据、布尔值、分类数据、枚举和对象

快速检查Dataframe的方法:

  • head:默认前5行
  • tail:默认最后5行
  • glimpse:输出的每一行对应一个单独的列
  • sample:获取任意数量的随机选择行,这些行不一定按它们在Dataframe中出现的相同顺序返回
  • describe:计算Dataframe中所有列的汇总统计信息

表达式,数据转换的惰性表示:

py 复制代码
import polars as pl

pl.col("weight") / (pl.col("height") ** 2)

表达式需要一个上下文来执行并产生结果。根据使用的上下文,相同Polars表达式可产生不同结果。官方提供四种常见上下文:

  • select:选择,将表达式应用于列,可生成新列,这些列可以是聚合结果、其他列的组合或字面值
  • with_columns:与select非常相似。会创建新DataFrame,其中包含原始DataFrame中的列以及根据其输入表达式生成的新列,而select上下文只包含其输入表达式选择的列。
  • filter:根据一个或多个评估为布尔数据类型的表达式来筛选DataFrame的行
  • group_by:行根据分组表达式的唯一值进行分组

支持两种操作模式:

  • 惰性:lazy,查询只有在被收集(.collect())后才会被评估。将执行推迟到最后一刻可以带来显著的性能优势
  • 即时:eager,默认使用
py 复制代码
import polars as pl

q = (
	pl.scan_csv("docs/assets/data/iris.csv")
	.filter(pl.col("sepal_length") > 5)
	.group_by("species")
	.agg(pl.col("sepal_width").mean())
)
df = q.collect()

实战

使用方式:

  • SaaS云
  • pip安装:pip install polars

Dask

官网,用于并行计算的开源(GitHub,13.9K Star,1.9K Fork)库,能够将计算扩展到单机多核或分布式集群上。提供一系列高级集合(如dask.arraydask.dataframedask.bagdask.delayed),这些集合模仿NumPy、Pandas和Python内置迭代器的API,同时能够处理超出内存大小的数据集,并利用并行计算加速处理。

Dask的核心是任务调度器,负责将计算任务图(DAG)分配到工作线程或进程上执行,提供主要的调度器:

  • 单线程调度器:用于调试,行为类似于普通Python;
  • 多线程调度器:利用线程并行执行,适合受CPU计算限制但无GIL问题的任务(如NumPy操作);
  • 多进程调度器:利用进程并行执行,适合受GIL限制的纯Python代码;
  • 分布式调度器:在集群上运行,提供更强大的功能和监控界面。

实战

通过pip安装:pip install dask

使用dask.compute()方法时,可通过scheduler参数指定调度器。分布式调度器需要先创建Client。

py 复制代码
import dask
import dask.array as da
# 创建随机数组
x = da.random.random((10000, 10000), chunks=(1000, 1000))
# 使用多线程调度器
result = x.mean().compute(scheduler='threads')
# 使用多进程调度器
result = x.mean().compute(scheduler='processes')

Dask提供四种主要的数据结构,将大型数据切分为小块,并在块上执行操作。

  • Dask Array:提供类似于NumPy的接口,支持多维数组操作,但数据被分块(chunks),这些块可以是NumPy数组或其他类似数组的对象。当执行操作时,Dask会为每个块构建任务图,并行执行。

创建数组

py 复制代码
import dask.array as da
import numpy as np

# 从NumPy数组创建,指定块大小
x = da.from_array(np.arange(10000), chunks=1000)
print(x) # dask.array<from_array, shape=(10000,), chunks=(1000,), dtype=int64>
# 使用随机数创建,指定块大小和形状
y = da.random.random((5000, 5000), chunks=(500, 500))
print(y)

数组运算

py 复制代码
# 基本数学运算(延迟执行)
z = (x + 1) * 2 - 5
result = z.compute() # 触发计算,返回NumPy数组
# 聚合操作
mean_val = y.mean().compute()
print(mean_val)
# 切片和索引
slice_y = y[1000:3000, 1000:3000].compute()

分块控制

py 复制代码
# 重新分块
y_rechunked = y.rechunk((1000, 1000))
print(y_rechunked.chunks)

存储结果

py 复制代码
# 保存为NumPy文件
z.to_npy('output.npy')
  • Dask DataFrame:模仿Pandas DataFrame的API,适用于处理大型表格数据(如CSV、Parquet文件)。将数据按行分区(partitions),每个分区是一个Pandas DataFrame。

读取数据

py 复制代码
import dask.dataframe as dd

df = dd.read_csv('data.csv', blocksize='64MB') # 按大小自动分区
# 读取多个文件(支持通配符)
df = dd.read_csv('data/*.csv', parse_dates=['date_column'])
# 读取 Parquet
df = dd.read_parquet('data.parquet')

基本操作

py 复制代码
# 查看元数据(分区数、列名等)
print(df)
# 计算行数(触发计算)
count = df.shape[0].compute()
# 筛选
filtered = df[df['value'] > 100]
# 分组聚合
result = df.groupby('category')['amount'].mean().compute()
# 添加新列
df['new_col'] = df['col1'] + df['col2']
# 排序(谨慎使用,可能需要大量计算)
sorted_df = df.sort_values('date').compute()

分区操作

py 复制代码
# 获取分区数量
npartitions = df.npartitions
# 重新分区
df = df.repartition(npartitions=10)
# 对每个分区应用函数
def process_partition(part):
	part['processed'] = part['value'] * 2
	return part
df = df.map_partitions(process_partition)

合并与连接

py 复制代码
# 合并两个 DataFrame(类似 Pandas merge)
df1 = dd.read_csv('data1.csv')
df2 = dd.read_csv('data2.csv')
merged = dd.merge(df1, df2, on='id')
result = merged.compute()

写入数据

py 复制代码
# 写入 CSV(每个分区一个文件)
df.to_csv('output_*.csv', index=False)
# 写入 Parquet
df.to_parquet('output.parquet')
  • Dask Bag:用于处理半结构化或非结构化数据,如JSON、日志文件等。提供类似PySpark RDD的操作(如map、filter、groupby、fold等)。

创建Bag

py 复制代码
import dask.bag as db

# 从列表创建
b = db.from_sequence(['apple', 'banana', 'cherry'], npartitions=2)
# 从文本文件创建(每行一个元素)
b = db.read_text('log.txt', blocksize='64MB')

操作示例

py 复制代码
# 计算单词频率
word_counts = db.read_text('text.txt').map(str.split).flatten().frequencies().compute()
print(word_counts[:10])
# 过滤与映射
def is_valid(line):
	return 'ERROR' in line

error_lines = db.read_text('log.txt').filter(is_valid).map(lambda line: line.strip()).compute()

折叠与聚合

py 复制代码
# 求和(使用 fold)
total = b.fold(lambda acc, x: acc + len(x), initial=0).compute()
  • Dask Delayed:用于将任意Python函数转换为延迟对象,从而构建自定义的任务图。它非常适合需要并行执行多个独立任务或循环的场景。

基本用法

py 复制代码
from dask import delayed

@delayed
def add(a, b):
	return a + b

@delayed
def square(x):
	return x ** 2

# 构建计算图
x = add(1, 2)
y = square(x)
z = add(y, 10)
result = z.compute()

并行循环

py 复制代码
import time

@delayed
def slow_square(x):
	time.sleep(1)
	return x ** 2

values = range(10)
lazy_results = [slow_square(v) for v in values]

# 并行计算(多线程)
results = dask.compute(*lazy_results, scheduler='threads')
print(results)

依赖关系自动管理

py 复制代码
@delayed
def load_data(filename):
	return [1, 2, 3]

@delayed
def process(data):
	return sum(data)

files = ['file1.csv', 'file2.csv', 'file3.csv']
loaded = [load_data(f) for f in files]
processed = [process(data) for data in loaded]

# 等待所有处理完成
results = dask.compute(*processed)
print(results)

分布式部署

当需要多机并行时,可使用Dask的分布式调度器。首先启动调度器和工作进程,然后在代码中连接。

py 复制代码
from dask.distributed import Client

# 连接本地集群,启动单机集群
client = Client() # 自动启动本地调度器和工作进程
# 或连接外部集群,如dask-scheduler启动
# client = Client('tcp://scheduler-address:8786')

使用分布式调度器时,所有Dask集合的.compute()会自动使用该客户端,也可显式传递 scheduler='distributed'。分布式调度器提供可视化仪表盘,默认地址http://localhost:8787/status,可监控任务执行。

py 复制代码
# 使用客户端提交任务
x = da.random.random((10000, 10000), chunks=(1000, 1000))
mean = x.mean()
result = client.compute(mean) # 异步提交
print(result.result())

Dask ML

提供一些与scikit-learn集成的并行机器学习算法,以及用于超参数调优的工具。允许在大型数据集上训练模型,使用分布式计算。

py 复制代码
from dask_ml.linear_model import LogisticRegression
from dask_ml.datasets import make_classification
from dask_ml.model_selection import train_test_split

# 生成大型分类数据集
X, y = make_classification(n_samples=1000000, n_features=20, chunks=100000)

# 分割训练集和测试集
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)

# 训练模型
model = LogisticRegression()
model.fit(X_train, y_train)

# 预测
y_pred = model.predict(X_test).compute()

还支持使用joblib并行化scikit-learn模型,例如在分布式集群上进行超参数搜索:

py 复制代码
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import GridSearchCV
from joblib import parallel_backend

with parallel_backend('dask'):
	clf = RandomForestClassifier()
	param_grid = {'n_estimators': [10, 50, 100]}
	gs = GridSearchCV(clf, param_grid)
	gs.fit(X_train, y_train)

最佳实践与注意事项

最佳实践:

  • 避免过早计算:采用延迟执行,只有调用.compute().persist()时才会实际执行计算。尽量在完成所有操作后一次性计算,避免多次调用.compute()导致重复计算。
  • 使用持久化:如果某个中间结果会被多次使用,可使用.persist()将其保存在内存中(或磁盘上),避免重复计算。
py 复制代码
df = dd.read_csv('data.csv')
df_filtered = df[df['value'] > 0]
df_filtered = df_filtered.persist() # 保留在集群内存中

mean = df_filtered['col'].mean().compute()
max = df_filtered['col'].max().compute()
  • 调整分区/块大小:合理设置分区或块大小对于性能至关重要。通常分区大小应控制在 100MB 左右,块大小应使每个块的计算时间在 100ms 到数秒之间。
  • 监控任务:使用分布式调度器的仪表盘(http://localhost:8787/status)可以查看任务执行情况、内存使用、任务图等,帮助调试和优化。
  • 处理内存不足:如果数据集过大,确保分块大小适合内存,并考虑使用 spill-to-disk 或适当减少并行度。
  • 了解任务图:通过visualize()方法查看任务图,有助于理解计算过程。
py 复制代码
x = da.random.random((1000, 1000), chunks=(500, 500))
y = x.mean()
y.visualize(filename='graph.pdf')

Modin

开源(GitHub,10.4K Star,674 Fork)并行大数据集处理库,可弥补Pandas在大数据处理上的不足,能将代码速度提高4倍左右。import modin.pandas as pd后,现有Pandas代码无需修改即可获得多核加速。官方文档

核心技术原理:

  • 数据分区与并行执行:将DataFrame分割成分区,在Dask或Ray后端上并行执行操作;
  • API兼容性:致力于实现与Pandas API的高度兼容。

特点:

  • 使用DataFrame作为基本数据类型
  • 与Pandas高度兼容,语法相似,几乎不需要额外学习
  • 能处理1MB到1TB+的数据
  • 使用者不需要知道系统有多少内核,也不需要指定如何分配数据

核心价值:为希望无缝加速现有Pandas工作流的用户提供最简单的迁移路径。

有很多库可实现对Pandas的加速,如Dask、Vaex、Ray、CuDF等:

  • Dask:既可作为Modin的后端引擎,也能单独并行处理DataFrame,提高数据处理速度;但对Pandas兼容性不如Modin;
  • Vaex:核心在于惰性加载,类似Spark,但有独立的一套语法,和Pandas差异很大;
  • RAPIDS:加速效果非常好,但需要GPU加持。

实战

Modin使用Ray或Dask作为后端,安装pip install modin[dask]

示例:

py 复制代码
import pandas as pd
import time

s = time.time()
df = pd.read_csv("test.csv")
e = time.time()
print("Pandas读取时间 = {}".format(e-s))

import modin.pandas as pd

s = time.time()
df = pd.read_csv("test.csv")
e = time.time()
print("Modin读取时间 = {}".format(e-s))

Joblib

专注于轻量级流水线和并行计算的开源(GitHub,4.4K Star,462 Fork)库,常用于在数据科学和ML领域,官方文档

主要解决两类问题:

  • 让Python代码能轻松利用多核CPU并行运行
  • 通过磁盘缓存避免重复计算

核心组件

  • Parallel和delayed:用于并行计算
  • Memory:用于缓存计算结果
  • dump和load:用于高效持久化Python对象

实战

基于pip安装:pip install joblib

示例:

py 复制代码
from joblib import Parallel, delayed
import time

def square(x):
	return x ** 2

# 串行计算,观察耗时
numbers = range(1000000)
start = time.time()
results_serial = [square(n) for n in numbers]
print(f"串行计算耗时: {time.time() - start:.2f} 秒")

# 并行计算,使用所有CPU核心
start = time.time()
# Parallel 创建并行执行器,delayed 包装函数及其参数
results_parallel = Parallel(n_jobs=-1)(delayed(square)(n) for n in numbers)
print(f"并行计算耗时: {time.time() - start:.2f} 秒")

def power(base, exponent):
	return base ** exponent

# 生成参数组合:计算 2^1, 2^2, 2^3, ..., 2^10
bases = [2] * 10
exponents = range(1, 11)

# 并行计算
results = Parallel(n_jobs=-1)(
	delayed(power)(base, exp) for base, exp in zip(bases, exponents)
)
print(f"2的1到10次方计算结果: {results}")

解读:n_jobs=-1表示使用机器上所有的 CPU 核心,也可指定具体的数字,如n_jobs=4

支持多种后端。对于CPU密集型任务(如数学计算),推荐使用默认loky或显式指定 multiprocessing,它们会创建独立的进程来绕过Python的GIL限制。对于I/O密集型任务(如网络请求、文件读写),可使用threading

py 复制代码
import requests

def fetch_url(url):
	"""模拟一个网络请求任务"""
	time.sleep(0.5)
	return f"Fetched {url}"

urls = [f"http://example.com/{i}" for i in range(10)]
# 对于I/O密集型任务,线程(threading)可能更高效,因为它开销更小
results = Parallel(n_jobs=5, backend='threading')(
	delayed(fetch_url)(url) for url in urls
)
print(f"抓取{len(results)} 个URL")

对于参数相同、需要反复调用的昂贵函数,Memory类可将结果缓存到磁盘。

py 复制代码
from joblib import Memory
import time

cache_dir = './joblib_cache'
memory = Memory(cache_dir, verbose=0)

@memory.cache
def expensive_computation(a, b):
	time.sleep(3)
	return a * b

# 第一次调用:会计算并缓存结果
start = time.time()
result1 = expensive_computation(10, 20)
print(f"第一次调用结果: {result1}, 耗时: {time.time() - start:.2f}秒")

# 第二次调用,参数相同:直接从缓存加载,不执行函数体
start = time.time()
result2 = expensive_computation(10, 20)
print(f"第二次调用结果: {result2}, 耗时: {time.time() - start:.2f}秒")

# 第三次调用,参数改变:需重新计算
start = time.time()
result3 = expensive_computation(5, 5)
print(f"第三次调用结果: {result3}, 耗时: {time.time() - start:.2f}秒")

在ML中,训练好模型后,通常需要保存下来以便后续直接用于预测。相比于Python内置的picklejoblib.dumpjoblib.load在处理包含大型NumPy数组的模型(如scikit-learn模型)时,效率更高。

py 复制代码
from sklearn import svm
from sklearn import datasets
import joblib

# 1. 训练一个简单的模型
iris = datasets.load_iris()
X, y = iris.data, iris.target
clf = svm.SVC()
clf.fit(X, y)
# 2. 保存模型到文件
model_filename = 'my_model.joblib'
joblib.dump(clf, model_filename)
# 对于特别大的模型,可以使用 compress 参数来压缩文件,以节省磁盘空间
joblib.dump(clf, 'compressed.joblib', compress=3) # 压缩级别0-9
print(f"模型已保存到 {model_filename}")
# 3. 从文件加载模型
loaded_model = joblib.load(model_filename)
# 4. 使用加载的模型进行预测
new_data = [[5.1, 3.5, 1.4, 0.2]] # 假设是山鸢尾
prediction = loaded_model.predict(new_data)
print(f"预测的类别索引是: {prediction[0]}, 对应花名: {iris.target_names[prediction[0]]}")

在循环中逐步添加任务,可先创建Parallel对象,然后用循环动态地添加任务:

py 复制代码
def process_data_chunk(chunk_id):
	return f"Processed chunk {chunk_id}"

# 创建并行执行器
with Parallel(n_jobs=2) as parallel:
	 # 可动态地添加任务
	 results = []
	 for i in range(5):
		results.append(parallel(delayed(process_data_chunk)(i)))

# 注意:上述用法不太常见,更常见的是直接生成一个生成器表达式传给 Parallel。
# 典型做法:
results = Parallel(n_jobs=2)(delayed(process_data_chunk)(i) for i in range(5))
print(results)

许多Scikit-learn算法的n_jobs参数底层就是由Joblib实现的。可结合Dask来将Joblib的任务分发到集群上。

py 复制代码
import joblib
from sklearn.datasets import load_digits
from sklearn.model_selection import GridSearchCV
from sklearn.svm import SVC

# 假设你想在Dask集群上运行网格搜索
with joblib.parallel_backend('dask'):
	# GridSearchCV代码
	search.fit(X, y)

在Windows系统上进行并行计算时,务必在脚本的最外层加入if __name__ == '__main__':保护,以防止无限递归创建进程。Linux和macOS上通常不是必须的,但加上是良好习惯。