Skip to content

Commit 6eea888

Browse files
Merge pull request #13 from JaneliaSciComp/write-defaults-0.8
0.8.0: write without fsync or OS locks, file I/O sized apart, create timed out
2 parents da56dbd + 9065322 commit 6eea888

10 files changed

Lines changed: 251 additions & 57 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,31 @@
11
# Changelog
22

3+
## 0.8.0 (2026-10-23)
4+
5+
Faster writes from the contexts the package makes, and the create open timed out. No
6+
placement change and no unit table change. **Requires tensorstore 0.1.68 or later** (was
7+
0.1.60), the first with the `file_io_locking` setting.
8+
9+
- `shared_context` and `capped_context` write without fsync (`file_io_sync: false`) and
10+
without OS file locks (`file_io_locking` mode `"none"`); `fsync=True` and `locking="os"`
11+
restore tensorstore's defaults. A whole-unit write stays atomic, a temporary file renamed
12+
into place; only concurrent read-modify-writes of one unit (`partial=True`) lose their
13+
guarantee, which was never given (Behaviour 13). fsync cost 6.5 ms a chunk on a local
14+
disk and nothing on NFS; the locks cost about 10 ms a chunk on NFS.
15+
- The default context's file I/O pool is `thread_budget()` or 8, whichever is larger;
16+
decoding stays at `thread_budget()`. File I/O threads wait on storage rather than
17+
compute, and one of them wrote a block's chunk files one NFS round trip after another.
18+
`capped_context(file_io=)` sizes it apart from `concurrency` (default unchanged).
19+
- From one-thread workers through the default context, against 0.7: a 64^3 label chunk to
20+
NFS 23 to 12.2 ms (zarr-python 2: 13.1), an 8-chunk label block 159 to 66.5 ms
21+
(zarr-python 2: 102), an 8-chunk EM block 330 to 202 ms; to a local disk a label block
22+
51.5 to 4.1 ms. Through `capped_context(1, network=False)` the NFS label block went 166
23+
to 91 ms; `file_io=8` gives it the rest.
24+
- The open that creates an array is timed out and retried under `retries` (Behaviour 6). A
25+
retry that finds the array made by an attempt of its own that timed out adopts it after
26+
the usual comparison, even under `exists="error"`. That open keeps OS locking, so
27+
create-if-absent stays atomic.
28+
329
## 0.7.1 (2026-10-23)
430

531
Faster opens over http. No placement change, no unit table change, no API change.

‎README.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,11 @@ wa.metadata(path, level=None, units=None, open=True, retries=None) -> ArrayMeta
150150
wa.detect(path, missing_ok=False, retries=None) -> Node # format, array or group, raw attrs
151151
wa.open_tensorstore(path, level=None, context=None, retries=None)
152152
# retries= times out and retries document reads and the open, as it does data reads
153-
wa.shared_context(cache_bytes=0, threads=None) ; wa.capped_context(cache_bytes=0, concurrency=1, network=True) ; wa.Retry(...)
154-
wa.thread_budget() # the shared context's default thread limit: OMP_NUM_THREADS, the job's slots, or CPUs;
155-
# threads=N limits it to N, threads=0 leaves tensorstore's own pool sizes
153+
wa.shared_context(cache_bytes=0, threads=None, fsync=False, locking="none") ; wa.Retry(...)
154+
wa.capped_context(cache_bytes=0, concurrency=1, network=True, file_io=None, fsync=False, locking="none")
155+
wa.thread_budget() # the shared context's decoding threads: OMP_NUM_THREADS, the job's slots, or CPUs;
156+
# file I/O gets at least 8; threads=N limits both to N, threads=0 leaves tensorstore's sizes
157+
# contexts made here write without fsync or OS file locks; fsync=True, locking="os" restore them
156158
wa.list_chunk_keys(path, separator=".") # chunk indices from keys alone, no metadata needed
157159
wa.Box ; wa.units.TABLE
158160

