Daft is a computing engine designed for DATA + AI multimodal data processing and analysis scenarios. It supports both single-node and distributed operation modes, with its core implemented in Rust, and provides two interaction methods: SQL and Python DataFrame.
Considering the characteristics of multimodal data processing in DATA + AI scenarios, Daft adopts a Pipeline execution model for its overall architecture design. Applications written by users using Daft DataFrame or SQL API are first represented internally in Daft as an abstract syntax tree (AST: Abstract Syntax Tree). After a series of optimization and transformation rules are applied, this abstract syntax tree is converted into a Pipeline tree that can be executed by the underlying execution engine, and is ultimately submitted to either the single-node or distributed execution engine for execution.
As shown in the diagram above, Daft's overall architecture can be divided into three layers: the API layer, the Plan layer, and the Execution layer:
Let us take the following DataFrame as an example and explain, from the perspective of the overall execution pipeline, how Daft starts from this DataFrame and ultimately generates a PhysicalPlan through optimization and transformation.
df = daft.read_parquet("/opt/workspace/daft/data") df = ( df .select("id", "name") .sort(by="id", desc=True) .filter(col("id") < 127) .offset(100) .limit(10) ) df.to_pydict()
This is a relatively simple DataFrame example, used to read a parquet dataset and select 10 records from it as required. Internally, Daft represents the DataFrame by converting it into a LogicalPlan according to the construction order:
* Limit: 10 | * Offset: 100 | * Filter: col(id) < lit(127) | * Sort: Sort by = (col(id), descending, nulls first) | * Project: col(id), col(name) | * GlobScanOperator | Glob paths = [/opt/workspace/daft/data] | Coerce int96 timestamp unit = Nanoseconds | IO config = ... | Use multithreading = true | File schema = id#Int64, name#Utf8, email#Utf8 | Partitioning keys = [] | Output schema = id#Int64, name#Utf8, email#Utf8
Considering that the DataFrame or SQL written by the user may not be optimal, to achieve maximum execution efficiency, Daft's internal LogicalPlan optimizer traverses and applies a series of optimization rules to the above LogicalPlan, ultimately producing the following optimized LogicalPlan:
* TopN: Sort by = (col(id), descending, nulls first), Num Rows = 10, Offset = 100 | Stats = { Approx num rows = 110, Approx size bytes = 3.01 KiB, Accumulated | selectivity = 0.01 } | * Num Scan Tasks = 112 | File schema = id#Int64, name#Utf8, email#Utf8 | Partitioning keys = [] | Projection pushdown = [id, name] | Filter pushdown = col(id) < lit(127) | Output schema = id#Int64, name#Utf8 | Stats = { Approx num rows = 3,279, Approx size bytes = 92.09 KiB, Accumulated | selectivity = 0.30 }
The optimized LogicalPlan is much more streamlined compared to the original, resulting in improved execution efficiency. From the optimized LogicalPlan above, we can also infer that Daft has roughly applied the following optimization rules:
.filter(col("id") < 127) and .select("id", "name") are pushed down to the data scan stage, greatly reducing the amount of data scanned..offset(100).limit(10) are implemented by rewriting it as global_limit(10, 100) and local_limit(0, 110) to achieve offset...limit..sort(by="id", desc=True).limit(10, 100) is rewritten as a TopN operator to implement sorting and truncation semantics.The optimized LogicalPlan is further converted into a PhysicalPlan, and additional optimization rules specific to the PhysicalPlan are applied. For example, the optimized LogicalPlan above would be converted into the following PhysicalPlan:
* TopN: Sort by = (col(0: id), descending, nulls first), Num Rows = 10, Offset | = 100 | Stats = { Approx num rows = 110, Approx size bytes = 3.01 KiB, Accumulated | selectivity = 0.01 } | * ScanTaskSource: | Num Scan Tasks = 112 | Estimated Scan Bytes = 180568 | Pushdowns: {projection: [id, name], filter: col(id) < lit(127)} | Schema: {id#Int64, name#Utf8, email#Utf8} | Scan Tasks: [ ... ] | Stats = { Approx num rows = 3,279, Approx size bytes = 92.09 KiB, Accumulated | selectivity = 0.30 }
Currently, mainstream data computation engines in the DATA + AI domain (such as Pandas and Polars) by default only provide single-node execution mode. The distributed version of Polars is only available in the commercial edition, while Dask, although considered a distributed version of Pandas, has relatively low execution efficiency. Daft provides both single-node and distributed execution modes in its open-source version, and allows switching freely between these two modes.
By default, Daft executes user-input SQL or constructed DataFrames in single-node mode. Its single-node execution engine, Swordfish, is implemented in Rust and is designed based on a pipeline computation model, leveraging multithreading to enable parallel data processing.
As shown in the figure above, the PhysicalPlan on the left is parsed by the Swordfish engine into a Pipeline tree. The engine traverses the tree in a depth-first manner and sequentially starts and runs each node. Swordfish PipelineNode includes four types of nodes:
Node type | Definition | Concurrency | Operator examples |
|---|---|---|---|
SourceNode | Data source node | Depends on the number of data shards, default limit is 8 | ScanTaskSource |
IntermediateNode | Intermediate processing node | Depends on the specific operator type, default is the number of CPU cores on the node | Project、Filter、UDF、JoinProbe |
BlockingSinkNode | Blocking data receiving node | Depends on the specific operator type, default is the number of CPU cores on the node | Aggregate、JoinBuild、TopN、Sort |
StreamingSinkNode | Streaming data receiving node | Depends on the specific operator type, default is the number of CPU cores on the node | Limit、MonotonicallyIncreasingId |
Data transmission between nodes relies on Kanal Channel.
Daft supports not only running in standalone mode, but also running in distributed mode based on Ray. Users only need to simply configure the connection information of the Ray cluster to switch between standalone and distributed modes, without the need to adapt the business logic layer.
Flotilla v1.5 is the latest distributed execution engine of Daft. Its design goal is to provide better scalability, performance, and observability for multimodal data processing, supporting:
In terms of design philosophy, Flotilla v1.5 leverages Ray to distribute multiple Swordfish standalone execution engines, and data shuffling between nodes is achieved via the built-in Flight Shuffle Server.
The implementation of the Flotilla distributed execution engine includes the following core components:
Each Ray Worker node runs a resident RaySwordfishActor, which is responsible for receiving the LocalPhysicalPlan contained within a SwordfishTask that can be executed on a standalone Swordfish engine. When an execution request is received, the RaySwordfishActor submits the LocalPhysicalPlan to the local standalone Swordfish engine for execution. For details on the execution process, refer to Section 1 above.
The Flotilla distributed execution engine has now evolved to version v1.5, which offers significant performance improvements over v1.0. However, its feature completeness is still limited, so when unsupported operations are encountered, it will fall back to v1.0 to ensure that the job can run properly. Unlike v1.5, which uses the Actor model in conjunction with the standalone Swordfish execution engine to run Pipeline operators, v1.0 splits each Stage into multiple Ray Remote Tasks and submits them in batches to the Ray cluster for execution.
As shown in the left part of the figure above, the core execution process of PhysicalPlan in v1.0 is as follows:
The main reason for redesigning the Flotilla distributed execution engine is that v1.0 has the following two core issues compared to v1.5: