Daft 是面向 AI 与多模态工作负载的高性能数据处理引擎。它提供 Python DataFrame 和 SQL 接口,让图片、音频、视频、文本、Embedding 与普通结构化数据进入同一条声明式流水线;底层由 Rust 执行引擎负责向量化、流式执行和内存控制,并能从单机扩展到分布式集群。
本文希望帮助你在 30 分钟内理解 Daft 解决什么问题、跑通一个本地任务,并建立从 DataFrame 到 Swordfish、Flotilla 和 Ray 的完整心智模型。尤其会说清楚一个容易混淆的问题:daft.set_runner_ray() 确实表示使用 Ray 运行,但 Daft 并没有把查询规划和任务拆分全部交给 Ray。
1. 什么是 Daft
1.1 一句话定位
Daft 是一个 Python 原生、Rust 驱动、面向多模态与大规模数据的数据引擎。它把"读取数据---筛选---下载媒体---解析---调用模型---关联聚合---写回存储"表达为一张惰性 DataFrame 计算图,再由优化器和执行引擎决定如何分批、并行和分布式运行。
Daft 的价值不只是提供另一套 DataFrame API。传统表格引擎主要处理数值和字符串,而 AI 数据流水线还包含网络下载、图片解码、文档解析、模型初始化、GPU 推理和外部 API 调用等昂贵且资源异构的操作。Daft 可以把这些操作纳入查询计划,为它们安排合适的批大小、并发度和 CPU/GPU 资源,并尽可能在过滤、连接或聚合之后再执行昂贵计算。
1.2 常见使用场景
| 场景 | Daft 扮演的角色 | 典型流水线 |
|---|---|---|
| 多模态 ETL | 统一处理元数据与大对象 | 读取路径或 URL → 过滤 → 下载 → 解码/解析 → 写出 |
| 批量模型推理 | 负责分批、并发、资源声明与反压 | 预处理 → Embedding/分类/LLM → 后处理 → 持久化 |
| 数据集构建 | 编排清洗、去重、质量规则和样本生成 | 多源读取 → Join/Filter → UDF → 数据集 |
| 多模态数据湖 | 连接对象存储、Parquet、Lance 与 Catalog | 数据湖读取 → 计算 → 回写 |
| 训练数据加载 | 将计算结果流式交给训练框架 | 读取 → 解码/增强 → 批处理 → DataLoader |
1.3 什么时候适合使用
当任务同时包含结构化计算与多模态或 AI 算子、需要从本地平滑扩展到分布式,或者需要避免"每个阶段都落盘再交给下一个系统"时,Daft 很合适。它尤其适合 Read → Transform → Infer → Write 类型的离线流水线。
如果数据量很小、逻辑只是交互式表格分析,Pandas 或 Polars 往往更简单;如果已有成熟的 Spark SQL 数仓任务且没有多模态或 Python 模型计算,迁移收益可能有限;如果需要毫秒级在线请求服务,Daft 更适合作为离线数据处理层,而不是在线服务框架。
1.4 与常见框架的边界
| 框架 | 强项 | 与 Daft 的主要差异 |
|---|---|---|
| Pandas / Polars | 单机分析、生态成熟、上手快 | Daft 更强调多模态类型、AI 算子和分布式扩展 |
| Spark | 大规模结构化 ETL、SQL 与企业数据生态 | Daft 以 Python/Rust 为核心,更直接地承载 Python 模型、异构资源和流式多模态流水线 |
| Ray Data | 基于 Ray 的分布式数据处理 | Daft 提供自己的查询计划、优化器、类型和表达式系统,并可使用 Ray 作为分布式运行时 |
2. 十分钟跑通第一个任务
2.1 安装
基础体验只需安装 Daft;需要 Ray 分布式执行时,再安装 Ray 扩展。生产环境建议固定 Daft、Python 和 Ray 版本,避免客户端与集群环境不一致。
python -m pip install -U daft # 需要 Ray Runner 时安装 python -m pip install -U "daft[ray]"
2.2 第一个 DataFrame
import daft from daft
import col
df = daft.from_pydict({ "name": ["alice", "bob", "cindy"], "score": [92, 55, 81], })
result = ( df.where(col("score") >= 60) .with_column("score_x2", col("score") * 2) .select("name", "score", "score_x2") )
# 前面的调用主要在构造计划;show() 才触发计算 result.show()
这里要先形成一个关键认识:where、with_column 和 select 通常不会立即遍历数据,它们是在构造 LogicalPlan。show()、collect()、to_arrow() 或写出方法才是物化边界。
2.3 最小多模态示例
import daft from daft
import col
df = daft.from_pydict({ "image_url": ["https://example.com/a.jpg"] })
images = ( df.with_column("bytes", col("image_url").url.download()) .with_column("image", col("bytes").image.decode()) .select("image_url", "image") )
images.show()
真实任务应先根据尺寸、类型、标签等轻量元数据过滤,再下载或解码大对象。这样既减少网络 I/O,也避免图片等大字段过早进入内存和数据交换过程。
2.4 自定义 Python 计算
import daft from daft
import col
@daft.func(return_dtype=daft.DataType.string())
def normalize_text(value: str) -> str:
return value.strip().lower()
df = daft.from_pydict({"text": [" Hello ", "DAFT"]})
df.select(normalize_text(col("text")).alias("normalized")).show()
逐行无状态逻辑可以使用 @daft.func;向量化计算可以使用 @daft.func.batch;模型加载或连接初始化成本较高、需要跨批次复用时可以使用 @daft.cls。旧资料中的 @daft.udf 属于旧接口,编写新代码时应以当前安装版本的文档为准。
2.5 切换到 Ray Runner
import daft
# 未指定地址时,连接或启动本地 Ray 实例
daft.set_runner_ray()
# 连接已有 Ray Client
# daft.set_runner_ray("ray://127.0.0.1:10001")
# DataFrame 代码通常不需要改写
daft.set_runner_ray() 的准确含义是:将 Daft 的执行后端切换为 Ray Runner。它不是"绕过 Daft 调度、直接把 DataFrame 扔给 Ray"。查询计划、优化、分区任务生成和任务依赖仍由 Daft 负责,Ray 提供集群资源、Actor 生命周期、对象存储和跨节点通信等基础能力。
3. 核心编程模型与内存数据结构
3.1 从用户对象到执行对象
DataFrame(惰性查询)
└─ LogicalPlan:Source / Project / Filter / Join / Aggregate ...
└─ Expression Tree:列引用 / 常量 / 内置函数 / Python UDF
└─ PhysicalPlan:具体算法、交换和执行边界
└─ Task / Operator Pipeline
└─ Partition → MicroPartition → Arrow 列式数据
DataFrame。 它不是一份已经完全驻留内存的表,而是 Schema 加一棵尚未执行的计划。链式调用返回新的 DataFrame,并在逻辑计划上追加节点。
Expression。 col("score") * 2 并不立即计算,它构造了一棵表达式树。Project 节点中可以包含列引用、字面量、函数调用和 UDF。优化器可以分析这些表达式,执行投影裁剪、常量折叠等改写。
Schema 与 DataType。 Schema 描述列名和类型。严格类型既能在规划阶段发现一部分错误,也能帮助引擎选择合适的向量化内核。除基础和嵌套类型外,Daft 还提供适合多模态处理的 Image、Tensor、Embedding 和 Python 等类型。
Series 与 Arrow。 Series 表示一列数据。内置类型优先采用 Arrow 兼容的列式缓冲区:数值连续存储,字符串通常由 offset 和 value buffer 表示,空值由 validity bitmap 表示。算子可以按列或批次处理数据,并通过 Arrow 生态交换数据,减少序列化和不必要的复制。Python 对象类型更灵活,但会失去一部分列式、零拷贝和向量化优势。
MicroPartition。 它是执行边界上的小型分区对象,承载 Arrow 兼容的数据批及相关元信息。单机迭代时可以直接得到 MicroPartition;使用 Ray Runner 时,分区结果也可能以 Ray ObjectRef 返回。
3.2 Partition 与 Batch 不要混淆
| 概念 | 解决的问题 | 常用控制方式 |
|---|---|---|
| Partition | 把有限的数据工作单元分布到不同 Worker,影响分布式并行度与 Shuffle | repartition(...) / into_partitions(...) |
| Batch | 控制单个算子一次处理多少行,影响峰值内存、吞吐和 GPU 批推理效率 | into_batches(...) 或 UDF batch 配置 |
Partition 主要是分布式执行的任务划分单位,Batch 则在单机和分布式模式下都存在。文件型输入通常形成与输入文件相关的分区;Join、GroupBy、Sort 等全局算子可能触发重新分区。图片解码、Explode、下载或返回大对象的 UDF 属于"数据膨胀"操作,必要时应在它们之前减小 batch。
3.3 内存中的典型数据流
对象存储或本地文件 → Source 分批读取列式数据 → Filter / Project 消费并产生下一批 → 下载 / 解码 / UDF 使用各自的并发与批大小 → Join / Aggregate / Sort 等阻塞或交换节点 → Sink 流式写出,或 collect 到客户端
流式 Operator 不需要等全部上游完成,就可以继续传递批次;Aggregate、Sort 等阻塞 Operator 则需要积累更多输入。结果缓冲区和异步通道承担反压:下游消费变慢时,上游不会无限制地产生数据。
4. 单机与分布式调度原理
4.1 一条查询的生命周期
Python DataFrame / SQL
│
▼
LogicalPlan + Expression Trees
│ 过滤下推、投影裁剪、常量折叠、Join 优化等
▼
Optimized LogicalPlan
│ 选择算法并生成 PhysicalPlan
▼
执行
├─ Native Runner:Swordfish 在单机进程内执行
└─ Ray Runner:Flotilla 分发分区任务
└─ 每个 Worker 内由 Swordfish 执行本地 Pipeline
Planning 和 Optimization 不因 Runner 不同而消失。DataFrame 或 SQL 先转换成逻辑计划,优化器完成规则或代价相关的改写,再生成物理计划。Native Runner 与 Ray Runner 的主要差异出现在物理执行阶段:前者在单机进程内调度算子,后者还需要把分区任务放到不同机器上。
4.2 Swordfish:单机流式执行
Swordfish 是 Daft Native Runner 的代号,也是每个分布式 Worker 内部使用的本地执行引擎。单机模式下,整条查询运行在一个进程中,不存在跨节点 Shuffle。
物理计划会被组织为 Operator Pipeline。Source 读取数据,Filter、Project、UDF 等中间 Operator 处理数据,Sink 返回或写出结果。流式算子可以在收到一个批次后立即开始处理并把结果交给下游,不必等待整份输入完全物化;全局 Aggregate、Sort 等阻塞算子则需要积累输入,形成同步或物化边界。
可以把单机调度理解为两层:
-
算子层调度。 Daft 根据物理计划组织 Source、转换算子和 Sink,控制哪些算子可以流水线并发运行。
-
批次层流动。 数据以较小的 batch 在算子之间传递。批次大小影响吞吐与内存,并发度影响同一时间有多少工作在运行。
当下游速度变慢时,有限缓冲区会产生背压,使上游暂停继续生产,避免中间结果无限堆积。网络下载、CPU 解码和模型推理的资源特征不同,因此可以拥有不同的 batch 和并发设置。
Local PhysicalPlan
→ 构造 Operator Pipeline
→ Source 按批次读取数据
→ Filter / Project / UDF 流式消费和生产
→ 缓冲区控制背压
→ Streaming Sink 提前输出
或 Blocking Sink 汇总后输出
4.3 Flotilla:Daft 自己的分布式调度层
Ray Runner 也称为 Flotilla。它是 Daft 构建在 Ray 之上的分布式执行引擎,而不是 Ray Data 的别名。
在 Ray 集群中,Flotilla 调度器运行在 Head 节点。它根据分布式物理计划和 DataFrame Partition 生成任务,再依据数据本地性与 Worker 负载分配任务。每个 Worker 节点上,Daft 启动一个 Ray Actor,这个 Actor 内部承载一个 Swordfish 实例。换句话说,Flotilla 负责跨机器安排任务,Swordfish 负责每台机器内部的算子流水线执行。
Distributed PhysicalPlan
→ Daft 按 Partition 生成任务并维护依赖
→ Flotilla 根据数据位置与负载选择 Worker
→ 通过 Ray Actor 把任务提交到目标 Worker
→ Worker 内的 Swordfish 执行本地 Pipeline
→ 输出进入 Ray Object Store 或交给下游任务
→ Flotilla 根据完成状态继续释放下游任务
Join、GroupBy、Sort、Repartition 等全局操作可能需要在 Partition 之间移动数据,也就是 Shuffle。Flotilla 决定任务和数据依赖关系;Ray 的 Object Store、网络和 Actor 通信负责承载中间数据与远程调用。
4.4 Daft、Flotilla、Swordfish 与 Ray 的职责边界
| 问题 | Daft / Flotilla / Swordfish | Ray |
|---|---|---|
| 查询如何执行 | Daft 负责 LogicalPlan、PhysicalPlan、算子选择和优化 | 不理解 Daft 查询语义 |
| 分布式任务如何产生 | Flotilla 按计划与 Partition 产生任务并维护依赖 | 不负责拆分 Daft 查询 |
| 任务放到哪里 | Flotilla 根据数据本地性和 Worker 负载选择目标 | 提供节点资源约束并运行远程调用 |
| 单个任务内部如何执行 | Worker 内的 Swordfish 执行 Operator Pipeline | 承载 Daft 的 Ray Actor 进程 |
| 中间结果如何流转 | Daft 决定 Partition、Shuffle 和消费关系 | 提供 Object Store、网络和 Actor 通信 |
一句话概括:Swordfish 是单机流式执行引擎;Flotilla 是 Daft 自己的分布式调度与执行层;Ray 是 Flotilla 使用的集群运行时。
因此,下面两句话需要同时成立:
-
daft.set_runner_ray()会让 Daft 使用 Ray 进行分布式运行。 -
查询规划、任务拆分和 Daft 语义层面的调度仍由 Daft 的 Flotilla 负责,Ray 提供底层集群能力。
4.5 为什么惰性与流式对多模态特别重要
一张图片的 URL 只有几十到几百字节,解码后的像素却可能放大几个数量级。如果在 Join 或 Filter 之前下载并解码所有图片,网络、内存和 Shuffle 都会迅速放大。惰性计划允许优化器先处理轻量元数据,流式执行允许每一批在完成后立即进入下游;二者结合,可以显著降低中间数据驻留量。
5. 实践模式与性能调优
5.1 推荐流水线
-
读取轻量元数据和必要列。
-
尽早执行 Filter、列裁剪、去重和必要的 Join。
-
真正需要大对象时再下载、解码或读取内容。
-
优先使用内置向量化算子,缺失能力时再使用
@daft.func、@daft.func.batch或@daft.cls。 -
为昂贵 UDF 声明合适的 CPU/GPU、并发与批大小。
-
大结果优先直接写入持久化存储,避免无必要的
collect()。
5.2 UDF 选型
| 需求 | 建议接口 | 原因 |
|---|---|---|
| 简单逐行逻辑 | @daft.func |
最直接,可以作为 Expression 组合 |
| NumPy、PyArrow 或模型批推理 | @daft.func.batch |
减少 Python 调用和模型启动开销 |
| 模型或连接需初始化一次并重复使用 | @daft.cls |
保留状态并摊薄初始化成本 |
| 异步外部 API | async @daft.func |
利用并发隐藏网络等待,同时应设置限流与重试 |
5.3 常见内存与性能问题
图片解码后 OOM。 先过滤尺寸和类型,解码前使用更小的 batch,处理完成后尽快只保留下游需要的列。
Driver 内存过高。 避免对大结果调用 collect() 或转换为 Python list;优先使用写出方法或分区迭代,并控制结果缓冲区。
GPU 利用率低。 检查数据下载和 CPU 预处理是否成为瓶颈,使用批 UDF 和状态类复用模型,适度提高并发和 batch,不要只增加 GPU 数量。
Shuffle OOM 或调度变慢。 检查是否携带了图片字节、Tensor 等大列;先裁剪大字段,再评估 Partition 数量和 Shuffle 配置。
任务重试产生重复外部副作用。 LLM/API 调用、数据库 append 等 UDF 或 Sink 不一定具备 exactly-once 语义。应为请求和输出设计幂等键、去重或 upsert,不要假设框架能撤销已经发送到外部系统的请求。
6. 从入门到进阶的学习路线
| 阶段 | 目标 | 建议练习 |
|---|---|---|
| 第 1 阶段 | 理解惰性 DataFrame | 读取 Parquet/Pydict,完成 Filter、Select、WithColumn 和写出 |
| 第 2 阶段 | 掌握多模态表达式 | URL 下载、图片解码/Resize、Embedding 计算 |
| 第 3 阶段 | 掌握自定义计算 | 分别实现逐行、批量和有状态模型 UDF |
| 第 4 阶段 | 理解执行与内存 | 比较不同 batch,观察执行计划和峰值内存 |
| 第 5 阶段 | 分布式运行 | 切换 Ray Runner,调整 Partition,执行 Join 或 GroupBy |
完成这些练习后,你应该能够回答以下问题:
-
为什么调用
with_column后任务还没有真正执行? -
DataFrame、Expression、MicroPartition、Partition 与 Batch 分别是什么?
-
Native Runner 与 Ray Runner 的调度边界在哪里?
-
Swordfish、Flotilla 与 Ray 分别负责什么?
-
什么时候应该选择逐行函数、批函数或状态类?
-
为什么图片解码和模型推理通常应放在过滤与列裁剪之后?