Migration guide: daisy 1.x → daisy v2#
daisy v2 is a Rust rewrite. The Python surface is mostly compatible with daisy 1.x; this document describes the deltas that matter to existing users.
tl;dr#
Existing 1.x code should keep working.
pip install -U daisyupgrades you to v2 and the top-levelimport daisynamespace is the v1.x backwards-compatibility surface (daisy.v1_compat). Code that usesTask(num_workers=…),daisy.SerialServer, etc. continues to run; you’ll just seeDeprecationWarnings pointing at the v2 names.For new code, prefer the v2 interface. Use
import daisy.v2 as daisyto get the cleaner v2 API with no compat aliases (and no deprecation warnings).It is a lot faster. 17 to 330 times the block throughput of 1.x at every worker count, a scheduler that drains 100,000 blocks in a sixth of a second, and resumes that read one byte per block. See Speed.
Some 1.x APIs are gone. Array I/O (
daisy.persistence,daisy.Array,daisy.open_ds,daisy.prepare_ds) is no longer re-exported. See Removed APIs below.
Importing the v2 interface#
Three import options:
Import |
Surface |
|---|---|
|
v1.x compat (default — same as |
|
v1.x compat (explicit) |
|
v2 native — no compat aliases |
The v2 surface drops Task(num_workers=…), SerialServer, and a handful of other 1.x-only names. In return you get cleaner kwargs (max_workers), no deprecation warnings, and the same types as 1.x: daisy.Roi and daisy.Coordinate are funlib.geometry’s classes, and daisy.Block is a picklable Python class with funlib ROIs, on both surfaces.
If you’re starting a fresh project, use import daisy.v2 as daisy. If you’re maintaining a daisy 1.x codebase and want gradual migration, leave bare import daisy and address the deprecation warnings file by file.
API mapping (1.x → v2)#
1.x |
v2 |
|---|---|
|
|
|
|
|
unchanged (or |
|
unchanged signature; bool semantics tightened (see below) |
|
gone — use |
run_blockwise return value#
In daisy 1.x, run_blockwise returned True whenever the scheduler finished accounting for every block — including blocks that permanently failed. The 1.x docstring claimed “all blocks successfully run” but the implementation was actually “all blocks reached a terminal verdict.” daisy v2 tightens this: run_blockwise returns True only if every block of every task completed successfully (or was skipped because a previous run already marked it done). Permanently failed or orphaned blocks now make the bool return False.
If you have 1.x scripts that branched on if run_blockwise(...): to detect “did the run finish accounting” rather than “did everything succeed,” you’ll want to switch to inspecting the TaskState counters returned from Server.run_blockwise(...) directly.
New in v2#
A few things v2 added that have no 1.x equivalent:
Block tracking as a built-in (
Task(tracking_path=…)or viaset_tracking_basedir(…);done_marker_path/set_done_marker_basedirare deprecated aliases). A per-task Zarr v3 group recording which blocks completed (used to skip already-done work on resume) and how many times each failed.Task.reset()clears it for an explicit re-run. Tracking is opt-in: without an explicit path or a basedir set, nothing is tracked or skipped. The recommended pattern is oneset_tracking_basedir("<stable absolute path>")call at pipeline start. Note that tracking is keyed by task_id + block geometry, not by your code — rerunning changed code against existing tracking resumes silently, so use distinct task_ids per pipeline version orTask.reset()after code changes. It is also bound to the exact task layout: changing any ROI (including growingtotal_roifor a dataset that acquired more data) invalidates it entirely; in-place migration for grown volumes is a planned enhancement (seedocs/source/design/DONE_MARKERS.md).Skip masks (
Task(skip_mask=…)): a uint8 or bool array with one cell per block (Task.block_grid_shape; cell(0, 0, …)is the first block), nonzero = skip. Masked blocks count as skipped and release their dependents, but are not recorded as done, so the mask is a per-run input: a later run with a different mask (or none) runs them. Composes with tracking: done blocks stay done, masked blocks stay undone. Use it for blocks you know carry no work (far from a predicted surface, outside an acquired region) instead of acheck_function, which runs a Python callable per block and cannot be seeded ahead of a run.Per-block resource tracking (
Task(resource_tracking=True), needs a tracking path). Each block reports its wall time, CPU time, peak RSS and IO bytes, measured inside whoever ran it — so thread, subprocess and external cluster workers all report the same numbers. Values land in the tracking group as sibling arrays indexed the same way asdone, readable with plainzarr.open_group(...), and the run prints an agglomerated Resource Utilization summary. daisy measures the whole block body automatically;with daisy.profile_block(block):narrows the scope if you want to exclude your own setup. Programmatic access:task.report()(aTaskReportwithmean ± stdper metric, read from the persistedruns.jsonl). 1.x had no equivalent.Per-worker time tracking (always on). The scheduler stamps every worker’s lifecycle — spawn → first acquire (startup, including cluster queue time), the gap before each block (idle), each round trip (compute), and shutdown reply → exit (teardown) — and the run report carries them per task (
task.report().workers, distributions over workers and blocks) and per worker (.workers.by_id[i], or.worker(i)). Printed in every summary along with the scheduler’s per-block round trip (neither needs a tracking store);resource_tracking=Trueadds the in-worker measurements to the panel and persistsworker_id/sent_at/returned_atper block in the tracking group and the report is appended toruns.jsonl(in the tracking store, else next to the worker logs) sotask.report()reads it back in any later process;task.report().block(block)reads one block’s record back. 1.x had no equivalent.block_trackingflag onrun_blockwiseto disable done-marker tracking entirely for a run.JsonProgressObserver— line-delimited JSON progress events, useful for piping into log aggregators or external dashboards.Per-task resource budgets (
requiresper task + globalresourcesdict) — coordinate worker concurrency across tasks competing for shared resources (CPU, GPU, etc.).Worker restart cap with proper abandonment + transitive downstream orphan propagation.
Run statistics (per-worker, per-task, process-wide) surfaced after each run.
How workers run#
Behavior change: blocks always time out.
Task(timeout=...)now defaults to 600 seconds (10 minutes) and cannot be disabled —timeout=Nonemeans the default, and non-positive values raiseValueError. In 1.x the parameter existed but was never enforced (a wedged block hung the run forever). In v2 a block that exceeds its deadline is reclaimed and retried, its worker process is killed, and when a task fails on timeouts both the run summary and the abandonment error say so and point atTask(timeout=...). Raise the value explicitly for genuinely slow blocks.
One worker model: a dedicated process each#
On the distributed run paths every worker is its own OS process, as in
daisy 1.x. Whatever your process_function is, daisy serializes it (with
cloudpickle when installed, which gives 1.x’s lambda/closure support back)
and each worker slot runs python -m daisy._subprocess_worker:
your function |
what the worker process does |
|---|---|
|
runs the standard |
|
calls |
Consequences worth knowing:
CPU-bound python scales with
max_workers. There is no shared GIL.Task(timeout=…)truly preempts. A stuck block’s process is killed.max_workers=Nmeans N python interpreters, each importing your function’s modules. Budget memory accordingly.Your function must be picklable. Objects that cannot cross a process boundary (threading locks/conditions, open write handles, live database connections — held directly or by an object your function is bound to) are rejected before the run starts, with guidance. Create them inside the function instead, or pass a path and reopen it in the worker.
No free shared memory. Workers cannot share a large read-only array by closing over it;
numpy.memmapa file instead, which costs one page cache copy rather than one heap copy per worker.
Serial execution (run_blockwise(…, multiprocessing=False)) is the in-process
escape hatch: single-threaded, no workers, your original function called
directly. Use it for pdb, for closures over live objects, and in tests that
need to observe in-process state.
A 0-arg worker function runs in its own process with DAISY_CONTEXT already
in that process’s environment, so whatever it launches (srun, sbatch,
subprocess.run) inherits the context and a daisy.Client() in the
grandchild connects without you forwarding anything, exactly as in 1.x. The
only launchers that need help are ones that drop the environment on purpose:
sbatch --export=NONE, docker run without -e DAISY_CONTEXT, ssh. Pass
the variable through there (--export=ALL or -e DAISY_CONTEXT), or declare
def start_worker(*, context) to receive the parsed daisy.Context and hand
context.to_env() to the launcher yourself. The context carries the master’s
log directory, and constructing a daisy.Client() adopts it, as in 1.x. The old set_log_basedir(client.context["logdir"]) line is now
redundant — harmless, but you can delete it. A worker that wants its logs
somewhere else calls set_log_basedir(...) after constructing its client.
Verified behaviour differences#
Every row below links to a script under experiments/
that runs unchanged with daisy 1.x or 2.0 installed and prints what it
measured, next to daisy’s own report. Each experiment directory also has a
README with the captured output on both lines, so you can read the result
without running it. A representative 3-D task in every worker mode
shows the normal case first; the speed plots are in the Speed
section below and in experiments/speed/.
Same behaviour on both lines#
Case |
1.x |
2.0 |
Verify |
|---|---|---|---|
Happy path: 512 3-D blocks with context, modes A, B, C and S |
every block exactly once |
every block exactly once |
|
|
works; |
works; |
|
A 0-arg worker function launches a subprocess that calls |
the grandchild finds |
the same, nothing to forward |
|
|
every block skipped |
every block skipped |
|
|
block runs; exception logged per block |
block runs; exception logged once per task |
|
A worker is SIGKILLed while holding a block |
replaced, block retried |
replaced, block retried |
|
Serial mode with a 0-argument worker function |
TypeError at the first block |
configuration error before the run |
1.x waits forever, 2.0 finishes or refuses#
Case |
1.x |
2.0 |
Verify |
|---|---|---|---|
A block never returns, |
the timeout is never enforced; the run hangs |
worker killed at 5 s, block retried, reported failed after |
|
The spawn function exits immediately |
respawned 10 times a second, forever |
|
|
Fire-and-forget launcher whose worker later dies |
nothing is respawned; the run hangs |
|
|
Log directory containing |
every worker fails to parse |
values are percent-encoded; the run completes |
|
|
waits forever |
|
|
Downstream task reaching beyond its upstream’s write region |
reachable blocks finish, then the run hangs |
the pipeline is refused before it starts |
1.x finishes with failed blocks, 2.0 abandons the task#
On 2.0 an exception in a block function kills its worker, and every worker
death counts against Task(max_worker_restarts=10). A run with more
persistently failing blocks than that budget is abandoned, its remaining
blocks are orphaned and run_blockwise raises. Raise the budget, or catch
exceptions inside the block function, for data with many bad blocks.
Case |
1.x |
2.0 |
Verify |
|---|---|---|---|
20 always-failing blocks of 2,000, 4 workers |
finishes the other 1,980, returns |
abandoned after 14 worker deaths; the rest orphaned; |
|
External worker that fails every block |
a new job every 4 failures; returns |
one job per failure until the budget is spent; |
|
A failure after the other worker was released as idle, |
retried by the failing worker |
abandoned with one orphan |
1.x is silent, 2.0 is loud#
Case |
1.x |
2.0 |
Verify |
|---|---|---|---|
Every block failed |
|
returns |
|
The block raises an exception that cannot be pickled |
failure list empty, returns |
traceback carried as text, returns |
|
Two tasks with the same |
the second is dropped silently |
|
|
Ctrl-C mid-run |
returns |
prints what finished, raises |
|
|
the whole run aborts with |
warns once, treats the block as not done |
Different by design#
Case |
1.x |
2.0 |
Verify |
|---|---|---|---|
No |
completes |
killed at the 600 s default, retried, reported failed; pass |
|
Every block already done on a re-run |
empty execution summary |
summary with a |
|
|
|
|
|
Progress output when stderr is a file |
two refreshes per block |
a handful of refreshes per second |
|
Resuming 20,000 done blocks |
one |
the same, or one byte per block with the built-in tracking store |
Speed#
Three measurements from experiments/speed/,
each from a script that runs on either line. The figures are from daisy
1.2.3 and 2.0.0 on one 96-core machine; the scripts, the raw CSV rows and
the plotting script are next to the README there.
Block throughput by worker mode and worker count#
10,000 no-op blocks, 1-D, no conflicts. 1.x is bounded at about 23 blocks
per second per worker: a 43 ms round trip per block, because its tornado
sockets leave Nagle on and run into the 40 ms delayed-ACK timer. 2.0 sets
TCP_NODELAY and is bounded by its scheduler thread instead, so it peaks
around 16 workers for a run this short. The three worker modes cost the
same on each line; the mode only changes who starts the worker process.

Mode |
Workers |
1.x blocks/s |
2.0 blocks/s |
Ratio |
|---|---|---|---|---|
A, block function |
1 |
24 |
7,999 |
333 |
A |
16 |
377 |
49,897 |
132 |
A |
64 |
1,169 |
25,586 |
22 |
B, worker function |
16 |
377 |
46,821 |
124 |
C, external worker |
16 |
375 |
34,895 |
93 |
The full table with 1, 4, 16 and 64 workers in every mode is in the speed README.
Scheduler cost without workers#
One thread acquiring and releasing every block straight from the
Scheduler: no TCP, no processes, no check_function. This is the
ceiling either line could reach with infinitely fast workers. With
read/write conflicts the 1.x scheduler rebuilds every neighbouring block
object on each release, which is why its 3-D grids stay slow however
many workers you add.

Grid |
1.x blocks/s |
2.0 blocks/s |
|---|---|---|
1-D, 100,000 blocks, no conflicts |
24,626 |
613,371 |
3-D, 8,000 blocks with context (conflicts) |
747 |
66,297 |
Resuming a finished run#
20,000 blocks already done. With a check_function both lines call it
once per block on the scheduling thread before any worker gets work; the
built-in tracking store on 2.0 reads one byte per block instead and needs
no check_function. The gap grows with slower checks and larger block
counts: at 100,000 blocks the evaluation measured 4.7 s for 1.x marker
files, 1.0 s for 2.0 marker files and 0.3 s for the store.

Strategy |
Line |
Resume, 20,000 blocks |
|---|---|---|
|
1.x |
1.05 s |
same |
2.0 |
0.19 s |
built-in tracking store, |
2.0 |
0.12 s |
Removed APIs#
These 1.x names are not exposed by daisy v2 (neither in daisy nor daisy.v2):
daisy.persistence— already deprecated in 1.x in favour offunlib.persistence. v2 drops the re-export. Import directly fromfunlib.persistence.daisy.Array,daisy.open_ds,daisy.prepare_ds— same story. Usefunlib.persistence.daisy.messages,daisy.tcp— internal protocol modules. v2’s wire protocol is bincode over tokio TCP, implemented in Rust; the message types are not exposed at the Python level. If you were writing a worker in another language, seedocs/source/design/PROTOCOL.md.
Wire / on-disk format breaks#
v2 introduces format identifiers under the v2 name; existing daisy 1.x stores were never compatible with these because the formats are v2 inventions. Worth knowing:
Block tracking is a Zarr v3 group per task (children:
done,failures, and the resource arrays when enabled), with adaisy_task_hashattribute on the group under the hash prefixdaisy-tracking:v1. These are v2-only; 1.x had no tracking store.The TCP frame carries a protocol version byte, and
Blockcarries an optional stats payload. The payload is positional bincode, which is not self-describing, so a driver and its workers must run the same daisy build. A mismatch reports both versions and tells you to rebuild your workers. The realistic way to hit this is external cluster workers loading daisy from a different environment (conda env, module tree) than the driver.Worker context carries
hostname(the address workers dial — auto-detected and verified, or set withrun_blockwise(host=...)/DAISY_HOST; the scheduler listens on all interfaces unless you ask for loopback),port,task_id,worker_id,resource_trackingandlogdir(the master’s log directory, so a worker at the far end of ansruncan place its own logs where the rest of the run’s are). Values are percent-encoded —%,:and=become%25,%3A,%3D— because thekey=value:key=valueframing has no escape mechanism and both task ids and paths can contain those characters;Context.to_env()/from_env()hide this, so only code parsing the raw environment variable itself needs to care. Each worker function runs in its own child process withDAISY_CONTEXTset in that process’s environment before the function is called, so processes it launches inherit it; nothing has to be forwarded by hand unless the launcher drops the environment (sbatch --export=NONE, a container,ssh). A worker function that wants the parsed form can declare a keyword-onlycontextparameter and receive this worker’sdaisy.Context:def start_worker(*, context): subprocess.run( [ "sbatch", "--wait", # <- block until the job finishes (see below) "--export=NONE", # a launcher that drops the environment ... f"--export=DAISY_CONTEXT={context.to_env()}", # ... so forward it "worker.sh", ] )
The keyword-only parameter doesn’t change the function’s positional arity, so it still classifies as a worker function;
contextsupports dict access (context["worker_id"]) andto_env().Spawn functions must block for the worker’s lifetime. daisy’s worker accounting (
max_workers, the start budget, abandonment) tracks the spawn-function call: while your function is running, that worker slot counts as alive; when it returns, the slot is considered dead and eligible for replacement. A submit-and-return spawn function (sbatchwithout--wait,bsubwithout-K, a barePopen) breaks that link: daisy will submitmax_workers + max_worker_restartsjobs instead ofmax_workers, all of which connect and process concurrently, and may abandon the task while your jobs are still waiting in the queue (it cannot tell a queued job from a dead one). Use the blocking form:sbatch --wait,bsub -K,srun, orsubprocess.runfor local workers. Connection-aware worker accounting that lifts this requirement is on the roadmap.
Python version#
daisy 1.x supported Python ≥ 3.10.
daisy v2 requires Python ≥ 3.11.
Dependencies#
daisy 1.x: hard runtime deps on
numpy,tornado,dill,funlib.math,funlib.geometry,tqdm.daisy v2: hard runtime deps on
tqdm,cloudpickle(worker payloads; the v1-compat block wrapper is a closure, so plain pickle is not enough) andfunlib.geometry(the v1-compatBlockhands out funlib ROIs). The Rust core uses no Python deps;numpy/zarrare only needed to read a tracking store from outside daisy.
Suppressing deprecation warnings#
If you want to silence the v1_compat warnings while you migrate (e.g. in CI), add this to your pyproject.toml:
[tool.pytest.ini_options]
filterwarnings = [
"ignore::DeprecationWarning:daisy.v1_compat",
]
Or in code:
import warnings
warnings.filterwarnings("ignore", category=DeprecationWarning, module="daisy.v1_compat")
The cleanest long-term fix is to switch the import to import daisy.v2 as daisy and address the renamed kwargs in your tasks.