worldarray 0.7.1__tar.gz → 0.9.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {worldarray-0.7.1 → worldarray-0.9.0}/CHANGELOG.md +42 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/PKG-INFO +14 -10
- {worldarray-0.7.1 → worldarray-0.9.0}/README.md +12 -8
- {worldarray-0.7.1 → worldarray-0.9.0}/docs/spec.md +75 -20
- {worldarray-0.7.1 → worldarray-0.9.0}/pyproject.toml +2 -2
- {worldarray-0.7.1 → worldarray-0.9.0}/src/worldarray/__init__.py +1 -1
- {worldarray-0.7.1 → worldarray-0.9.0}/src/worldarray/context.py +120 -31
- {worldarray-0.7.1 → worldarray-0.9.0}/src/worldarray/write.py +18 -2
- {worldarray-0.7.1 → worldarray-0.9.0}/tests/test_access.py +68 -4
- {worldarray-0.7.1 → worldarray-0.9.0}/tests/test_write.py +47 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/uv.lock +2 -2
- {worldarray-0.7.1 → worldarray-0.9.0}/.github/workflows/ci.yml +0 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/.github/workflows/docs.yml +0 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/.github/workflows/publish.yml +0 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/.gitignore +0 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/LICENSE +0 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/docs/changelog.md +0 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/docs/index.md +0 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/mkdocs.yml +0 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/src/worldarray/array.py +0 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/src/worldarray/geometry.py +0 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/src/worldarray/grid.py +0 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/src/worldarray/levels.py +0 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/src/worldarray/placement.py +0 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/src/worldarray/resample.py +0 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/src/worldarray/retry.py +0 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/src/worldarray/store.py +0 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/src/worldarray/units.py +0 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/tests/conftest.py +0 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/tests/fixtures/metadata.json +0 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/tests/fixtures/write.json +0 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/tests/test_geometry.py +0 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/tests/test_grid.py +0 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/tests/test_lost_wakeup.py +0 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/tests/test_metadata.py +0 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/tests/test_read.py +0 -0
- {worldarray-0.7.1 → worldarray-0.9.0}/tests/test_resample.py +0 -0
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0 (2026-10-23)
|
|
4
|
+
|
|
5
|
+
A job script can size each worker's threads. No placement change, no unit table change, and
|
|
6
|
+
no default changes: with `WORLDARRAY_THREADS` unset and no `file_io_floor`, every context
|
|
7
|
+
is what 0.8 made.
|
|
8
|
+
|
|
9
|
+
- `thread_budget()` reads `WORLDARRAY_THREADS` ahead of `OMP_NUM_THREADS` and the job's
|
|
10
|
+
slots, for a job script to give each dask worker its share of the job's cores where
|
|
11
|
+
`OMP_NUM_THREADS` stays 1 for numpy and `LSB_DJOB_NUMPROC` counts the whole job (for
|
|
12
|
+
example `max(1, ncpus // processes)`). A handle opened on the driver on the shared
|
|
13
|
+
context is sized in each worker by that worker's value. Anything but a positive integer
|
|
14
|
+
raises `ValueError`; blank is unset.
|
|
15
|
+
- `shared_context(file_io_floor=)` sets the least the file I/O pool takes under the thread
|
|
16
|
+
budget (default `FILE_IO_FLOOR`, 8); `1` keeps file I/O at the budget. A pickle carries
|
|
17
|
+
it, and with `threads=N` or `threads=0` it is ignored.
|
|
18
|
+
|
|
19
|
+
## 0.8.0 (2026-10-23)
|
|
20
|
+
|
|
21
|
+
Faster writes from the contexts the package makes, and the create open timed out. No
|
|
22
|
+
placement change and no unit table change. **Requires tensorstore 0.1.68 or later** (was
|
|
23
|
+
0.1.60), the first with the `file_io_locking` setting.
|
|
24
|
+
|
|
25
|
+
- `shared_context` and `capped_context` write without fsync (`file_io_sync: false`) and
|
|
26
|
+
without OS file locks (`file_io_locking` mode `"none"`); `fsync=True` and `locking="os"`
|
|
27
|
+
restore tensorstore's defaults. A whole-unit write stays atomic, a temporary file renamed
|
|
28
|
+
into place; only concurrent read-modify-writes of one unit (`partial=True`) lose their
|
|
29
|
+
guarantee, which was never given (Behaviour 13). fsync cost 6.5 ms a chunk on a local
|
|
30
|
+
disk and nothing on NFS; the locks cost about 10 ms a chunk on NFS.
|
|
31
|
+
- The default context's file I/O pool is `thread_budget()` or 8, whichever is larger;
|
|
32
|
+
decoding stays at `thread_budget()`. File I/O threads wait on storage rather than
|
|
33
|
+
compute, and one of them wrote a block's chunk files one NFS round trip after another.
|
|
34
|
+
`capped_context(file_io=)` sizes it apart from `concurrency` (default unchanged).
|
|
35
|
+
- From one-thread workers through the default context, against 0.7: a 64^3 label chunk to
|
|
36
|
+
NFS 23 to 12.2 ms (zarr-python 2: 13.1), an 8-chunk label block 159 to 66.5 ms
|
|
37
|
+
(zarr-python 2: 102), an 8-chunk EM block 330 to 202 ms; to a local disk a label block
|
|
38
|
+
51.5 to 4.1 ms. Through `capped_context(1, network=False)` the NFS label block went 166
|
|
39
|
+
to 91 ms; `file_io=8` gives it the rest.
|
|
40
|
+
- The open that creates an array is timed out and retried under `retries` (Behaviour 6). A
|
|
41
|
+
retry that finds the array made by an attempt of its own that timed out adopts it after
|
|
42
|
+
the usual comparison, even under `exists="error"`. That open keeps OS locking, so
|
|
43
|
+
create-if-absent stays atomic.
|
|
44
|
+
|
|
3
45
|
## 0.7.1 (2026-10-23)
|
|
4
46
|
|
|
5
47
|
Faster opens over http. No placement change, no unit table change, no API change.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: worldarray
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Read zarr, N5 and Neuroglancer precomputed volumes in world coordinates through tensorstore: correct voxel placement, pyramid levels, padded region reads, resampling. numpy + tensorstore only.
|
|
5
5
|
Project-URL: Homepage, https://janeliascicomp.github.io/worldarray/
|
|
6
6
|
Project-URL: Documentation, https://janeliascicomp.github.io/worldarray/
|
|
@@ -54,7 +54,7 @@ Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
|
54
54
|
Classifier: Typing :: Typed
|
|
55
55
|
Requires-Python: >=3.10
|
|
56
56
|
Requires-Dist: numpy>=1.26
|
|
57
|
-
Requires-Dist: tensorstore>=0.1.
|
|
57
|
+
Requires-Dist: tensorstore>=0.1.68
|
|
58
58
|
Description-Content-Type: text/markdown
|
|
59
59
|
|
|
60
60
|
# worldarray
|
|
@@ -146,11 +146,12 @@ s1 = wa.open_pyramid("out.zarr", mode="r+").add_level("s1", factors=2) # place
|
|
|
146
146
|
interpolation anchored where you say. Adjoining block reads agree with one read across
|
|
147
147
|
both.
|
|
148
148
|
- **Behaves on a cluster**: one shared tensorstore context per process, its threads sized
|
|
149
|
-
from `
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
149
|
+
from `WORLDARRAY_THREADS` (a job script's share for each worker), `OMP_NUM_THREADS` or
|
|
150
|
+
the LSF job's slots instead of the node's cores, or a capped one per worker; reads and
|
|
151
|
+
writes retried with backoff and size-aware timeouts on transient store errors, a
|
|
152
|
+
cancelled future on timeout, and every context's thread pools nudged once a second
|
|
153
|
+
against a tensorstore bug that can leave a queued task with no thread, so a stuck
|
|
154
|
+
operation recovers or fails loudly instead of hanging the job.
|
|
154
155
|
- **Carries the geometry**: `Box` with `intersect`, `union`, `contains`, `grow`, `shift`,
|
|
155
156
|
`snap` to a grid, and the voxel indices a world box selects.
|
|
156
157
|
- **Writes placed arrays** (0.2): the OME-NGFF 0.4 or 0.5 document from voxel size, centre
|
|
@@ -209,9 +210,12 @@ wa.metadata(path, level=None, units=None, open=True, retries=None) -> ArrayMeta
|
|
|
209
210
|
wa.detect(path, missing_ok=False, retries=None) -> Node # format, array or group, raw attrs
|
|
210
211
|
wa.open_tensorstore(path, level=None, context=None, retries=None)
|
|
211
212
|
# retries= times out and retries document reads and the open, as it does data reads
|
|
212
|
-
wa.shared_context(cache_bytes=0, threads=None
|
|
213
|
-
wa.
|
|
214
|
-
|
|
213
|
+
wa.shared_context(cache_bytes=0, threads=None, fsync=False, locking="none", file_io_floor=8) ; wa.Retry(...)
|
|
214
|
+
wa.capped_context(cache_bytes=0, concurrency=1, network=True, file_io=None, fsync=False, locking="none")
|
|
215
|
+
wa.thread_budget() # the shared context's decoding threads: WORLDARRAY_THREADS, OMP_NUM_THREADS,
|
|
216
|
+
# the job's slots, or CPUs; file I/O gets at least file_io_floor (8);
|
|
217
|
+
# threads=N limits both to N, threads=0 leaves tensorstore's sizes
|
|
218
|
+
# contexts made here write without fsync or OS file locks; fsync=True, locking="os" restore them
|
|
215
219
|
wa.list_chunk_keys(path, separator=".") # chunk indices from keys alone, no metadata needed
|
|
216
220
|
wa.Box ; wa.units.TABLE
|
|
217
221
|
|
|
@@ -87,11 +87,12 @@ s1 = wa.open_pyramid("out.zarr", mode="r+").add_level("s1", factors=2) # place
|
|
|
87
87
|
interpolation anchored where you say. Adjoining block reads agree with one read across
|
|
88
88
|
both.
|
|
89
89
|
- **Behaves on a cluster**: one shared tensorstore context per process, its threads sized
|
|
90
|
-
from `
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
90
|
+
from `WORLDARRAY_THREADS` (a job script's share for each worker), `OMP_NUM_THREADS` or
|
|
91
|
+
the LSF job's slots instead of the node's cores, or a capped one per worker; reads and
|
|
92
|
+
writes retried with backoff and size-aware timeouts on transient store errors, a
|
|
93
|
+
cancelled future on timeout, and every context's thread pools nudged once a second
|
|
94
|
+
against a tensorstore bug that can leave a queued task with no thread, so a stuck
|
|
95
|
+
operation recovers or fails loudly instead of hanging the job.
|
|
95
96
|
- **Carries the geometry**: `Box` with `intersect`, `union`, `contains`, `grow`, `shift`,
|
|
96
97
|
`snap` to a grid, and the voxel indices a world box selects.
|
|
97
98
|
- **Writes placed arrays** (0.2): the OME-NGFF 0.4 or 0.5 document from voxel size, centre
|
|
@@ -150,9 +151,12 @@ wa.metadata(path, level=None, units=None, open=True, retries=None) -> ArrayMeta
|
|
|
150
151
|
wa.detect(path, missing_ok=False, retries=None) -> Node # format, array or group, raw attrs
|
|
151
152
|
wa.open_tensorstore(path, level=None, context=None, retries=None)
|
|
152
153
|
# retries= times out and retries document reads and the open, as it does data reads
|
|
153
|
-
wa.shared_context(cache_bytes=0, threads=None
|
|
154
|
-
wa.
|
|
155
|
-
|
|
154
|
+
wa.shared_context(cache_bytes=0, threads=None, fsync=False, locking="none", file_io_floor=8) ; wa.Retry(...)
|
|
155
|
+
wa.capped_context(cache_bytes=0, concurrency=1, network=True, file_io=None, fsync=False, locking="none")
|
|
156
|
+
wa.thread_budget() # the shared context's decoding threads: WORLDARRAY_THREADS, OMP_NUM_THREADS,
|
|
157
|
+
# the job's slots, or CPUs; file I/O gets at least file_io_floor (8);
|
|
158
|
+
# threads=N limits both to N, threads=0 leaves tensorstore's sizes
|
|
159
|
+
# contexts made here write without fsync or OS file locks; fsync=True, locking="os" restore them
|
|
156
160
|
wa.list_chunk_keys(path, separator=".") # chunk indices from keys alone, no metadata needed
|
|
157
161
|
wa.Box ; wa.units.TABLE
|
|
158
162
|
|
|
@@ -85,8 +85,8 @@ is asked to convert.
|
|
|
85
85
|
the OME document builder and the level rule, chunk keys, `create`, opening for write,
|
|
86
86
|
aligned writes, `add_level`. 0.3 adds grids (Behaviours 17 to 19): a store-less `Grid`,
|
|
87
87
|
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
|
|
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
|
|
90
90
|
consumer that works in integer Rois builds them in its own subclass (section 6). Python >=
|
|
91
91
|
3.10 with no 3.11-only syntax, no upper bounds on numpy or tensorstore.
|
|
92
92
|
|
|
@@ -185,8 +185,12 @@ wa.open_tensorstore(path, level=None, context=None, retries=None) -> tensorstore
|
|
|
185
185
|
wa.corner_to_centre(corner, voxel_size, axes=None) ; wa.default_axes(ndim) ; wa.index_range(begin, end, voxel_size, translation)
|
|
186
186
|
wa.units.TABLE # the unit tables, public: {"length": {...metres per unit}, "time": {...seconds per unit}}
|
|
187
187
|
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
|
|
189
|
-
|
|
188
|
+
wa.shared_context(cache_bytes=0, threads=None, fsync=False, locking="none", file_io_floor=8)
|
|
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 file_io_floor for file I/O (0.8; the
|
|
191
|
+
# argument 0.9), N = that many, 0 = tensorstore's own pool sizes (0.6); fsync and locking:
|
|
192
|
+
# tensorstore's file_io_sync and file_io_locking mode, off and "none" in every context the
|
|
193
|
+
# package makes (0.8, Behaviour 7)
|
|
190
194
|
# network=False caps decoding and local file I/O only, leaving tensorstore's request pools alone
|
|
191
195
|
wa.list_chunk_keys(path, separator=".", ndim=None) # chunk indices present under a path, from its keys alone: no
|
|
192
196
|
# metadata document needed, so a directory of chunk files with no .zarray lists too
|
|
@@ -232,9 +236,9 @@ where it also chooses the integer scale factor that makes the corner whole.
|
|
|
232
236
|
|
|
233
237
|
`shared_context(cache_bytes, threads=None)` is one `tensorstore.Context` per budget and
|
|
234
238
|
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
|
|
236
|
-
|
|
237
|
-
(0.6, Behaviour 7).
|
|
239
|
+
pool; the default budget is 0. Its decoding pool is sized by `thread_budget()` (0.5) and
|
|
240
|
+
its file I/O pool by that or `file_io_floor` (8), whichever is larger (0.8; the argument
|
|
241
|
+
0.9), or both by `threads` when given, `0` leaving tensorstore's own sizes (0.6, Behaviour 7).
|
|
238
242
|
`capped_context` is mesh-n-bone's worker-process context (every tensorstore concurrency
|
|
239
243
|
resource limited, cache off); it returns a **new** context per call, as those workers
|
|
240
244
|
inline the spec into every open today, and `capped_context_spec` exports the dict for
|
|
@@ -363,8 +367,8 @@ wa.create(..., attrs={...}, exists="adopt") # an adopt also compares the keys
|
|
|
363
367
|
vol.write_index(begin, end, data, partial=False, retries=None)
|
|
364
368
|
vol.write(box, data, partial=False, retries=None) ; vol.write_chunk(index, data, retries=None)
|
|
365
369
|
# timed out and retried as reads are: `retries`, else the handle's (Behaviour 6)
|
|
366
|
-
wa.thread_budget() -> int # threads for tensorstore's decoding and file I/O in this process:
|
|
367
|
-
# LSB_DJOB_NUMPROC, else the CPUs it may run on (Behaviour 7)
|
|
370
|
+
wa.thread_budget() -> int # threads for tensorstore's decoding and file I/O in this process: WORLDARRAY_THREADS (0.9),
|
|
371
|
+
# else OMP_NUM_THREADS, else LSB_DJOB_NUMPROC, else the CPUs it may run on (Behaviour 7)
|
|
368
372
|
# every context the package makes or opens a store on is kicked once a second, whoever
|
|
369
373
|
# waits on it (Behaviour 6)
|
|
370
374
|
```
|
|
@@ -665,8 +669,13 @@ this document and that one differ, fix one of them.
|
|
|
665
669
|
of the public call doing the reading: `open`, `open_pyramid`, `create`, `metadata`,
|
|
666
670
|
`detect` and `open_tensorstore`, which all take it; a document read later outside such
|
|
667
671
|
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
|
|
669
|
-
|
|
672
|
+
`Retry()`. Listings are not timed out, their size being unknown. The open that creates
|
|
673
|
+
an array is (0.8, cellmap-analyze's question): its second attempt may find the array its
|
|
674
|
+
first made before timing out, so after a timed-out attempt `ALREADY_EXISTS` is not an
|
|
675
|
+
error even under `exists="error"`, and `create` adopts the array only if it matches what
|
|
676
|
+
was asked for (the comparison an adopt makes), since an identical array that appeared
|
|
677
|
+
after our own attempt is ours. That open keeps tensorstore's `"os"` file locking whatever
|
|
678
|
+
the caller's context says, as create-if-absent is a conditional write.
|
|
670
679
|
|
|
671
680
|
The watchdog (0.5): tensorstore up to at least 0.1.85 can strand a queued task. A pool
|
|
672
681
|
worker idle for 20 ms exits, and a task queued as it exits gets no thread while the pool
|
|
@@ -687,8 +696,7 @@ this document and that one differ, fix one of them.
|
|
|
687
696
|
|
|
688
697
|
7. **Contexts.** Without `context`, every open in the process shares `shared_context(0)`:
|
|
689
698
|
tensorstore's cache is off and the levels of one pyramid share one context. Its
|
|
690
|
-
`data_copy_concurrency`
|
|
691
|
-
first of `OMP_NUM_THREADS` (dask sets 1 in every worker it spawns; a cluster job script
|
|
699
|
+
`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
|
|
692
700
|
sets its slots) and `LSB_DJOB_NUMPROC` (an LSF job's slots) that holds a positive
|
|
693
701
|
integer, else the CPUs `sched_getaffinity` allows: the cap every other threaded library
|
|
694
702
|
in the job already takes, as Janelia's cluster guidance asks. tensorstore alone sizes
|
|
@@ -696,7 +704,25 @@ this document and that one differ, fix one of them.
|
|
|
696
704
|
workers in a twelve-slot job on a 96-core node could start 96 threads (0.5). A dask
|
|
697
705
|
worker therefore runs tensorstore on one thread of each pool, which Behaviour 6's
|
|
698
706
|
watchdog makes safe. SLURM's `SLURM_CPUS_PER_TASK` and SGE's `NSLOTS` stand in for
|
|
699
|
-
`LSB_DJOB_NUMPROC` elsewhere.
|
|
707
|
+
`LSB_DJOB_NUMPROC` elsewhere. Ahead of them all comes `WORLDARRAY_THREADS` (0.9), the
|
|
708
|
+
package's own variable, for a job script to give each worker its share of the job's
|
|
709
|
+
cores where `OMP_NUM_THREADS` stays 1 for numpy and `LSB_DJOB_NUMPROC` counts the whole
|
|
710
|
+
job: cellmap-analyze's prologue exports its CPUs over its worker processes, at least 1,
|
|
711
|
+
so a worker with spare cores uses them (in a two-slot job, pools of four against one
|
|
712
|
+
halved an 8-chunk block's time, below) and a worker per slot stays at one. A handle opened on
|
|
713
|
+
the driver before the cluster starts is then sized in each worker by that worker's
|
|
714
|
+
share, as a pickle carries `threads=None`, not a number (Behaviour 11). A value that is
|
|
715
|
+
not a positive integer raises `ValueError` rather than being skipped: nothing else sets
|
|
716
|
+
this variable, so a value worldarray cannot use is a mistake to report. Its
|
|
717
|
+
`file_io_concurrency` limit is that or `file_io_floor`, by default
|
|
718
|
+
`context.FILE_IO_FLOOR` (8), whichever is larger (0.8; the argument 0.9, `1` keeping
|
|
719
|
+
file I/O at the budget, cellmap-analyze's choice until eight is measured on /nrs at
|
|
720
|
+
scale): file I/O threads wait on storage
|
|
721
|
+
rather than compute, so they take no core from the job, and one of them serialises every
|
|
722
|
+
chunk file of a block on NFS's round trips. From one decoding thread, eight 64^3 label
|
|
723
|
+
chunks written to NFS took 166 to 176 ms with one file I/O thread and 83 to 89 ms with
|
|
724
|
+
four to sixteen (zarr-python 2 took 102); an 8-chunk EM read from /nrs was the same
|
|
725
|
+
either way, decoding bound. The cap is the default because the process that passes
|
|
700
726
|
no context is the one nobody tuned: a quick dask script over `wa.open(path)` is how a
|
|
701
727
|
job came to start a thousand threads on twelve slots. One thread per pool slows one
|
|
702
728
|
task (a 256^3 block write took 2.1 s against 1.3 s uncapped) but not a job of many
|
|
@@ -707,13 +733,35 @@ this document and that one differ, fix one of them.
|
|
|
707
733
|
tensorstore's own sizes (decided 2026-10-23, over chunkmirage's objection, once the
|
|
708
734
|
watchdog had removed the hang risk; the argument is 0.6, at chunkmirage's request, so
|
|
709
735
|
its server keeps one watched context per cache budget for every store it opens
|
|
710
|
-
instead of building its own). Each
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
736
|
+
instead of building its own). Each set of arguments is one context for the process,
|
|
737
|
+
made on first use and watched; `threads=None` and the number `thread_budget()` returns
|
|
738
|
+
are two contexts, as a pickle carries `None` for the other process to size by its own
|
|
739
|
+
job and a number as that number. `file_io_floor` sizes only the budget's pool, so with
|
|
740
|
+
a thread count of its own it is ignored and no second context is made.
|
|
741
|
+
|
|
742
|
+
Writes (0.8). Every context the package makes (`shared_context`, `capped_context`)
|
|
743
|
+
sets tensorstore's `file_io_sync` to false and its `file_io_locking` mode to `"none"`,
|
|
744
|
+
the arguments `fsync=True` and `locking="os"` restoring tensorstore's defaults. fsync
|
|
745
|
+
cost 6.5 ms a chunk on a local NVMe disk (8.2 against 1.5 ms for a 64^3 chunk) and
|
|
746
|
+
nothing on NFS, whose client commits a file when it closes it; zarr-python and funlib,
|
|
747
|
+
every writer the repos used before, never synced, so a machine crash on a local disk
|
|
748
|
+
can lose the last seconds of chunks as it always could, and a killed process loses
|
|
749
|
+
nothing. `"os"` locking takes an advisory lock on a `.__lock` file for every write,
|
|
750
|
+
about 10 ms of NFS round trips a chunk (21.7 against 12.0 ms for a 64^3 label chunk on
|
|
751
|
+
/groups; zarr-python 2 took 12.7). With `"none"` a whole-unit write is still atomic, a
|
|
752
|
+
temporary file renamed into place, and only concurrent read-modify-writes of one unit
|
|
753
|
+
lose their guarantee, which Behaviour 13 never gave (`partial=True` is unsafe while
|
|
754
|
+
anything else writes the unit); a write interrupted mid-way may leave a temporary
|
|
755
|
+
`.__lock` file that costs space only. tensorstore's `"lockfile"` mode is not the
|
|
756
|
+
default because such a file left by a killed worker blocks every later write of its
|
|
757
|
+
unit. Together, from one-thread workers (`OMP_NUM_THREADS=1`) through the default
|
|
758
|
+
context: a 64^3 label chunk to NFS took 23 ms in 0.7 and 12.2 in 0.8, an 8-chunk label
|
|
759
|
+
block 159 and 66.5, an 8-chunk EM block 330 and 202; to a local disk the label block
|
|
760
|
+
took 51.5 and 4.1 ms.
|
|
714
761
|
`capped_context` sets `data_copy_concurrency`, `file_io_concurrency`,
|
|
715
762
|
`gcs_request_concurrency`, `s3_request_concurrency` and `http_request_concurrency` to
|
|
716
|
-
`concurrency`
|
|
763
|
+
`concurrency` (`file_io_concurrency` to `file_io` when given, 0.8) and the cache to
|
|
764
|
+
`cache_bytes`, and returns a new context each call, so two
|
|
717
765
|
handles never share one capped pool unless the caller passes the same object twice; a
|
|
718
766
|
`concurrency` of 1 is safe under Behaviour 6's watchdog, but a read or write spanning
|
|
719
767
|
several chunks then handles them one at a time. On /nrs, from one process alternating
|
|
@@ -1035,7 +1083,14 @@ context's own pools; `thread_budget` from each variable, a bad value skipped, an
|
|
|
1035
1083
|
shared context's limits. 0.6: `shared_context(threads=)` at `None`, `N` and `0`, one
|
|
1036
1084
|
context per pair, a negative count refused, and a pickled handle coming back on the same
|
|
1037
1085
|
shared context. 0.7: `metadata`, `open` and `detect` against a server
|
|
1038
|
-
that accepts and never answers raising `TimeoutError` after the attempts.
|
|
1086
|
+
that accepts and never answers raising `TimeoutError` after the attempts. 0.8: the default
|
|
1087
|
+
and capped contexts' file I/O limit, sync and locking, `fsync` and `locking` carried
|
|
1088
|
+
through a pickle, and a create whose first attempt lands after its timeout adopting its own
|
|
1089
|
+
array under `exists="error"`. 0.9: `WORLDARRAY_THREADS` ahead of every other variable,
|
|
1090
|
+
blank as unset and a bad value refused; `file_io_floor` sizing file I/O under the budget,
|
|
1091
|
+
ignored with a thread count, and carried through a pickle; a handle pickled on the driver
|
|
1092
|
+
sized in a spawned worker by that worker's `WORLDARRAY_THREADS`.
|
|
1093
|
+
Opt-in (`WORLDARRAY_STRESS=1`, about a minute,
|
|
1039
1094
|
`tests/test_lost_wakeup.py`): worker processes aim tasks at the moment a one-thread pool's
|
|
1040
1095
|
worker exits idle, so a plain context strands some for good (13 of about 7,000 on
|
|
1041
1096
|
tensorstore 0.1.78; 40 to 44 of 12,000 to 15,000 with 16 workers on 0.1.75 and 0.1.85)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "worldarray"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.9.0"
|
|
4
4
|
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."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { file = "LICENSE" }
|
|
@@ -31,7 +31,7 @@ classifiers = [
|
|
|
31
31
|
# no dask, no funlib (docs/spec.md, section 1).
|
|
32
32
|
dependencies = [
|
|
33
33
|
"numpy>=1.26",
|
|
34
|
-
"tensorstore>=0.1.
|
|
34
|
+
"tensorstore>=0.1.68",
|
|
35
35
|
]
|
|
36
36
|
|
|
37
37
|
[project.urls]
|
|
@@ -4,11 +4,14 @@
|
|
|
4
4
|
thread limit for the whole process, so the levels of a pyramid and repeat opens of an
|
|
5
5
|
array draw on one cache pool. The default budget is 0: tensorstore's decoded-chunk cache
|
|
6
6
|
is off, because every consumer either reads each region once or caches decoded chunks
|
|
7
|
-
itself. By default its decoding
|
|
8
|
-
|
|
9
|
-
the node
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
itself. By default its decoding pool is sized by ``thread_budget()``, from the variables a
|
|
8
|
+
cluster job sets (``WORLDARRAY_THREADS`` first, a job script's share for each worker), where
|
|
9
|
+
tensorstore alone would size it to every CPU on the node, and its file I/O pool to at least
|
|
10
|
+
``file_io_floor`` threads (``FILE_IO_FLOOR``), which wait on storage rather than compute;
|
|
11
|
+
``threads=0`` leaves both at tensorstore's sizes (chunkmirage's server). Every
|
|
12
|
+
context made here writes without fsync and without OS file locks (``write_settings``).
|
|
13
|
+
Every context here, and every one a store is opened on, is watched for tensorstore's lost
|
|
14
|
+
wakeup (``watch``).
|
|
12
15
|
|
|
13
16
|
``capped_context`` is mesh-n-bone's worker-process context: every tensorstore concurrency
|
|
14
17
|
resource limited, so dozens of worker processes on one host do not multiply into thousands
|
|
@@ -28,7 +31,7 @@ from typing import Any
|
|
|
28
31
|
|
|
29
32
|
import tensorstore as ts
|
|
30
33
|
|
|
31
|
-
_shared: dict[tuple[int, int | None], ts.Context] = {}
|
|
34
|
+
_shared: dict[tuple[int, int | None, bool, str, int], ts.Context] = {}
|
|
32
35
|
_rebuilt: dict[str, ts.Context] = {}
|
|
33
36
|
_lock = threading.Lock()
|
|
34
37
|
|
|
@@ -117,14 +120,26 @@ if hasattr(os, "register_at_fork"):
|
|
|
117
120
|
os.register_at_fork(after_in_child=_forget_watchdog)
|
|
118
121
|
|
|
119
122
|
|
|
123
|
+
THREADS_VAR = "WORLDARRAY_THREADS"
|
|
120
124
|
_SLOTS = ("OMP_NUM_THREADS", "LSB_DJOB_NUMPROC", "SLURM_CPUS_PER_TASK", "NSLOTS")
|
|
121
125
|
|
|
122
126
|
|
|
123
127
|
def thread_budget() -> int:
|
|
124
|
-
"""Threads for this process's tensorstore decoding and file I/O: ``
|
|
128
|
+
"""Threads for this process's tensorstore decoding and file I/O: ``WORLDARRAY_THREADS``
|
|
129
|
+
when set, a job script's share for each worker where ``OMP_NUM_THREADS`` must stay 1 for
|
|
130
|
+
numpy (a positive integer, anything else a ``ValueError``), else ``OMP_NUM_THREADS``
|
|
125
131
|
when set (dask sets 1 in every worker it spawns; a job script sets its slots), else the
|
|
126
132
|
job's slots (LSF's ``LSB_DJOB_NUMPROC``, SLURM's ``SLURM_CPUS_PER_TASK``, SGE's
|
|
127
133
|
``NSLOTS``), else the CPUs this process may run on."""
|
|
134
|
+
own = os.environ.get(THREADS_VAR, "").strip()
|
|
135
|
+
if own:
|
|
136
|
+
try:
|
|
137
|
+
n = int(own)
|
|
138
|
+
except ValueError:
|
|
139
|
+
n = 0
|
|
140
|
+
if n < 1:
|
|
141
|
+
raise ValueError(f"{THREADS_VAR} must be a positive integer, not {own!r}")
|
|
142
|
+
return n
|
|
128
143
|
for name in _SLOTS:
|
|
129
144
|
try:
|
|
130
145
|
n = int(os.environ.get(name, ""))
|
|
@@ -138,38 +153,91 @@ def thread_budget() -> int:
|
|
|
138
153
|
return os.cpu_count() or 1
|
|
139
154
|
|
|
140
155
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
156
|
+
# File I/O threads mostly wait on storage, so they are not capped like decoding: on NFS,
|
|
157
|
+
# eight 64^3 label chunks written from one decoding thread took 166-176 ms with one file I/O
|
|
158
|
+
# thread and 83-89 ms with four to sixteen (2026-10-23; zarr-python 2 took 102)
|
|
159
|
+
FILE_IO_FLOOR = 8
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def write_settings(fsync: bool = False, locking: str = "none") -> dict:
|
|
163
|
+
"""tensorstore's ``file_io_sync`` and ``file_io_locking`` for a context made here. fsync
|
|
164
|
+
is off by default: zarr-python never synced, NFS commits a file on close anyway, and on
|
|
165
|
+
a local disk it cost 6 ms a chunk. Locking is ``"none"`` by default: a whole-unit write
|
|
166
|
+
stays atomic (a temporary file renamed into place), only concurrent read-modify-writes of
|
|
167
|
+
one unit lose the guarantee, which Behaviour 13 never gave, and tensorstore's ``"os"``
|
|
168
|
+
locks cost about 10 ms a chunk on NFS (spec, Behaviour 7)."""
|
|
169
|
+
return {"file_io_sync": bool(fsync), "file_io_locking": {"mode": str(locking)}}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def shared_context(
|
|
173
|
+
cache_bytes: int = 0,
|
|
174
|
+
*,
|
|
175
|
+
threads: int | None = None,
|
|
176
|
+
fsync: bool = False,
|
|
177
|
+
locking: str = "none",
|
|
178
|
+
file_io_floor: int = FILE_IO_FLOOR,
|
|
179
|
+
) -> ts.Context:
|
|
180
|
+
"""The process's one context for these arguments, made on first use and watched.
|
|
181
|
+
``threads`` limits its decoding and file I/O pools: ``None`` decoding to
|
|
182
|
+
``thread_budget()``, read when the context is made, and file I/O to that or
|
|
183
|
+
``file_io_floor``, whichever is larger (``1`` keeps file I/O at the budget), a positive
|
|
184
|
+
number both to that many, ``0`` neither (tensorstore's own sizes, every CPU on the
|
|
185
|
+
node); ``file_io_floor`` applies to ``threads=None`` alone. ``fsync`` and ``locking`` go
|
|
186
|
+
to ``write_settings``."""
|
|
187
|
+
if threads is not None and int(threads) < 0:
|
|
148
188
|
raise ValueError(f"threads must be None, 0 or positive, not {threads!r}")
|
|
189
|
+
if int(file_io_floor) < 0:
|
|
190
|
+
raise ValueError(f"file_io_floor must be 0 or positive, not {file_io_floor!r}")
|
|
191
|
+
floor = max(int(file_io_floor), 1) if threads is None else FILE_IO_FLOOR
|
|
192
|
+
key = (
|
|
193
|
+
int(cache_bytes),
|
|
194
|
+
None if threads is None else int(threads),
|
|
195
|
+
bool(fsync),
|
|
196
|
+
str(locking),
|
|
197
|
+
floor,
|
|
198
|
+
)
|
|
149
199
|
with _lock:
|
|
150
200
|
ctx = _shared.get(key)
|
|
151
201
|
if ctx is None:
|
|
152
|
-
spec: dict = {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
202
|
+
spec: dict = {
|
|
203
|
+
"cache_pool": {"total_bytes_limit": key[0]},
|
|
204
|
+
**write_settings(fsync, locking),
|
|
205
|
+
}
|
|
206
|
+
if threads is None:
|
|
207
|
+
budget = thread_budget()
|
|
208
|
+
spec["data_copy_concurrency"] = {"limit": budget}
|
|
209
|
+
spec["file_io_concurrency"] = {"limit": max(budget, floor)}
|
|
210
|
+
elif key[1]:
|
|
211
|
+
spec["data_copy_concurrency"] = spec["file_io_concurrency"] = {"limit": key[1]}
|
|
156
212
|
ctx = watch(ts.Context(spec))
|
|
157
213
|
_shared[key] = ctx
|
|
158
214
|
return ctx
|
|
159
215
|
|
|
160
216
|
|
|
161
|
-
def capped_context_spec(
|
|
217
|
+
def capped_context_spec(
|
|
218
|
+
cache_bytes: int = 0,
|
|
219
|
+
concurrency: int = 1,
|
|
220
|
+
network: bool = True,
|
|
221
|
+
*,
|
|
222
|
+
file_io: int | None = None,
|
|
223
|
+
fsync: bool = False,
|
|
224
|
+
locking: str = "none",
|
|
225
|
+
) -> dict:
|
|
162
226
|
"""The spec dict behind ``capped_context``, for callers building their own opens.
|
|
163
227
|
``network=False`` caps only decoding and local file I/O, leaving tensorstore's request
|
|
164
228
|
pools alone (cellmap-analyze's choice for remote reads). A ``concurrency`` of 1 is safe:
|
|
165
229
|
the context is watched (see ``KICK_EVERY``). It is not free: a read or write spanning
|
|
166
230
|
several chunks then decodes and stores them one at a time, half the speed of 4 on
|
|
167
|
-
8-chunk blocks in a two-slot job, 7 to 19% slower in a one-slot one (spec, Behaviour 7).
|
|
231
|
+
8-chunk blocks in a two-slot job, 7 to 19% slower in a one-slot one (spec, Behaviour 7).
|
|
232
|
+
``file_io`` sizes the file I/O pool apart from decoding (default ``concurrency``; on NFS
|
|
233
|
+
a few file I/O threads halve the time of a multi-chunk write, ``FILE_IO_FLOOR``).
|
|
234
|
+
``fsync`` and ``locking`` go to ``write_settings``."""
|
|
168
235
|
limit = {"limit": int(concurrency)}
|
|
169
236
|
spec = {
|
|
170
237
|
"data_copy_concurrency": limit,
|
|
171
|
-
"file_io_concurrency": limit,
|
|
238
|
+
"file_io_concurrency": {"limit": int(file_io if file_io is not None else concurrency)},
|
|
172
239
|
"cache_pool": {"total_bytes_limit": int(cache_bytes)},
|
|
240
|
+
**write_settings(fsync, locking),
|
|
173
241
|
}
|
|
174
242
|
if network:
|
|
175
243
|
spec.update(
|
|
@@ -180,20 +248,35 @@ def capped_context_spec(cache_bytes: int = 0, concurrency: int = 1, network: boo
|
|
|
180
248
|
return spec
|
|
181
249
|
|
|
182
250
|
|
|
183
|
-
def capped_context(
|
|
184
|
-
|
|
251
|
+
def capped_context(
|
|
252
|
+
cache_bytes: int = 0,
|
|
253
|
+
concurrency: int = 1,
|
|
254
|
+
network: bool = True,
|
|
255
|
+
*,
|
|
256
|
+
file_io: int | None = None,
|
|
257
|
+
fsync: bool = False,
|
|
258
|
+
locking: str = "none",
|
|
259
|
+
) -> ts.Context:
|
|
260
|
+
spec = capped_context_spec(
|
|
261
|
+
cache_bytes, concurrency, network, file_io=file_io, fsync=fsync, locking=locking
|
|
262
|
+
)
|
|
263
|
+
return watch(ts.Context(spec))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# shared_context's key with every argument at its default
|
|
267
|
+
_SHARED_DEFAULTS = (0, None, False, "none", FILE_IO_FLOOR)
|
|
185
268
|
|
|
186
269
|
|
|
187
270
|
def recipe_of(context: ts.Context) -> tuple[str, Any]:
|
|
188
271
|
"""How another process rebuilds ``context``: ``("shared", cache_bytes)`` for a shared
|
|
189
|
-
context at
|
|
190
|
-
``("shared", (cache_bytes, threads
|
|
191
|
-
``("spec", json)`` from the context's own spec, so a capped context
|
|
192
|
-
a pickle (Behaviour 11, mesh-n-bone's workers)."""
|
|
272
|
+
context with every other argument at its default (the other process takes its own
|
|
273
|
+
``thread_budget()``), ``("shared", (cache_bytes, threads, fsync, locking, file_io_floor))``
|
|
274
|
+
otherwise, else ``("spec", json)`` from the context's own spec, so a capped context
|
|
275
|
+
stays capped across a pickle (Behaviour 11, mesh-n-bone's workers)."""
|
|
193
276
|
with _lock:
|
|
194
|
-
for
|
|
277
|
+
for key, ctx in _shared.items():
|
|
195
278
|
if ctx is context:
|
|
196
|
-
return ("shared",
|
|
279
|
+
return ("shared", key[0] if key[1:] == _SHARED_DEFAULTS[1:] else key)
|
|
197
280
|
spec = context.spec
|
|
198
281
|
return ("spec", json.dumps(spec.to_json() if spec is not None else {}, sort_keys=True))
|
|
199
282
|
|
|
@@ -205,8 +288,14 @@ def from_recipe(recipe: tuple[str, Any] | None) -> ts.Context | None:
|
|
|
205
288
|
return None
|
|
206
289
|
kind, value = recipe
|
|
207
290
|
if kind == "shared":
|
|
208
|
-
|
|
209
|
-
|
|
291
|
+
given = tuple(value) if isinstance(value, (tuple, list)) else (value,)
|
|
292
|
+
cache_bytes, threads, fsync, locking, floor = (
|
|
293
|
+
*given,
|
|
294
|
+
*_SHARED_DEFAULTS[len(given) :],
|
|
295
|
+
)[:5] # a 0.6 or 0.7 pickle carries two, a 0.8 one four
|
|
296
|
+
return shared_context(
|
|
297
|
+
int(cache_bytes), threads=threads, fsync=fsync, locking=locking, file_io_floor=floor
|
|
298
|
+
)
|
|
210
299
|
with _lock:
|
|
211
300
|
ctx = _rebuilt.get(value)
|
|
212
301
|
if ctx is None:
|
|
@@ -34,6 +34,7 @@ from .store import (
|
|
|
34
34
|
_DRIVER,
|
|
35
35
|
Location,
|
|
36
36
|
Node,
|
|
37
|
+
_wait,
|
|
37
38
|
basename,
|
|
38
39
|
detect,
|
|
39
40
|
is_refused,
|
|
@@ -753,6 +754,11 @@ def create_array(
|
|
|
753
754
|
# the group documents first (Behaviour 11), then the array
|
|
754
755
|
ensure_group_entry(group_path, name, vs, tr, axes, units, axis_types, group_attrs, format=fmt)
|
|
755
756
|
|
|
757
|
+
# create-if-absent is a conditional write, atomic only under tensorstore's OS locks,
|
|
758
|
+
# whatever locking the caller's context chose for its data (write_settings)
|
|
759
|
+
creating = watch(ts.Context(ts.Context.Spec({"file_io_locking": {"mode": "os"}}), context))
|
|
760
|
+
starts = 0
|
|
761
|
+
|
|
756
762
|
def attempt(kvstore: dict) -> ts.TensorStore:
|
|
757
763
|
spec = array_spec(
|
|
758
764
|
kvstore,
|
|
@@ -768,16 +774,26 @@ def create_array(
|
|
|
768
774
|
separator,
|
|
769
775
|
store_fill,
|
|
770
776
|
)
|
|
771
|
-
|
|
777
|
+
|
|
778
|
+
def start():
|
|
779
|
+
nonlocal starts
|
|
780
|
+
starts += 1
|
|
781
|
+
return ts.open(spec, create=True, open=False, context=creating)
|
|
782
|
+
|
|
783
|
+
return _wait(start, kvstore, path, "create")
|
|
772
784
|
|
|
773
785
|
try:
|
|
774
786
|
with_access(kvstore_specs(path), attempt)
|
|
775
787
|
except Exception as e: # noqa: BLE001
|
|
776
788
|
if "ALREADY_EXISTS" not in str(e) or is_refused(e):
|
|
777
789
|
raise
|
|
778
|
-
if exists == "error":
|
|
790
|
+
if exists == "error" and starts == 1:
|
|
779
791
|
raise FileExistsError(f"{path} was created by another process meanwhile") from e
|
|
792
|
+
# another process made it, or an attempt of ours that timed out did: the caller
|
|
793
|
+
# adopts it only if it matches what was asked for (Behaviour 6)
|
|
780
794
|
_wait_for(path)
|
|
795
|
+
if starts > 1 and fmt == "zarr2" and attrs and Location(path).read(".zattrs") is None:
|
|
796
|
+
Location(path).write_json(".zattrs", dict(attrs))
|
|
781
797
|
return path, fmt, True
|
|
782
798
|
if fmt == "zarr2" and attrs:
|
|
783
799
|
Location(path).write_json(".zattrs", dict(attrs))
|
|
@@ -137,6 +137,7 @@ def test_contexts_and_retry_timeouts():
|
|
|
137
137
|
|
|
138
138
|
|
|
139
139
|
def test_the_thread_budget_follows_the_job(monkeypatch):
|
|
140
|
+
monkeypatch.delenv("WORLDARRAY_THREADS", raising=False)
|
|
140
141
|
monkeypatch.delenv("OMP_NUM_THREADS", raising=False)
|
|
141
142
|
monkeypatch.delenv("LSB_DJOB_NUMPROC", raising=False)
|
|
142
143
|
allowed = len(os.sched_getaffinity(0)) if hasattr(os, "sched_getaffinity") else os.cpu_count()
|
|
@@ -154,10 +155,26 @@ def test_the_thread_budget_follows_the_job(monkeypatch):
|
|
|
154
155
|
assert wa.thread_budget() == 6
|
|
155
156
|
monkeypatch.setattr(wa.context, "_shared", {})
|
|
156
157
|
spec = wa.shared_context(5).spec.to_json()
|
|
157
|
-
assert spec["data_copy_concurrency"] ==
|
|
158
|
+
assert spec["data_copy_concurrency"] == {"limit": 6}
|
|
159
|
+
assert spec["file_io_concurrency"] == {
|
|
160
|
+
"limit": wa.context.FILE_IO_FLOOR
|
|
161
|
+
} # it waits, not computes
|
|
158
162
|
assert spec["cache_pool"] == {"total_bytes_limit": 5}
|
|
159
163
|
|
|
160
164
|
|
|
165
|
+
def test_a_job_script_gives_each_worker_its_threads(monkeypatch):
|
|
166
|
+
monkeypatch.setenv("OMP_NUM_THREADS", "1") # what dask and a job prologue set, for numpy
|
|
167
|
+
monkeypatch.setenv("LSB_DJOB_NUMPROC", "24") # the whole job's slots, not one worker's
|
|
168
|
+
monkeypatch.setenv("WORLDARRAY_THREADS", "3") # the job's cpus over its worker processes
|
|
169
|
+
assert wa.thread_budget() == 3
|
|
170
|
+
monkeypatch.setenv("WORLDARRAY_THREADS", " ")
|
|
171
|
+
assert wa.thread_budget() == 1 # empty is unset
|
|
172
|
+
for bad in ("0", "-2", "x", "2.5"): # worldarray's own variable: a bad value is an error
|
|
173
|
+
monkeypatch.setenv("WORLDARRAY_THREADS", bad)
|
|
174
|
+
with pytest.raises(ValueError, match="WORLDARRAY_THREADS"):
|
|
175
|
+
wa.thread_budget()
|
|
176
|
+
|
|
177
|
+
|
|
161
178
|
def test_a_kick_runs_on_the_contexts_own_pools():
|
|
162
179
|
ctx = wa.capped_context(concurrency=1)
|
|
163
180
|
futures = wa.context.kick(ctx)
|
|
@@ -323,10 +340,15 @@ def test_gce_guard_is_left_alone_on_a_google_machine(monkeypatch):
|
|
|
323
340
|
|
|
324
341
|
|
|
325
342
|
def test_a_shared_context_takes_the_callers_thread_count(monkeypatch):
|
|
343
|
+
monkeypatch.delenv("WORLDARRAY_THREADS", raising=False)
|
|
326
344
|
monkeypatch.setenv("OMP_NUM_THREADS", "1")
|
|
327
345
|
monkeypatch.setattr(wa.context, "_shared", {})
|
|
328
346
|
default = wa.shared_context(7)
|
|
329
|
-
|
|
347
|
+
spec = default.spec.to_json()
|
|
348
|
+
assert spec["data_copy_concurrency"] == {"limit": 1} and spec["file_io_concurrency"] == {
|
|
349
|
+
"limit": 8
|
|
350
|
+
}
|
|
351
|
+
assert spec["file_io_sync"] is False and spec["file_io_locking"] == {"mode": "none"}
|
|
330
352
|
four = wa.shared_context(7, threads=4).spec.to_json()
|
|
331
353
|
assert four["data_copy_concurrency"] == four["file_io_concurrency"] == {"limit": 4}
|
|
332
354
|
machine = wa.shared_context(7, threads=0)
|
|
@@ -335,13 +357,42 @@ def test_a_shared_context_takes_the_callers_thread_count(monkeypatch):
|
|
|
335
357
|
assert "data_copy_concurrency" not in spec and "file_io_concurrency" not in spec
|
|
336
358
|
assert spec["cache_pool"] == {"total_bytes_limit": 7}
|
|
337
359
|
assert machine in wa.context._watched
|
|
338
|
-
assert wa.context.recipe_of(machine) == ("shared", (7, 0))
|
|
339
|
-
assert wa.context.from_recipe(("shared", (7, 0))) is machine
|
|
360
|
+
assert wa.context.recipe_of(machine) == ("shared", (7, 0, False, "none", 8))
|
|
361
|
+
assert wa.context.from_recipe(("shared", (7, 0))) is machine # a 0.6 or 0.7 pickle
|
|
362
|
+
durable = wa.shared_context(7, fsync=True, locking="os")
|
|
363
|
+
spec = durable.spec.to_json()
|
|
364
|
+
assert durable is not default and spec["file_io_sync"] is True
|
|
365
|
+
assert spec["file_io_locking"] == {} # "os", tensorstore's default, which its spec leaves out
|
|
366
|
+
assert wa.context.from_recipe(wa.context.recipe_of(durable)) is durable
|
|
340
367
|
assert wa.context.from_recipe(("shared", 7)) is default
|
|
341
368
|
with pytest.raises(ValueError):
|
|
342
369
|
wa.shared_context(threads=-1)
|
|
343
370
|
|
|
344
371
|
|
|
372
|
+
def test_file_io_can_follow_the_thread_budget(monkeypatch):
|
|
373
|
+
monkeypatch.setenv("WORLDARRAY_THREADS", "3")
|
|
374
|
+
monkeypatch.setattr(wa.context, "_shared", {})
|
|
375
|
+
default = wa.shared_context(0)
|
|
376
|
+
assert default.spec.to_json()["file_io_concurrency"] == {"limit": 8}
|
|
377
|
+
level = wa.shared_context(0, file_io_floor=1)
|
|
378
|
+
spec = level.spec.to_json()
|
|
379
|
+
assert spec["data_copy_concurrency"] == spec["file_io_concurrency"] == {"limit": 3}
|
|
380
|
+
assert level is not default and wa.shared_context(0, file_io_floor=0) is level
|
|
381
|
+
assert wa.shared_context(0, file_io_floor=wa.context.FILE_IO_FLOOR) is default
|
|
382
|
+
wide = wa.shared_context(0, file_io_floor=16).spec.to_json()
|
|
383
|
+
assert wide["data_copy_concurrency"] == {"limit": 3}
|
|
384
|
+
assert wide["file_io_concurrency"] == {"limit": 16}
|
|
385
|
+
# the floor sizes the budget's pools alone: a thread count of its own ignores it
|
|
386
|
+
assert wa.shared_context(0, threads=4, file_io_floor=1) is wa.shared_context(0, threads=4)
|
|
387
|
+
assert wa.shared_context(0, threads=0, file_io_floor=1) is wa.shared_context(0, threads=0)
|
|
388
|
+
assert wa.context.recipe_of(level) == ("shared", (0, None, False, "none", 1))
|
|
389
|
+
assert wa.context.from_recipe(wa.context.recipe_of(level)) is level
|
|
390
|
+
assert wa.context.recipe_of(default) == ("shared", 0)
|
|
391
|
+
assert wa.context.from_recipe(("shared", (0, None, False, "none"))) is default # 0.8
|
|
392
|
+
with pytest.raises(ValueError):
|
|
393
|
+
wa.shared_context(file_io_floor=-1)
|
|
394
|
+
|
|
395
|
+
|
|
345
396
|
@pytest.fixture
|
|
346
397
|
def silent_server():
|
|
347
398
|
"""An http address whose server accepts every connection and never answers."""
|
|
@@ -399,3 +450,16 @@ def test_an_http_open_probes_its_keys_at_once(tmp_path):
|
|
|
399
450
|
assert time.monotonic() - start < 1.0 # in turn: 1.5 s
|
|
400
451
|
finally:
|
|
401
452
|
server.shutdown()
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def test_capped_contexts_size_file_io_apart_and_write_without_sync_or_locks():
|
|
456
|
+
spec = wa.capped_context_spec(concurrency=1, network=False)
|
|
457
|
+
assert spec["data_copy_concurrency"] == spec["file_io_concurrency"] == {"limit": 1}
|
|
458
|
+
assert spec["file_io_sync"] is False and spec["file_io_locking"] == {"mode": "none"}
|
|
459
|
+
spec = wa.capped_context_spec(concurrency=1, file_io=8, fsync=True, locking="os")
|
|
460
|
+
assert spec["file_io_concurrency"] == {"limit": 8} and spec["data_copy_concurrency"] == {
|
|
461
|
+
"limit": 1
|
|
462
|
+
}
|
|
463
|
+
assert spec["file_io_sync"] is True and spec["file_io_locking"] == {"mode": "os"} # as written
|
|
464
|
+
capped = wa.capped_context(concurrency=1, file_io=8)
|
|
465
|
+
assert capped.spec.to_json()["file_io_concurrency"] == {"limit": 8}
|
|
@@ -830,6 +830,23 @@ def test_a_pickled_handle_keeps_its_context(tmp_path):
|
|
|
830
830
|
assert pickle.loads(pickle.dumps(wa.open(path, context=machine))).context is machine
|
|
831
831
|
|
|
832
832
|
|
|
833
|
+
def _worker_limits(handle):
|
|
834
|
+
spec = handle.context.spec.to_json()
|
|
835
|
+
return spec["data_copy_concurrency"]["limit"], spec["file_io_concurrency"]["limit"]
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def test_a_pickled_handle_takes_each_workers_thread_share(tmp_path, monkeypatch):
|
|
839
|
+
# a handle opened before the cluster starts is sized in each worker by that worker's job
|
|
840
|
+
# script, which exports WORLDARRAY_THREADS where OMP_NUM_THREADS stays 1 (cellmap-analyze)
|
|
841
|
+
path = str(tmp_path / "vol.zarr/s0")
|
|
842
|
+
wa.create(path, (8, 8, 8), "uint16", (4, 4, 4), (2, 2, 2), chunks=(4, 4, 4))
|
|
843
|
+
handles = [wa.open(path, context=wa.shared_context(0, file_io_floor=1)), wa.open(path)]
|
|
844
|
+
monkeypatch.setenv("OMP_NUM_THREADS", "1")
|
|
845
|
+
monkeypatch.setenv("WORLDARRAY_THREADS", "3")
|
|
846
|
+
with mp.get_context("spawn").Pool(1) as pool:
|
|
847
|
+
assert pool.map(_worker_limits, handles) == [(3, 3), (3, 8)]
|
|
848
|
+
|
|
849
|
+
|
|
833
850
|
def test_codecs_of_a_path_and_the_fill_write_that_removes_a_key(tmp_path):
|
|
834
851
|
path = str(tmp_path / "vol.zarr/s0")
|
|
835
852
|
vol = wa.create(
|
|
@@ -849,3 +866,33 @@ def test_codecs_of_a_path_and_the_fill_write_that_removes_a_key(tmp_path):
|
|
|
849
866
|
vol.write_index((0, 0, 0), (4, 4, 4), np.zeros((4, 4, 4), np.uint8))
|
|
850
867
|
assert vol.list_chunks() == [] # the one key a write may remove: its own, now all fill
|
|
851
868
|
assert int(vol.read_index((0, 0, 0), (4, 4, 4)).sum()) == 0
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
class _NeverAnswers:
|
|
872
|
+
def result(self, timeout=None):
|
|
873
|
+
raise TimeoutError()
|
|
874
|
+
|
|
875
|
+
def cancel(self):
|
|
876
|
+
pass
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def test_a_create_that_lands_after_its_timeout_adopts_its_own_array(tmp_path, monkeypatch):
|
|
880
|
+
real_open = ts.open
|
|
881
|
+
creates = []
|
|
882
|
+
|
|
883
|
+
def open_landing_late(spec, *args, **kwargs):
|
|
884
|
+
if kwargs.get("create"):
|
|
885
|
+
creates.append(1)
|
|
886
|
+
if len(creates) == 1: # the array is made, but the answer never comes
|
|
887
|
+
real_open(spec, *args, **kwargs).result()
|
|
888
|
+
return _NeverAnswers()
|
|
889
|
+
return real_open(spec, *args, **kwargs)
|
|
890
|
+
|
|
891
|
+
monkeypatch.setattr(ts, "open", open_landing_late)
|
|
892
|
+
path = str(tmp_path / "vol.zarr/s0")
|
|
893
|
+
quick = wa.Retry(timeout=0.2, max_attempts=3, base_delay=0.0)
|
|
894
|
+
vol = wa.create(path, (8, 8, 8), "uint8", (4, 4, 4), (2, 2, 2), chunks=(4, 4, 4), retries=quick)
|
|
895
|
+
assert len(creates) == 2 and vol.shape == (8, 8, 8)
|
|
896
|
+
monkeypatch.setattr(ts, "open", real_open)
|
|
897
|
+
with pytest.raises(FileExistsError):
|
|
898
|
+
wa.create(path, (8, 8, 8), "uint8", (4, 4, 4), (2, 2, 2), chunks=(4, 4, 4))
|
|
@@ -1247,7 +1247,7 @@ wheels = [
|
|
|
1247
1247
|
|
|
1248
1248
|
[[package]]
|
|
1249
1249
|
name = "worldarray"
|
|
1250
|
-
version = "0.
|
|
1250
|
+
version = "0.9.0"
|
|
1251
1251
|
source = { editable = "." }
|
|
1252
1252
|
dependencies = [
|
|
1253
1253
|
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
|
@@ -1270,7 +1270,7 @@ docs = [
|
|
|
1270
1270
|
[package.metadata]
|
|
1271
1271
|
requires-dist = [
|
|
1272
1272
|
{ name = "numpy", specifier = ">=1.26" },
|
|
1273
|
-
{ name = "tensorstore", specifier = ">=0.1.
|
|
1273
|
+
{ name = "tensorstore", specifier = ">=0.1.68" },
|
|
1274
1274
|
]
|
|
1275
1275
|
|
|
1276
1276
|
[package.metadata.requires-dev]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|