Skip to content

Commit be24434

Browse files
committed
feat(batch): parallel NASC computation script
ProcessPoolExecutor with spawn context, configurable workers/threads. Key optimisation: removes synchronous dask scheduler that throttled stage-7 NASC to single-threaded execution.
1 parent be0b8fc commit be24434

1 file changed

Lines changed: 316 additions & 0 deletions

File tree

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
#!/usr/bin/env python3
2+
"""Parallel NASC computation for all denoised zarrs.
3+
4+
Discovers denoised zarrs on local disk, skips those that already have
5+
NASC results, and computes the remaining in parallel using
6+
ProcessPoolExecutor.
7+
8+
Key optimisations vs build_full_survey.py stage-7 NASC:
9+
- Removes ``scheduler="synchronous"`` — lets dask use threaded scheduler
10+
so each worker exploits multiple CPU cores internally.
11+
- Processes multiple zarrs simultaneously via ProcessPoolExecutor.
12+
- Progress logging with ETA.
13+
14+
Usage:
15+
python run_nasc_parallel.py # default 12 workers
16+
python run_nasc_parallel.py --workers 8 # 8 workers
17+
python run_nasc_parallel.py --dry-run # list work only
18+
python run_nasc_parallel.py --workers 16 --threads-per-worker 3
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import argparse
24+
import gc
25+
import logging
26+
import multiprocessing
27+
import os
28+
import sys
29+
import time
30+
from concurrent.futures import ProcessPoolExecutor, as_completed
31+
from pathlib import Path
32+
33+
# ---------------------------------------------------------------------------
34+
# Constants
35+
# ---------------------------------------------------------------------------
36+
37+
NASC_RANGE_BIN = "10m"
38+
NASC_DIST_BIN = "0.5nmi"
39+
OUTPUT_CONTAINER = "sd-tpos2023-full-v01"
40+
CHUNKS = {"ping_time": 1000, "range_sample": -1}
41+
_DATA_DISK = Path("/mnt/data/output")
42+
43+
log = logging.getLogger("nasc_parallel")
44+
45+
46+
# ---------------------------------------------------------------------------
47+
# Discovery
48+
# ---------------------------------------------------------------------------
49+
50+
def discover_work(container_dir: Path) -> list[tuple[str, str, Path]]:
51+
"""Return list of (day_key, category, denoised_zarr_path) needing NASC.
52+
53+
Skips zarrs that already have a corresponding NASC zarr.
54+
"""
55+
work: list[tuple[str, str, Path]] = []
56+
already_done = 0
57+
58+
for day_dir in sorted(container_dir.iterdir()):
59+
if not day_dir.is_dir() or not day_dir.name.startswith("2023-"):
60+
continue
61+
day_key = day_dir.name
62+
63+
for zarr_path in sorted(day_dir.glob("*--denoised.zarr")):
64+
# Parse category from filename: 2023-06-22--short_pulse--denoised.zarr
65+
parts = zarr_path.stem.split("--")
66+
if len(parts) < 3:
67+
continue
68+
category = parts[1] # short_pulse or long_pulse
69+
70+
# Check if NASC already computed
71+
nasc_zarr = day_dir / f"{day_key}--{category}--nasc.zarr"
72+
if nasc_zarr.exists():
73+
already_done += 1
74+
continue
75+
76+
work.append((day_key, category, zarr_path))
77+
78+
log.info(
79+
"Discovered %d denoised zarrs needing NASC (%d already done)",
80+
len(work), already_done,
81+
)
82+
return work
83+
84+
85+
# ---------------------------------------------------------------------------
86+
# Single-zarr NASC computation (runs in worker process)
87+
# ---------------------------------------------------------------------------
88+
89+
def _compute_one_nasc(args: tuple[str, str, str, str, int]) -> tuple[str, str, bool, str]:
90+
"""Compute NASC for a single denoised zarr.
91+
92+
Args is a tuple: (day_key, category, denoised_zarr_str, output_container, threads)
93+
94+
Returns: (day_key, category, success, message)
95+
"""
96+
day_key, category, denoised_zarr_str, output_container, threads_per_worker = args
97+
98+
# Configure dask to use limited threads within this worker
99+
import dask
100+
dask.config.set(num_workers=threads_per_worker)
101+
102+
# Patch storage for local disk
103+
from local_storage import patch_storage
104+
patch_storage(_DATA_DISK)
105+
106+
import numpy as np
107+
import xarray as xr
108+
109+
logging.basicConfig(
110+
level=logging.INFO,
111+
format=f"%(asctime)s [{day_key}/{category}] %(message)s",
112+
datefmt="%H:%M:%S",
113+
)
114+
wlog = logging.getLogger(f"worker.{day_key}.{category}")
115+
116+
t0 = time.time()
117+
try:
118+
from oceanstream.echodata.compute import compute_nasc
119+
from oceanstream.echodata.storage import open_sv_from_azure, save_dataset_to_azure
120+
121+
wlog.info("Opening denoised zarr...")
122+
ds = open_sv_from_azure(
123+
f"{day_key}/{day_key}--{category}--denoised.zarr",
124+
container=output_container,
125+
chunks=CHUNKS,
126+
)
127+
128+
# Verify required variables
129+
has_depth = "depth" in ds or "depth" in ds.coords
130+
has_lat = "latitude" in ds.data_vars or "latitude" in ds.coords
131+
has_lon = "longitude" in ds.data_vars or "longitude" in ds.coords
132+
133+
if not has_depth:
134+
ds.close()
135+
return (day_key, category, False, "No depth variable")
136+
if not (has_lat and has_lon):
137+
ds.close()
138+
return (day_key, category, False, "No lat/lon variables")
139+
140+
wlog.info("Computing NASC (range_bin=%s, dist_bin=%s)...", NASC_RANGE_BIN, NASC_DIST_BIN)
141+
142+
# Use default dask scheduler (threaded) — NOT synchronous!
143+
ds_nasc = compute_nasc(ds, range_bin=NASC_RANGE_BIN, dist_bin=NASC_DIST_BIN)
144+
145+
# Save zarr
146+
output_zarr = f"{day_key}/{day_key}--{category}--nasc.zarr"
147+
save_dataset_to_azure(ds_nasc, zarr_path=output_zarr, container=output_container)
148+
149+
# Save netcdf
150+
nc_path = f"{day_key}/{day_key}--{category}--nasc.nc"
151+
_save_netcdf(ds_nasc, nc_path, output_container)
152+
153+
elapsed = time.time() - t0
154+
msg = f"Done in {elapsed:.0f}s"
155+
wlog.info(msg)
156+
157+
ds.close()
158+
ds_nasc.close()
159+
del ds, ds_nasc
160+
gc.collect()
161+
162+
return (day_key, category, True, msg)
163+
164+
except Exception as e:
165+
elapsed = time.time() - t0
166+
msg = f"Failed after {elapsed:.0f}s: {e}"
167+
wlog.error(msg)
168+
return (day_key, category, False, msg)
169+
170+
171+
def _save_netcdf(ds, nc_path: str, container: str) -> None:
172+
"""Save dataset as NetCDF to local disk."""
173+
import numpy as np
174+
import tempfile
175+
from oceanstream.echodata.storage import upload_file_to_blob
176+
177+
try:
178+
ds_computed = ds.compute()
179+
for var in list(ds_computed.data_vars):
180+
if ds_computed[var].dtype == bool:
181+
ds_computed[var] = ds_computed[var].astype(np.int8)
182+
183+
encoding = {}
184+
for var in ds_computed.data_vars:
185+
if ds_computed[var].dtype.kind in {"U", "S", "O"}:
186+
encoding[var] = {}
187+
else:
188+
encoding[var] = {"zlib": True, "complevel": 5}
189+
190+
with tempfile.NamedTemporaryFile(suffix=".nc", delete=True) as tmp:
191+
ds_computed.to_netcdf(
192+
tmp.name, engine="netcdf4", format="NETCDF4", encoding=encoding,
193+
)
194+
upload_file_to_blob(tmp.name, nc_path, container)
195+
except Exception as e:
196+
log.warning("NetCDF export failed for %s: %s", nc_path, e)
197+
198+
199+
# ---------------------------------------------------------------------------
200+
# Main
201+
# ---------------------------------------------------------------------------
202+
203+
def main() -> None:
204+
parser = argparse.ArgumentParser(description="Parallel NASC computation")
205+
parser.add_argument(
206+
"--workers", type=int, default=12,
207+
help="Number of parallel worker processes (default: 12)",
208+
)
209+
parser.add_argument(
210+
"--threads-per-worker", type=int, default=4,
211+
help="Dask threads per worker process (default: 4)",
212+
)
213+
parser.add_argument(
214+
"--output-container", default=OUTPUT_CONTAINER,
215+
help=f"Output container name (default: {OUTPUT_CONTAINER})",
216+
)
217+
parser.add_argument(
218+
"--dry-run", action="store_true",
219+
help="List work items without computing",
220+
)
221+
parser.add_argument(
222+
"--limit", type=int, default=0,
223+
help="Process at most N zarrs (0 = all)",
224+
)
225+
args = parser.parse_args()
226+
227+
logging.basicConfig(
228+
level=logging.INFO,
229+
format="%(asctime)s %(levelname)-7s %(name)s %(message)s",
230+
datefmt="%H:%M:%S",
231+
handlers=[
232+
logging.StreamHandler(sys.stdout),
233+
logging.FileHandler(_DATA_DISK / "nasc-parallel.log"),
234+
],
235+
)
236+
237+
# Patch storage in main process too (for discovery)
238+
sys.path.insert(0, str(Path(__file__).parent))
239+
from local_storage import patch_storage
240+
patch_storage(_DATA_DISK)
241+
242+
container_dir = _DATA_DISK / args.output_container
243+
if not container_dir.exists():
244+
log.error("Container dir not found: %s", container_dir)
245+
sys.exit(1)
246+
247+
work = discover_work(container_dir)
248+
if not work:
249+
log.info("Nothing to compute — all NASC zarrs present!")
250+
return
251+
252+
if args.limit > 0:
253+
work = work[:args.limit]
254+
log.info("Limited to %d items", args.limit)
255+
256+
if args.dry_run:
257+
log.info("Dry run — %d items:", len(work))
258+
for day_key, category, path in work:
259+
log.info(" %s / %s (%s)", day_key, category, path.name)
260+
return
261+
262+
# Build task args
263+
tasks = [
264+
(day_key, category, str(zarr_path), args.output_container, args.threads_per_worker)
265+
for day_key, category, zarr_path in work
266+
]
267+
268+
log.info(
269+
"Starting parallel NASC: %d zarrs, %d workers, %d threads/worker",
270+
len(tasks), args.workers, args.threads_per_worker,
271+
)
272+
273+
# Use spawn context to avoid fork + dask conflicts
274+
ctx = multiprocessing.get_context("spawn")
275+
276+
completed = 0
277+
failed = 0
278+
t_start = time.time()
279+
280+
with ProcessPoolExecutor(max_workers=args.workers, mp_context=ctx) as executor:
281+
futures = {
282+
executor.submit(_compute_one_nasc, task): (task[0], task[1])
283+
for task in tasks
284+
}
285+
286+
for future in as_completed(futures):
287+
day_key, category = futures[future]
288+
try:
289+
rday, rcat, success, msg = future.result()
290+
if success:
291+
completed += 1
292+
else:
293+
failed += 1
294+
elapsed = time.time() - t_start
295+
rate = completed / elapsed if elapsed > 0 else 0
296+
remaining = len(tasks) - completed - failed
297+
eta_s = remaining / rate if rate > 0 else 0
298+
eta_m = eta_s / 60
299+
300+
log.info(
301+
"[%d/%d done, %d failed] %s/%s: %s (ETA: %.0f min)",
302+
completed, len(tasks), failed, rday, rcat, msg, eta_m,
303+
)
304+
except Exception as e:
305+
failed += 1
306+
log.error("[%d/%d] %s/%s EXCEPTION: %s", completed, len(tasks), day_key, category, e)
307+
308+
total_time = time.time() - t_start
309+
log.info(
310+
"NASC parallel complete: %d/%d succeeded, %d failed in %.1f min",
311+
completed, len(tasks), failed, total_time / 60,
312+
)
313+
314+
315+
if __name__ == "__main__":
316+
main()

0 commit comments

Comments
 (0)