‎docs/spec.md‎

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,8 @@ is asked to convert.
8585
the OME document builder and the level rule, chunk keys, `create`, opening for write,
8686
aligned writes, `add_level`. 0.3 adds grids (Behaviours 17 to 19): a store-less `Grid`,
8787
the block iterator every blockwise repo writes for itself, `write_chunk`, the factor
88-
schedule and the public path helpers. Dependencies at every version: `tensorstore>=0.1.60`,
89-
`numpy`. **No `zarr`, no `dask`, no `pydantic`, no `funlib`.** The package is float-only; a
88+
schedule and the public path helpers. Dependencies at every version: `tensorstore`
89+
(`>=0.1.60`; `>=0.1.68` from 0.8, the first with `file_io_locking`), `numpy`. **No `zarr`, no `dask`, no `pydantic`, no `funlib`.** The package is float-only; a
9090
consumer that works in integer Rois builds them in its own subclass (section 6). Python >=
9191
3.10 with no 3.11-only syntax, no upper bounds on numpy or tensorstore.
9292

@@ -185,8 +185,11 @@ wa.open_tensorstore(path, level=None, context=None, retries=None) -> tensorstore
185185
wa.corner_to_centre(corner, voxel_size, axes=None) ; wa.default_axes(ndim) ; wa.index_range(begin, end, voxel_size, translation)
186186
wa.units.TABLE # the unit tables, public: {"length": {...metres per unit}, "time": {...seconds per unit}}
187187
wa.select_level(levels, voxel_size, mode) ; wa.Box ; wa.Retry(max_attempts=10, timeout=None, base_delay=1.0)
188-
wa.shared_context(cache_bytes=0, threads=None) ; wa.capped_context(cache_bytes=0, concurrency=1, network=True) ; wa.capped_context_spec(...)
189-
# threads: None = thread_budget(), N = that many, 0 = tensorstore's own pool sizes (0.6)
188+
wa.shared_context(cache_bytes=0, threads=None, fsync=False, locking="none")
189+
wa.capped_context(cache_bytes=0, concurrency=1, network=True, file_io=None, fsync=False, locking="none") ; wa.capped_context_spec(...)
190+
# threads: None = thread_budget() for decoding and at least 8 for file I/O (0.8), N = that many,
191+
# 0 = tensorstore's own pool sizes (0.6); fsync and locking: tensorstore's file_io_sync and
192+
# file_io_locking mode, off and "none" in every context the package makes (0.8, Behaviour 7)
190193
# network=False caps decoding and local file I/O only, leaving tensorstore's request pools alone
191194
wa.list_chunk_keys(path, separator=".", ndim=None) # chunk indices present under a path, from its keys alone: no
192195
# metadata document needed, so a directory of chunk files with no .zarray lists too
@@ -232,9 +235,9 @@ where it also chooses the integer scale factor that makes the corner whole.
232235

