|
| 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