worldarray 0.8.0__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.8.0 → worldarray-0.9.0}/CHANGELOG.md +16 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/PKG-INFO +11 -9
- {worldarray-0.8.0 → worldarray-0.9.0}/README.md +10 -8
- {worldarray-0.8.0 → worldarray-0.9.0}/docs/spec.md +33 -15
- {worldarray-0.8.0 → worldarray-0.9.0}/pyproject.toml +1 -1
- {worldarray-0.8.0 → worldarray-0.9.0}/src/worldarray/__init__.py +1 -1
- {worldarray-0.8.0 → worldarray-0.9.0}/src/worldarray/context.py +51 -20
- {worldarray-0.8.0 → worldarray-0.9.0}/tests/test_access.py +40 -1
- {worldarray-0.8.0 → worldarray-0.9.0}/tests/test_write.py +17 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/uv.lock +1 -1
- {worldarray-0.8.0 → worldarray-0.9.0}/.github/workflows/ci.yml +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/.github/workflows/docs.yml +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/.github/workflows/publish.yml +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/.gitignore +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/LICENSE +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/docs/changelog.md +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/docs/index.md +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/mkdocs.yml +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/src/worldarray/array.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/src/worldarray/geometry.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/src/worldarray/grid.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/src/worldarray/levels.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/src/worldarray/placement.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/src/worldarray/resample.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/src/worldarray/retry.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/src/worldarray/store.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/src/worldarray/units.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/src/worldarray/write.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/tests/conftest.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/tests/fixtures/metadata.json +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/tests/fixtures/write.json +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/tests/test_geometry.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/tests/test_grid.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/tests/test_lost_wakeup.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/tests/test_metadata.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/tests/test_read.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.0}/tests/test_resample.py +0 -0
|
@@ -1,5 +1,21 @@
|
|
|
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
|
+
|
|
3
19
|
## 0.8.0 (2026-10-23)
|
|
4
20
|
|
|
5
21
|
Faster writes from the contexts the package makes, and the create open timed out. No
|
|
@@ -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/
|
|
@@ -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,10 +210,11 @@ 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, fsync=False, locking="none") ; wa.Retry(...)
|
|
213
|
+
wa.shared_context(cache_bytes=0, threads=None, fsync=False, locking="none", file_io_floor=8) ; wa.Retry(...)
|
|
213
214
|
wa.capped_context(cache_bytes=0, concurrency=1, network=True, file_io=None, fsync=False, locking="none")
|
|
214
|
-
wa.thread_budget() # the shared context's decoding threads:
|
|
215
|
-
# file I/O gets at least 8;
|
|
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
|
|
216
218
|
# contexts made here write without fsync or OS file locks; fsync=True, locking="os" restore them
|
|
217
219
|
wa.list_chunk_keys(path, separator=".") # chunk indices from keys alone, no metadata needed
|
|
218
220
|
wa.Box ; wa.units.TABLE
|
|
@@ -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,10 +151,11 @@ 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, fsync=False, locking="none") ; wa.Retry(...)
|
|
154
|
+
wa.shared_context(cache_bytes=0, threads=None, fsync=False, locking="none", file_io_floor=8) ; wa.Retry(...)
|
|
154
155
|
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:
|
|
156
|
-
# file I/O gets at least 8;
|
|
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
|
|
157
159
|
# contexts made here write without fsync or OS file locks; fsync=True, locking="os" restore them
|
|
158
160
|
wa.list_chunk_keys(path, separator=".") # chunk indices from keys alone, no metadata needed
|
|
159
161
|
wa.Box ; wa.units.TABLE
|
|
@@ -185,11 +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, fsync=False, locking="none")
|
|
188
|
+
wa.shared_context(cache_bytes=0, threads=None, fsync=False, locking="none", file_io_floor=8)
|
|
189
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
|
|
191
|
-
# 0 = tensorstore's own pool sizes (0.6); fsync and locking:
|
|
192
|
-
# file_io_locking mode, off and "none" in every context the
|
|
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)
|
|
193
194
|
# network=False caps decoding and local file I/O only, leaving tensorstore's request pools alone
|
|
194
195
|
wa.list_chunk_keys(path, separator=".", ndim=None) # chunk indices present under a path, from its keys alone: no
|
|
195
196
|
# metadata document needed, so a directory of chunk files with no .zarray lists too
|
|
@@ -236,8 +237,8 @@ where it also chooses the integer scale factor that makes the corner whole.
|
|
|
236
237
|
`shared_context(cache_bytes, threads=None)` is one `tensorstore.Context` per budget and
|
|
237
238
|
thread limit for the process, so the levels of a pyramid and repeat opens share one cache
|
|
238
239
|
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
|
|
240
|
-
`0` leaving tensorstore's own sizes (0.6, Behaviour 7).
|
|
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).
|
|
241
242
|
`capped_context` is mesh-n-bone's worker-process context (every tensorstore concurrency
|
|
242
243
|
resource limited, cache off); it returns a **new** context per call, as those workers
|
|
243
244
|
inline the spec into every open today, and `capped_context_spec` exports the dict for
|
|
@@ -366,8 +367,8 @@ wa.create(..., attrs={...}, exists="adopt") # an adopt also compares the keys
|
|
|
366
367
|
vol.write_index(begin, end, data, partial=False, retries=None)
|
|
367
368
|
vol.write(box, data, partial=False, retries=None) ; vol.write_chunk(index, data, retries=None)
|
|
368
369
|
# timed out and retried as reads are: `retries`, else the handle's (Behaviour 6)
|
|
369
|
-
wa.thread_budget() -> int # threads for tensorstore's decoding and file I/O in this process:
|
|
370
|
-
# 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)
|
|
371
372
|
# every context the package makes or opens a store on is kicked once a second, whoever
|
|
372
373
|
# waits on it (Behaviour 6)
|
|
373
374
|
```
|
|
@@ -703,8 +704,20 @@ this document and that one differ, fix one of them.
|
|
|
703
704
|
workers in a twelve-slot job on a 96-core node could start 96 threads (0.5). A dask
|
|
704
705
|
worker therefore runs tensorstore on one thread of each pool, which Behaviour 6's
|
|
705
706
|
watchdog makes safe. SLURM's `SLURM_CPUS_PER_TASK` and SGE's `NSLOTS` stand in for
|
|
706
|
-
`LSB_DJOB_NUMPROC` elsewhere.
|
|
707
|
-
|
|
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
|
|
708
721
|
rather than compute, so they take no core from the job, and one of them serialises every
|
|
709
722
|
chunk file of a block on NFS's round trips. From one decoding thread, eight 64^3 label
|
|
710
723
|
chunks written to NFS took 166 to 176 ms with one file I/O thread and 83 to 89 ms with
|
|
@@ -720,10 +733,11 @@ this document and that one differ, fix one of them.
|
|
|
720
733
|
tensorstore's own sizes (decided 2026-10-23, over chunkmirage's objection, once the
|
|
721
734
|
watchdog had removed the hang risk; the argument is 0.6, at chunkmirage's request, so
|
|
722
735
|
its server keeps one watched context per cache budget for every store it opens
|
|
723
|
-
instead of building its own). Each
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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.
|
|
727
741
|
|
|
728
742
|
Writes (0.8). Every context the package makes (`shared_context`, `capped_context`)
|
|
729
743
|
sets tensorstore's `file_io_sync` to false and its `file_io_locking` mode to `"none"`,
|
|
@@ -1072,7 +1086,11 @@ shared context. 0.7: `metadata`, `open` and `detect` against a server
|
|
|
1072
1086
|
that accepts and never answers raising `TimeoutError` after the attempts. 0.8: the default
|
|
1073
1087
|
and capped contexts' file I/O limit, sync and locking, `fsync` and `locking` carried
|
|
1074
1088
|
through a pickle, and a create whose first attempt lands after its timeout adopting its own
|
|
1075
|
-
array under `exists="error"`.
|
|
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,
|
|
1076
1094
|
`tests/test_lost_wakeup.py`): worker processes aim tasks at the moment a one-thread pool's
|
|
1077
1095
|
worker exits idle, so a plain context strands some for good (13 of about 7,000 on
|
|
1078
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" }
|
|
@@ -5,9 +5,10 @@ thread limit for the whole process, so the levels of a pyramid and repeat opens
|
|
|
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
7
|
itself. By default its decoding pool is sized by ``thread_budget()``, from the variables a
|
|
8
|
-
cluster job sets
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
11
12
|
context made here writes without fsync and without OS file locks (``write_settings``).
|
|
12
13
|
Every context here, and every one a store is opened on, is watched for tensorstore's lost
|
|
13
14
|
wakeup (``watch``).
|
|
@@ -30,7 +31,7 @@ from typing import Any
|
|
|
30
31
|
|
|
31
32
|
import tensorstore as ts
|
|
32
33
|
|
|
33
|
-
_shared: dict[tuple[int, int | None, bool, str], ts.Context] = {}
|
|
34
|
+
_shared: dict[tuple[int, int | None, bool, str, int], ts.Context] = {}
|
|
34
35
|
_rebuilt: dict[str, ts.Context] = {}
|
|
35
36
|
_lock = threading.Lock()
|
|
36
37
|
|
|
@@ -119,14 +120,26 @@ if hasattr(os, "register_at_fork"):
|
|
|
119
120
|
os.register_at_fork(after_in_child=_forget_watchdog)
|
|
120
121
|
|
|
121
122
|
|
|
123
|
+
THREADS_VAR = "WORLDARRAY_THREADS"
|
|
122
124
|
_SLOTS = ("OMP_NUM_THREADS", "LSB_DJOB_NUMPROC", "SLURM_CPUS_PER_TASK", "NSLOTS")
|
|
123
125
|
|
|
124
126
|
|
|
125
127
|
def thread_budget() -> int:
|
|
126
|
-
"""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``
|
|
127
131
|
when set (dask sets 1 in every worker it spawns; a job script sets its slots), else the
|
|
128
132
|
job's slots (LSF's ``LSB_DJOB_NUMPROC``, SLURM's ``SLURM_CPUS_PER_TASK``, SGE's
|
|
129
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
|
|
130
143
|
for name in _SLOTS:
|
|
131
144
|
try:
|
|
132
145
|
n = int(os.environ.get(name, ""))
|
|
@@ -162,15 +175,27 @@ def shared_context(
|
|
|
162
175
|
threads: int | None = None,
|
|
163
176
|
fsync: bool = False,
|
|
164
177
|
locking: str = "none",
|
|
178
|
+
file_io_floor: int = FILE_IO_FLOOR,
|
|
165
179
|
) -> ts.Context:
|
|
166
180
|
"""The process's one context for these arguments, made on first use and watched.
|
|
167
181
|
``threads`` limits its decoding and file I/O pools: ``None`` decoding to
|
|
168
|
-
``thread_budget()
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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:
|
|
173
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
|
+
)
|
|
174
199
|
with _lock:
|
|
175
200
|
ctx = _shared.get(key)
|
|
176
201
|
if ctx is None:
|
|
@@ -181,7 +206,7 @@ def shared_context(
|
|
|
181
206
|
if threads is None:
|
|
182
207
|
budget = thread_budget()
|
|
183
208
|
spec["data_copy_concurrency"] = {"limit": budget}
|
|
184
|
-
spec["file_io_concurrency"] = {"limit": max(budget,
|
|
209
|
+
spec["file_io_concurrency"] = {"limit": max(budget, floor)}
|
|
185
210
|
elif key[1]:
|
|
186
211
|
spec["data_copy_concurrency"] = spec["file_io_concurrency"] = {"limit": key[1]}
|
|
187
212
|
ctx = watch(ts.Context(spec))
|
|
@@ -238,16 +263,20 @@ def capped_context(
|
|
|
238
263
|
return watch(ts.Context(spec))
|
|
239
264
|
|
|
240
265
|
|
|
266
|
+
# shared_context's key with every argument at its default
|
|
267
|
+
_SHARED_DEFAULTS = (0, None, False, "none", FILE_IO_FLOOR)
|
|
268
|
+
|
|
269
|
+
|
|
241
270
|
def recipe_of(context: ts.Context) -> tuple[str, Any]:
|
|
242
271
|
"""How another process rebuilds ``context``: ``("shared", cache_bytes)`` for a shared
|
|
243
272
|
context with every other argument at its default (the other process takes its own
|
|
244
|
-
``thread_budget()``), ``("shared", (cache_bytes, threads, fsync, locking))``
|
|
245
|
-
else ``("spec", json)`` from the context's own spec, so a capped context
|
|
246
|
-
across a pickle (Behaviour 11, mesh-n-bone's workers)."""
|
|
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)."""
|
|
247
276
|
with _lock:
|
|
248
277
|
for key, ctx in _shared.items():
|
|
249
278
|
if ctx is context:
|
|
250
|
-
return ("shared", key[0] if key[1:] ==
|
|
279
|
+
return ("shared", key[0] if key[1:] == _SHARED_DEFAULTS[1:] else key)
|
|
251
280
|
spec = context.spec
|
|
252
281
|
return ("spec", json.dumps(spec.to_json() if spec is not None else {}, sort_keys=True))
|
|
253
282
|
|
|
@@ -259,12 +288,14 @@ def from_recipe(recipe: tuple[str, Any] | None) -> ts.Context | None:
|
|
|
259
288
|
return None
|
|
260
289
|
kind, value = recipe
|
|
261
290
|
if kind == "shared":
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
266
298
|
)
|
|
267
|
-
return shared_context(int(cache_bytes), threads=threads, fsync=fsync, locking=locking)
|
|
268
299
|
with _lock:
|
|
269
300
|
ctx = _rebuilt.get(value)
|
|
270
301
|
if ctx is None:
|
|
@@ -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()
|
|
@@ -161,6 +162,19 @@ def test_the_thread_budget_follows_the_job(monkeypatch):
|
|
|
161
162
|
assert spec["cache_pool"] == {"total_bytes_limit": 5}
|
|
162
163
|
|
|
163
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
|
+
|
|
164
178
|
def test_a_kick_runs_on_the_contexts_own_pools():
|
|
165
179
|
ctx = wa.capped_context(concurrency=1)
|
|
166
180
|
futures = wa.context.kick(ctx)
|
|
@@ -326,6 +340,7 @@ def test_gce_guard_is_left_alone_on_a_google_machine(monkeypatch):
|
|
|
326
340
|
|
|
327
341
|
|
|
328
342
|
def test_a_shared_context_takes_the_callers_thread_count(monkeypatch):
|
|
343
|
+
monkeypatch.delenv("WORLDARRAY_THREADS", raising=False)
|
|
329
344
|
monkeypatch.setenv("OMP_NUM_THREADS", "1")
|
|
330
345
|
monkeypatch.setattr(wa.context, "_shared", {})
|
|
331
346
|
default = wa.shared_context(7)
|
|
@@ -342,7 +357,7 @@ def test_a_shared_context_takes_the_callers_thread_count(monkeypatch):
|
|
|
342
357
|
assert "data_copy_concurrency" not in spec and "file_io_concurrency" not in spec
|
|
343
358
|
assert spec["cache_pool"] == {"total_bytes_limit": 7}
|
|
344
359
|
assert machine in wa.context._watched
|
|
345
|
-
assert wa.context.recipe_of(machine) == ("shared", (7, 0, False, "none"))
|
|
360
|
+
assert wa.context.recipe_of(machine) == ("shared", (7, 0, False, "none", 8))
|
|
346
361
|
assert wa.context.from_recipe(("shared", (7, 0))) is machine # a 0.6 or 0.7 pickle
|
|
347
362
|
durable = wa.shared_context(7, fsync=True, locking="os")
|
|
348
363
|
spec = durable.spec.to_json()
|
|
@@ -354,6 +369,30 @@ def test_a_shared_context_takes_the_callers_thread_count(monkeypatch):
|
|
|
354
369
|
wa.shared_context(threads=-1)
|
|
355
370
|
|
|
356
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
|
+
|
|
357
396
|
@pytest.fixture
|
|
358
397
|
def silent_server():
|
|
359
398
|
"""An http address whose server accepts every connection and never answers."""
|
|
@@ -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(
|
|
@@ -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'" },
|
|
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
|
|
File without changes
|