dau scheduler
Split row-parallel dataframe work across two compute engines and merge the partial results
Overview
When a machine has two engines that can both run the same query — two processors, a processor and an accelerator, a local worker and a remote one — the usual choice is all-or-nothing: run it here, or send it there. Running it in both places at once is often faster than either, but only for some queries, and only at the right split.
dau-scheduler makes that decision and makes the result correct. It answers
three questions:
- Should this be split, and by how much? A closed form over each engine's measured throughput gives the share of rows that finishes both arms at the same time. When the engines are comparable the predicted gain approaches 2x; when one is far faster than the other it shrinks toward 1x, and the split reports that figure rather than hiding it. A device job's fixed cost can rule a split out entirely, in which case the answer is host-only and says why.
- Can this query survive being split? Splitting rows means each engine computes a partial answer, and not every result can be reassembled from partials. Sums and counts add. A mean needs its sum and count carried separately rather than averaged. A top-k merges two k-lists. A grouped aggregation needs a key merge with the group straddling the boundary folded back together. An exact distinct count cannot be combined without the elements themselves. This library writes that taxonomy down and refuses any aggregate the caller has not declared mergeable under it.
- What if it can't? Refusal is by name and default-deny. A result the merge step does not know how to combine is refused, explicitly and with a reason, rather than silently mis-merged into a wrong answer.
Two commitments shape the rest of the design. Rows, not stages — splitting a
query by pipeline stage ships every intermediate across the link between the
engines, so the split is always over rows. And cost is measured, not
assumed — host cost is profiled per (plan, host) pair and re-measured when
the profile goes stale, because a speedup ratio is meaningless without naming
the machine it was measured on.
The library is engine-agnostic. Both arms are injected as callables, and nothing here knows what is on the other end.
Install
Python 3.12 or newer, and pydantic; nothing else.
pip install git+https://github.com/dau-dev/dau-scheduler
(The dau-scheduler name on PyPI is a placeholder until the first release.)
Usage
An arm is a callable. It is handed the query, the rows it owns, and the broadcast side of a join if there is one, and it returns the partial result an unsplit run would have produced for those rows. Both arms below are the same plain-Python function, because the executor never learns what is on either end of the seam.
from dau_scheduler import ADDITIVE, MergeRule, execute_split, merge_totals, plan_split
query = {"filter": lambda row: row["day"] < 30}
rows = [{"revenue": (seed * 31) % 1000, "day": seed % 60} for seed in range(100_000)]
def arm(query, rows, build):
kept = [row for row in rows.values() if query["filter"](row)]
return {"revenue": sum(row["revenue"] for row in kept), "len": len(kept)}
# how much each engine gets, from one whole-query estimate per engine
split = plan_split("collaborative", cpu_seconds=0.0276, device_seconds=0.0186)
split.device_share # 0.597...
split.predicted_speedup # 2.48...
# how the two partial answers become one
merge = MergeRule(
terminal="reductions",
combine=lambda partials: merge_totals(partials, ("revenue", "len")),
mergeable=ADDITIVE,
)
execution = execute_split(
query,
rows,
split,
merge,
device=arm,
host=arm,
aggregates=[("revenue", "sum"), ("len", "count")],
)
execution.result # the same object an unsplit run returns
execution.overlap_seconds # how long both arms were in flight together
aggregates is what makes the refusal possible. Ask for something that cannot
be reassembled and it is refused before either engine starts:
from dau_scheduler import SplitError
try:
execute_split(query, rows, split, merge, device=arm, host=arm, aggregates=[("average", "mean")])
except SplitError as refusal:
print(refusal)
# average: a mean is not row-partitionable; carry the sum and the count as
# separate outputs and divide after the merge
Rows an arm never reads
An engine running against data it already holds needs one number, not a list of
rows: a row count if it holds its own copy, an offset if it holds a
resident frame. RowRange is exactly that, and it costs two integers however
many rows it names, so a share sweep over six million rows allocates nothing
proportional to the row count. An arm that does need the values asks for them
and gets a typed refusal from a range rather than a table materialized behind
the caller's back.
from dau_scheduler import RowRange
def resident_arm(query, rows, build):
# an engine that holds the table: it reads the offset and the length,
# never a row
return {"revenue": 0, "len": len(rows)}
execute_split(query, RowRange(0, 6_000_000), split, merge, device=resident_arm, host=resident_arm)
Measured host cost
plan_split refuses to guess. The host figure can come from a measurement
instead: profile the first run — which has nothing to split onto anyway — and
price every later run from it.
from dau_scheduler import ProfileCache, plan_split, profile_from_spans
# (start_us, end_us, node name) per plan node, as the engine reported them
engine_timings = [(0, 11_200, "optimization"), (11_200, 12_900, "scan"), (12_900, 14_600, "filter")]
profile = profile_from_spans(
engine_timings,
rows=1_000,
plan_key="revenue-by-day",
is_fixed=lambda node: node.startswith("optimization"),
engine_version="1.42.1",
)
ProfileCache("profiles").put(profile)
if profile.is_stale_for(6_000_000, engine_version="1.42.1"):
pass # re-measure rather than extrapolate across orders of magnitude
plan_split("collaborative", device_seconds=0.0186, profile=profile, rows=6_000_000)
Plan-time cost is kept separate from per-row cost, because a query engine's optimizer does not get slower with more rows and folding it into a per-row rate over-estimates a large query by orders of magnitude.
What is here
| module | what it decides |
|---|---|
posture |
the share of rows each engine gets, and how that share is delivered |
merge |
which results survive a row split, and how the partials combine |
rows |
which rows an arm owns, by offset and optionally by value |
split |
running both arms at once and merging what comes back |
profile |
measured host cost, keyed by plan and machine, with staleness |
Metadata
Release files for dau-scheduler 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| dau_scheduler-0.1.0.tar.gz | 44.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| dau_scheduler-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 95.9 kB
Release files / dau_scheduler-0.1.0.tar.gz
| Download URL | dau_scheduler-0.1.0.tar.gz |
|---|---|
| Size | 44.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
0e7a82235e04d28fe7c27e60806ab15a3148288e865d4c8f569b43641d62f947
|
|
BLAKE2b-256 checksum How to use checksums |
606e1b3bae6e15c8198c8f4633b6a506b6ea9cdfd0d81a41e4ae7e9b90472050
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.14
|
Release files / dau_scheduler-0.1.0-py3-none-any.whl
| Download URL | dau_scheduler-0.1.0-py3-none-any.whl |
|---|---|
| Size | 51.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d539272767b154a49b9f307876931c2f055bae689b092e2b4d3f3fa856668082
|
|
BLAKE2b-256 checksum How to use checksums |
40b7a487e40a0dac26dc11c0fae9efd0c4b6d16a72b77176f173a61f9cd7c621
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.14
|