本文汇总介绍几个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.array、dask.dataframe、dask.bag和dask.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内置的pickle,joblib.dump和joblib.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上通常不是必须的,但加上是良好习惯。