Skip to content

Commit b6fe55a

Browse files
authored
Fix distribution bottleneck for higher dimensional grids (#832)
* Avoid full grid_map pickler overhead for every ingestion chunk * Eagerly materialize the numpy array repr * Reintorduce lazy loading but pickle the map only once per process in the pool * Fix pre-commit
1 parent 48ba918 commit b6fe55a

2 files changed

Lines changed: 75 additions & 37 deletions

File tree

src/mdio/segy/_workers.py

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,16 @@
66
from typing import TYPE_CHECKING
77

88
import numpy as np
9+
from segy import SegyFile
910
from segy.arrays import HeaderArray
11+
from zarr import open_group as zarr_open_group
1012

1113
from mdio.core.config import MDIOSettings
1214
from mdio.segy._raw_trace_wrapper import SegyFileRawTraceWrapper
1315
from mdio.segy.file import SegyFileArguments
1416
from mdio.segy.file import SegyFileWrapper
1517

1618
if TYPE_CHECKING:
17-
from segy import SegyFile
1819
from zarr import Array as zarr_Array
1920

2021
from zarr.core.config import config as zarr_config
@@ -68,30 +69,70 @@ def header_scan_worker(
6869
return HeaderArray(trace_header) # wrap back so we can use aliases
6970

7071

71-
def trace_worker( # noqa: PLR0913
72-
segy_file: SegyFile,
73-
data_array: zarr_Array,
74-
header_array: zarr_Array | None,
75-
raw_header_array: zarr_Array | None,
76-
region: dict[str, slice],
72+
# Per-worker process state populated once by `trace_worker_init`. Keeping the SEG-Y handle,
73+
# Zarr array handles, and the (compressed, in-memory) grid map here lets us pickle them a single
74+
# time per worker via the pool initializer instead of once per submitted block. The grid map is
75+
# retained as a compressed in-memory Zarr array and sliced lazily per region, so each worker only
76+
# materializes its own block rather than the full dense map.
77+
_worker_state: dict[str, object] = {}
78+
79+
80+
def trace_worker_init( # noqa: PLR0913
81+
segy_file_kwargs: SegyFileArguments,
82+
output_path: str,
83+
storage_options: dict[str, object] | None,
84+
use_consolidated: bool,
85+
data_variable_name: str,
7786
grid_map: zarr_Array,
78-
) -> SummaryStatistics | None:
87+
) -> None:
88+
"""Initialize per-process state for trace ingestion workers.
89+
90+
Used as the `ProcessPoolExecutor` initializer so the SEG-Y file, Zarr output handles, and grid
91+
map are opened/transferred once per worker process rather than re-pickled for every block.
92+
93+
Args:
94+
segy_file_kwargs: Arguments to open the SegyFile instance.
95+
output_path: POSIX path to the output MDIO Zarr store.
96+
storage_options: fsspec storage options for the output store.
97+
use_consolidated: Whether to open the group with consolidated metadata (Zarr V2).
98+
data_variable_name: Name of the data variable in the dataset.
99+
grid_map: Compressed in-memory Zarr array mapping live traces to their positions.
100+
"""
101+
# Setting the zarr config to 1 thread to ensure we honor the `MDIO__IMPORT__CPU_COUNT` environment variable.
102+
# The Zarr 3 engine utilizes multiple threads. This can lead to resource contention and unpredictable memory usage.
103+
zarr_config.set({"threading.max_workers": 1})
104+
105+
zarr_group = zarr_open_group(
106+
output_path,
107+
mode="r+",
108+
storage_options=storage_options,
109+
use_consolidated=use_consolidated,
110+
)
111+
112+
_worker_state["segy_file"] = SegyFile(**segy_file_kwargs)
113+
_worker_state["data_array"] = zarr_group[data_variable_name]
114+
_worker_state["header_array"] = zarr_group.get("headers")
115+
_worker_state["raw_header_array"] = zarr_group.get("raw_headers")
116+
_worker_state["grid_map"] = grid_map
117+
118+
119+
def trace_worker(region: dict[str, slice]) -> SummaryStatistics | None:
79120
"""Writes a subset of traces from a region of the dataset of Zarr file.
80121
122+
Reads its shared inputs (SEG-Y handle, Zarr arrays, grid map) from the per-process state set up
123+
by `trace_worker_init`, so only the lightweight `region` is pickled per block.
124+
81125
Args:
82-
segy_file: The opened SEG-Y file.
83-
data_array: Zarr array for writing trace data.
84-
header_array: Zarr array for writing trace headers (or None if not needed).
85-
raw_header_array: Zarr array for writing raw headers (or None if not needed).
86126
region: Region of the dataset to write to.
87-
grid_map: Zarr array mapping live traces to their positions in the dataset.
88127
89128
Returns:
90129
SummaryStatistics object containing statistics about the written traces.
91130
"""
92-
# Setting the zarr config to 1 thread to ensure we honor the `MDIO__IMPORT__CPU_COUNT` environment variable.
93-
# The Zarr 3 engine utilizes multiple threads. This can lead to resource contention and unpredictable memory usage.
94-
zarr_config.set({"threading.max_workers": 1})
131+
segy_file: SegyFile = _worker_state["segy_file"]
132+
data_array: zarr_Array = _worker_state["data_array"]
133+
header_array: zarr_Array | None = _worker_state["header_array"]
134+
raw_header_array: zarr_Array | None = _worker_state["raw_header_array"]
135+
grid_map: zarr_Array = _worker_state["grid_map"]
95136

96137
region_slices = tuple(region.values())
97138
local_grid_map = grid_map[region_slices[:-1]] # minus last (vertical) axis

src/mdio/segy/blocked_io.py

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
import zarr
1313
from dask.array import Array
1414
from dask.array import map_blocks
15-
from segy import SegyFile
1615
from tqdm.auto import tqdm
1716
from zarr import open_group as zarr_open_group
1817

@@ -23,6 +22,7 @@
2322
from mdio.core.config import MDIOSettings
2423
from mdio.core.indexing import ChunkIterator
2524
from mdio.segy._workers import trace_worker
25+
from mdio.segy._workers import trace_worker_init
2626
from mdio.segy.creation import SegyPartRecord
2727
from mdio.segy.creation import concat_files
2828
from mdio.segy.creation import serialize_to_segy_stack
@@ -82,47 +82,44 @@ def to_zarr( # noqa: PLR0913, PLR0915
8282
num_chunks = chunk_iter.num_chunks
8383

8484
zarr_format = zarr.config.get("default_zarr_format")
85+
use_consolidated = zarr_format == ZarrFormat.V2
8586

86-
# Open zarr group once in main process
87+
# Open zarr group once in main process (used for final stats update below).
8788
storage_options = _normalize_storage_options(output_path)
8889
zarr_group = zarr_open_group(
8990
output_path.as_posix(),
9091
mode="r+",
9192
storage_options=storage_options,
92-
use_consolidated=zarr_format == ZarrFormat.V2,
93+
use_consolidated=use_consolidated,
9394
)
9495

95-
# Get array handles from the opened group
96-
data_array = zarr_group[data_variable_name]
97-
header_array = zarr_group.get("headers")
98-
raw_header_array = zarr_group.get("raw_headers")
99-
10096
# For Unix async writes with s3fs/fsspec & multiprocessing, use 'spawn' instead of default
10197
# 'fork' to avoid deadlocks on cloud stores. Slower but necessary. Default on Windows.
10298
num_workers = min(num_chunks, settings.import_cpus)
10399
context = mp.get_context("spawn")
104100

105-
# Use initializer to open segy file once per worker
101+
# Open the SEG-Y file, Zarr output handles, and transfer the compressed grid map once per worker
102+
# via the initializer. The grid map stays a compressed in-memory Zarr array and is sliced lazily
103+
# inside each worker, so we avoid both re-pickling per block and materializing the full dense map.
106104
executor = ProcessPoolExecutor(
107105
max_workers=num_workers,
108106
mp_context=context,
107+
initializer=trace_worker_init,
108+
initargs=(
109+
segy_file_kwargs,
110+
output_path.as_posix(),
111+
storage_options,
112+
use_consolidated,
113+
data_variable_name,
114+
grid_map,
115+
),
109116
)
110117

111-
segy_file = SegyFile(**segy_file_kwargs)
112-
113118
with executor:
114119
futures = []
115120
for region in chunk_iter:
116-
# Pass zarr array handles directly to workers
117-
future = executor.submit(
118-
trace_worker,
119-
segy_file,
120-
data_array,
121-
header_array,
122-
raw_header_array,
123-
region,
124-
grid_map,
125-
)
121+
# Only the lightweight region is pickled per block; shared inputs live in worker state.
122+
future = executor.submit(trace_worker, region)
126123
futures.append(future)
127124

128125
iterable = tqdm(

0 commit comments

Comments
 (0)