233236
`shared_context(cache_bytes, threads=None)` is one `tensorstore.Context` per budget and
234237
thread limit for the process, so the levels of a pyramid and repeat opens share one cache
235-
pool; the default budget is 0. Its decoding and file I/O pools are sized by
236-
`thread_budget()` (0.5), or by `threads` when given, `0` leaving tensorstore's own sizes
237-
(0.6, Behaviour 7).
238+
pool; the default budget is 0. Its decoding pool is sized by `thread_budget()` (0.5) and
239+
its file I/O pool by that or 8, whichever is larger (0.8), or both by `threads` when given,
240+
`0` leaving tensorstore's own sizes (0.6, Behaviour 7).
238241
`capped_context` is mesh-n-bone's worker-process context (every tensorstore concurrency
239242
resource limited, cache off); it returns a **new** context per call, as those workers
240243
inline the spec into every open today, and `capped_context_spec` exports the dict for
@@ -665,8 +668,13 @@ this document and that one differ, fix one of them.
665668
of the public call doing the reading: `open`, `open_pyramid`, `create`, `metadata`,
666669
`detect` and `open_tensorstore`, which all take it; a document read later outside such
667670
a call (a zarr v2 `.zattrs` read on demand, a pyramid placing its levels) waits under
668-
`Retry()`. Listings are not timed out, their size being unknown, and neither is the
669-
open that creates an array, which a second attempt would find already made.
671+
`Retry()`. Listings are not timed out, their size being unknown. The open that creates
672+
an array is (0.8, cellmap-analyze's question): its second attempt may find the array its
673+
first made before timing out, so after a timed-out attempt `ALREADY_EXISTS` is not an
674+
error even under `exists="error"`, and `create` adopts the array only if it matches what
675+
was asked for (the comparison an adopt makes), since an identical array that appeared
676+
after our own attempt is ours. That open keeps tensorstore's `"os"` file locking whatever
677+
the caller's context says, as create-if-absent is a conditional write.
670678

671679
The watchdog (0.5): tensorstore up to at least 0.1.85 can strand a queued task. A pool
672680
worker idle for 20 ms exits, and a task queued as it exits gets no thread while the pool
@@ -687,16 +695,21 @@ this document and that one differ, fix one of them.
687695

688696
7. **Contexts.** Without `context`, every open in the process shares `shared_context(0)`:
689697
tensorstore's cache is off and the levels of one pyramid share one context. Its
690-
`data_copy_concurrency` and `file_io_concurrency` limits are `thread_budget()`: the
691-
first of `OMP_NUM_THREADS` (dask sets 1 in every worker it spawns; a cluster job script
698+
`data_copy_concurrency` limit is `thread_budget()`: the first of `OMP_NUM_THREADS` (dask sets 1 in every worker it spawns; a cluster job script
692699
sets its slots) and `LSB_DJOB_NUMPROC` (an LSF job's slots) that holds a positive
693700
integer, else the CPUs `sched_getaffinity` allows: the cap every other threaded library
694701
in the job already takes, as Janelia's cluster guidance asks. tensorstore alone sizes
695702
both pools to every CPU of the node, whatever the job's slots, so each of twelve dask
696703
workers in a twelve-slot job on a 96-core node could start 96 threads (0.5). A dask
697704
worker therefore runs tensorstore on one thread of each pool, which Behaviour 6's
698705
watchdog makes safe. SLURM's `SLURM_CPUS_PER_TASK` and SGE's `NSLOTS` stand in for
699-
`LSB_DJOB_NUMPROC` elsewhere. The cap is the default because the process that passes
706+
`LSB_DJOB_NUMPROC` elsewhere. Its `file_io_concurrency` limit is that or
707+
`context.FILE_IO_FLOOR` (8), whichever is larger (0.8): file I/O threads wait on storage
708+
rather than compute, so they take no core from the job, and one of them serialises every
709+
chunk file of a block on NFS's round trips. From one decoding thread, eight 64^3 label
710+
chunks written to NFS took 166 to 176 ms with one file I/O thread and 83 to 89 ms with
711+
four to sixteen (zarr-python 2 took 102); an 8-chunk EM read from /nrs was the same
712+
either way, decoding bound. The cap is the default because the process that passes
700713
no context is the one nobody tuned: a quick dask script over `wa.open(path)` is how a
701714
job came to start a thousand threads on twelve slots. One thread per pool slows one
702715
task (a 256^3 block write took 2.1 s against 1.3 s uncapped) but not a job of many
@@ -711,9 +724,30 @@ this document and that one differ, fix one of them.
711724
process, made on first use and watched; `threads=None` and the number
712725
`thread_budget()` returns are two contexts, as a pickle carries `None` for the other
713726
process to size by its own job and a number as that number.
727+
728+
Writes (0.8). Every context the package makes (`shared_context`, `capped_context`)
729+
sets tensorstore's `file_io_sync` to false and its `file_io_locking` mode to `"none"`,
730+
the arguments `fsync=True` and `locking="os"` restoring tensorstore's defaults. fsync
731+
cost 6.5 ms a chunk on a local NVMe disk (8.2 against 1.5 ms for a 64^3 chunk) and
732+
nothing on NFS, whose client commits a file when it closes it; zarr-python and funlib,
733+
every writer the repos used before, never synced, so a machine crash on a local disk
734+
can lose the last seconds of chunks as it always could, and a killed process loses
735+
nothing. `"os"` locking takes an advisory lock on a `.__lock` file for every write,
736+
about 10 ms of NFS round trips a chunk (21.7 against 12.0 ms for a 64^3 label chunk on
737+
/groups; zarr-python 2 took 12.7). With `"none"` a whole-unit write is still atomic, a
738+
temporary file renamed into place, and only concurrent read-modify-writes of one unit
739+
lose their guarantee, which Behaviour 13 never gave (`partial=True` is unsafe while
740+
anything else writes the unit); a write interrupted mid-way may leave a temporary
741+
`.__lock` file that costs space only. tensorstore's `"lockfile"` mode is not the
742+
default because such a file left by a killed worker blocks every later write of its
743+
unit. Together, from one-thread workers (`OMP_NUM_THREADS=1`) through the default
744+
context: a 64^3 label chunk to NFS took 23 ms in 0.7 and 12.2 in 0.8, an 8-chunk label
745+
block 159 and 66.5, an 8-chunk EM block 330 and 202; to a local disk the label block
746+
took 51.5 and 4.1 ms.
714747
`capped_context` sets `data_copy_concurrency`, `file_io_concurrency`,
715748
`gcs_request_concurrency`, `s3_request_concurrency` and `http_request_concurrency` to
716-
`concurrency` and the cache to `cache_bytes`, and returns a new context each call, so two
749+
`concurrency` (`file_io_concurrency` to `file_io` when given, 0.8) and the cache to
750+
`cache_bytes`, and returns a new context each call, so two
717751
handles never share one capped pool unless the caller passes the same object twice; a
718752
`concurrency` of 1 is safe under Behaviour 6's watchdog, but a read or write spanning
719753
several chunks then handles them one at a time. On /nrs, from one process alternating
@@ -1035,7 +1069,10 @@ context's own pools; `thread_budget` from each variable, a bad value skipped, an
10351069
shared context's limits. 0.6: `shared_context(threads=)` at `None`, `N` and `0`, one
10361070
context per pair, a negative count refused, and a pickled handle coming back on the same
10371071
shared context. 0.7: `metadata`, `open` and `detect` against a server
1038-
that accepts and never answers raising `TimeoutError` after the attempts. Opt-in (`WORLDARRAY_STRESS=1`, about a minute,
1072+
that accepts and never answers raising `TimeoutError` after the attempts. 0.8: the default
1073+
and capped contexts' file I/O limit, sync and locking, `fsync` and `locking` carried
1074+
through a pickle, and a create whose first attempt lands after its timeout adopting its own
1075+
array under `exists="error"`. Opt-in (`WORLDARRAY_STRESS=1`, about a minute,
10391076
`tests/test_lost_wakeup.py`): worker processes aim tasks at the moment a one-thread pool's
10401077
worker exits idle, so a plain context strands some for good (13 of about 7,000 on
10411078
tensorstore 0.1.78; 40 to 44 of 12,000 to 15,000 with 16 workers on 0.1.75 and 0.1.85)

‎pyproject.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "worldarray"
3-
version = "0.7.1"
3+
version = "0.8.0"
44
description = "Read zarr, N5 and Neuroglancer precomputed volumes in world coordinates through tensorstore: correct voxel placement, pyramid levels, padded region reads, resampling. numpy + tensorstore only."
55
readme = "README.md"
66
license = { file = "LICENSE" }
@@ -31,7 +31,7 @@ classifiers = [
3131
# no dask, no funlib (docs/spec.md, section 1).
3232
dependencies = [
3333
"numpy>=1.26",
34-
"tensorstore>=0.1.60",
34+
"tensorstore>=0.1.68",
3535
]
3636

3737
[project.urls]

‎src/worldarray/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
parse_chunk_key,
2929
)
3030

31-
__version__ = "0.7.1"
31+
__version__ = "0.8.0"
3232

3333
__all__ = [
3434
"ArrayMeta",

0 commit comments

Comments
 (0)