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.
Files changed (37) hide show
  1. {worldarray-0.8.0 → worldarray-0.9.0}/CHANGELOG.md +16 -0
  2. {worldarray-0.8.0 → worldarray-0.9.0}/PKG-INFO +11 -9
  3. {worldarray-0.8.0 → worldarray-0.9.0}/README.md +10 -8
  4. {worldarray-0.8.0 → worldarray-0.9.0}/docs/spec.md +33 -15
  5. {worldarray-0.8.0 → worldarray-0.9.0}/pyproject.toml +1 -1
  6. {worldarray-0.8.0 → worldarray-0.9.0}/src/worldarray/__init__.py +1 -1
  7. {worldarray-0.8.0 → worldarray-0.9.0}/src/worldarray/context.py +51 -20
  8. {worldarray-0.8.0 → worldarray-0.9.0}/tests/test_access.py +40 -1
  9. {worldarray-0.8.0 → worldarray-0.9.0}/tests/test_write.py +17 -0
  10. {worldarray-0.8.0 → worldarray-0.9.0}/uv.lock +1 -1
  11. {worldarray-0.8.0 → worldarray-0.9.0}/.github/workflows/ci.yml +0 -0
  12. {worldarray-0.8.0 → worldarray-0.9.0}/.github/workflows/docs.yml +0 -0
  13. {worldarray-0.8.0 → worldarray-0.9.0}/.github/workflows/publish.yml +0 -0
  14. {worldarray-0.8.0 → worldarray-0.9.0}/.gitignore +0 -0
  15. {worldarray-0.8.0 → worldarray-0.9.0}/LICENSE +0 -0
  16. {worldarray-0.8.0 → worldarray-0.9.0}/docs/changelog.md +0 -0
  17. {worldarray-0.8.0 → worldarray-0.9.0}/docs/index.md +0 -0
  18. {worldarray-0.8.0 → worldarray-0.9.0}/mkdocs.yml +0 -0
  19. {worldarray-0.8.0 → worldarray-0.9.0}/src/worldarray/array.py +0 -0
  20. {worldarray-0.8.0 → worldarray-0.9.0}/src/worldarray/geometry.py +0 -0
  21. {worldarray-0.8.0 → worldarray-0.9.0}/src/worldarray/grid.py +0 -0
  22. {worldarray-0.8.0 → worldarray-0.9.0}/src/worldarray/levels.py +0 -0
  23. {worldarray-0.8.0 → worldarray-0.9.0}/src/worldarray/placement.py +0 -0
  24. {worldarray-0.8.0 → worldarray-0.9.0}/src/worldarray/resample.py +0 -0
  25. {worldarray-0.8.0 → worldarray-0.9.0}/src/worldarray/retry.py +0 -0
  26. {worldarray-0.8.0 → worldarray-0.9.0}/src/worldarray/store.py +0 -0
  27. {worldarray-0.8.0 → worldarray-0.9.0}/src/worldarray/units.py +0 -0
  28. {worldarray-0.8.0 → worldarray-0.9.0}/src/worldarray/write.py +0 -0
  29. {worldarray-0.8.0 → worldarray-0.9.0}/tests/conftest.py +0 -0
  30. {worldarray-0.8.0 → worldarray-0.9.0}/tests/fixtures/metadata.json +0 -0
  31. {worldarray-0.8.0 → worldarray-0.9.0}/tests/fixtures/write.json +0 -0
  32. {worldarray-0.8.0 → worldarray-0.9.0}/tests/test_geometry.py +0 -0
  33. {worldarray-0.8.0 → worldarray-0.9.0}/tests/test_grid.py +0 -0
  34. {worldarray-0.8.0 → worldarray-0.9.0}/tests/test_lost_wakeup.py +0 -0
  35. {worldarray-0.8.0 → worldarray-0.9.0}/tests/test_metadata.py +0 -0
  36. {worldarray-0.8.0 → worldarray-0.9.0}/tests/test_read.py +0 -0
  37. {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.8.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 `OMP_NUM_THREADS` or the LSF job's slots instead of the node's cores, or a capped one
150
- per worker; reads and writes retried with backoff and size-aware timeouts on transient
151
- store errors, a cancelled future on timeout, and every context's thread pools nudged once
152
- a second against a tensorstore bug that can leave a queued task with no thread, so a
153
- stuck operation recovers or fails loudly instead of hanging the job.
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: OMP_NUM_THREADS, the job's slots, or CPUs;
215
- # file I/O gets at least 8; threads=N limits both to N, threads=0 leaves tensorstore's sizes
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 `OMP_NUM_THREADS` or the LSF job's slots instead of the node's cores, or a capped one
91
- per worker; reads and writes retried with backoff and size-aware timeouts on transient
92
- store errors, a cancelled future on timeout, and every context's thread pools nudged once
93
- a second against a tensorstore bug that can leave a queued task with no thread, so a
94
- stuck operation recovers or fails loudly instead of hanging the job.
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: 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
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 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)
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), or both by `threads` when given,
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: OMP_NUM_THREADS, else
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. 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
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 `(cache_bytes, threads)` is one context for the
724
- process, made on first use and watched; `threads=None` and the number
725
- `thread_budget()` returns are two contexts, as a pickle carries `None` for the other
726
- process to size by its own job and a number as that number.
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"`. Opt-in (`WORLDARRAY_STRESS=1`, about a minute,
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.8.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" }
@@ -28,7 +28,7 @@ from .write import (
28
28
  parse_chunk_key,
29
29
  )
30
30
 
31
- __version__ = "0.8.0"
31
+ __version__ = "0.9.0"
32
32
 
33
33
  __all__ = [
34
34
  "ArrayMeta",
@@ -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, where tensorstore alone would size it to every CPU on the node, and its
9
- file I/O pool to at least ``FILE_IO_FLOOR`` threads, which wait on storage rather than
10
- compute; ``threads=0`` leaves both at tensorstore's sizes (chunkmirage's server). Every
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: ``OMP_NUM_THREADS``
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()`` and file I/O to that or ``FILE_IO_FLOOR``, whichever is larger, a
169
- positive number both to that many, ``0`` neither (tensorstore's own sizes, every CPU on
170
- the node). ``fsync`` and ``locking`` go to ``write_settings``."""
171
- key = (int(cache_bytes), None if threads is None else int(threads), bool(fsync), str(locking))
172
- if key[1] is not None and key[1] < 0:
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, FILE_IO_FLOOR)}
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))`` otherwise,
245
- else ``("spec", json)`` from the context's own spec, so a capped context stays capped
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:] == (None, False, "none") else key)
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
- cache_bytes, threads, fsync, locking = (
263
- (*value, False, "none")[:4]
264
- if isinstance(value, (tuple, list))
265
- else (value, None, False, "none")
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.8.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