COSMO-REA6 Pipeline Parallelization and Performance¶
This document describes the parallelization strategy used by both the
monthly (test_cosmo_one_month.py) and annual (test_cosmo_one_year.py) pipeline
scripts for processing COSMO-REA6 data on a multi-core HPC node.
For the DNI-specific vectorization over the spatial grid using the Spencer solar-position formula see dni_methodology.md §3.
1. Pipeline stages and their parallelism type¶
Each run processes 9 attributes per month through four stages:
┌─────────────┐ ThreadPoolExecutor ┌──────────────────┐
│ Download │ ──── 9 threads ──────► │ .grb.bz2 files │
│ (I/O-bound)│ per month │ (on disk) │
└─────────────┘ └────────┬─────────┘
│
┌─────────────┐ ProcessPoolExecutor │
│ Decompress │ ◄─── 9 processes ──────────────┘
│ (CPU-bound)│ submitted as each │
└─────────────┘ download completes │
(producer-consumer) │
▼
┌─────────────┐ Dask threaded scheduler ┌──────────────┐
│ Transform │ ──── ncores workers ──────► │ in-memory │
│ (CPU-bound)│ (80 on HPC node) │ xr.Dataset │
└─────────────┘ └──────┬───────┘
│
┌─────────────┐ Dask threaded + zlib │
│ Export │ ◄────────────────────────────────┘
│ (I/O+CPU) │ same thread pool, float32
└─────────────┘ compression level 1
Worker counts — monthly script (9 attributes, 80 cores)¶
| Stage | Workers | Type | Hard ceiling | Reason |
|---|---|---|---|---|
| Download | 9 | ThreadPoolExecutor |
# attributes | Network I/O-bound |
| Decompress | 9 | ProcessPoolExecutor |
# attributes | One bz2 per attribute |
| Transform | 80 | Dask threaded | ncores |
CPU-bound arithmetic |
| Export | 80 | Dask threaded | ncores |
Dask .compute() + zlib |
Download and decompress are hard-limited to 9 workers (one per attribute)
regardless of how many cores are allocated. Since they complete before
transform begins, all ncores (80 in this example) are devoted to the
dask transform and export stages.
Worker counts — annual script (108 tasks, 96 cores)¶
The annual script treats all 12 months × 9 attributes = 108 tasks as independent, removing the per-month ceiling:
| Phase | Workers | Type | Hard ceiling | Reason |
|---|---|---|---|---|
| Phase 1 — Download | 96 | ThreadPoolExecutor |
min(108, ncores) | All month-attr pairs independent |
| DWD size check | 94 | ThreadPoolExecutor (sequential, after Phase 2) |
min(108, ncores) | Network I/O; runs after ProcessPoolExecutor exits to avoid fork+thread deadlock |
| Phase 2 — Decompress | 96 | ProcessPoolExecutor |
min(108, ncores) | All month-attr pairs independent |
| Phase 2 verify | — | Sequential | — | Local stat() + 4-byte GRIB magic; < 100 ms |
| Phase 3 — Transform | 96 | Dask threaded | ncores |
Per-month, CPU-bound |
| Phase 3 — Export | 96 | Dask threaded | ncores |
Per-month, I/O + CPU |
The DWD size check and Phase 2 decompression use completely different resources (network I/O threads vs CPU-bound processes) and therefore run simultaneously at no cost to either.
2. Download + Decompress: producer-consumer pattern (monthly script)¶
The monthly script (test_cosmo_one_month.py) uses a producer-consumer pattern
that overlaps downloading and decompression so the process pool is never idle:
with ProcessPoolExecutor(max_workers=dc_workers) as dc_pool: # outer
with ThreadPoolExecutor(max_workers=dl_workers) as dl_pool: # inner
for attr in attrs:
dl_futures[dl_pool.submit(download, attr)] = attr
for dl_fut in as_completed(dl_futures):
bz2_path = dl_fut.result() # blocks only for this attr
dc_pool.submit(decompress, bz2_path) # starts immediately
Worker budget allocation (ncores = 80, n = 9):
dl_workers = min(n, max(1, ncores // 2)) = min(9, 40) = 9
dc_workers = min(n, max(1, ncores - dl_workers)) = min(9, 71) = 9
The ncores // 2 heuristic reserves half the budget for each pool; both
are capped at n so the split only matters when ncores < 2 * n_attrs.
3. Annual pipeline: bulk parallel phases¶
providers/cosmo_rea6/pipeline.py::run_pipeline() (called by the thin
test_cosmo_one_year.py/test_cosmo_one_month.py CLI wrappers, matching
ERA5-Land/MERRA-2's pattern) exploits a key fact: all 12 months × 9
attributes = 108 download tasks are completely independent, and so are
all 108 decompress tasks. It therefore processes them in two bulk phases
before any transform work begins.
Full execution flow with integrity checks:
Phase 1 — Bulk download (all 108 bz2 files)
┌─────────────────────────────────────────────────────────────────────┐
│ ThreadPoolExecutor(min(108, ncores)) │
│ month 01 × 9 attrs ──────────────────────────────────────────────►│
│ month 02 × 9 attrs ──────────────────────────────────────────────►│
│ ... │
│ month 12 × 9 attrs ──────────────────────────────────────────────►│
│ [CHECK A] size > 0 per file, inline as each future completes │
└─────────────────────────────────────────────────────────────────────┘
Wall time ≈ 3–4 min (observed on HPC with 94 threads)
│
▼
Phase 2 — Bulk decompress (all 108 .grb files)
┌─────────────────────────────────────────────────────────────────────┐
│ ProcessPoolExecutor(min(108, ncores)) │
│ Round 1: 94 tasks in parallel ───────────────────────────────────►│
│ Round 2: 14 remaining tasks ─────────────────────────────────────►│
│ Each task logs start AND completion at INFO level │
└─────────────────────────────────────────────────────────────────────┘
│
▼
[CHECK C] decompress.verify_decompressed() — sequential, all local disk
stat() + size-expansion check + 4-byte GRIB magic
108 files; expected wall time: < 100 ms
│
▼
[CHECK B] download.verify_downloads() — sequential, after ProcessPoolExecutor exits
94 parallel HEAD requests to DWD (I/O-bound)
compares local bz2 size to Content-Length header
Must run BEFORE bz2 cleanup so local files still exist
Expected wall time: 1–5 s (2 batches × 94 requests @ <150 ms RTT)
Worst case (30 s timeout per request): 2 × 30 s = 60 s
│
▼
bz2 cleanup (gated on CHECK B + CHECK C both passing)
│
▼
Phase 3 — Transform + Export (sequential per month)
month 01 → [Open GRIBs] → [Transform] → [Export NetCDF] → [cleanup grb]
month 02 → [Open GRIBs] → [Transform] → [Export NetCDF] → [cleanup grb]
...
month 12 → [Open GRIBs] → [Transform] → [Export NetCDF] → [cleanup grb]
Wall time ≈ 12 × 149 s = 1788 s
│
▼
Output: 12 monthly files per year
COSMO_REA6_YYYY_01_all_attrs.nc … COSMO_REA6_YYYY_12_all_attrs.nc
No annual merge step required — test_percentile.py reads monthly files
directly via xr.open_mfdataset.
3.1 Why each check is placed where it is¶
| Check | Where | What | Why there |
|---|---|---|---|
A — size > 0 |
Inline in Phase 1 future loop | Each .grb.bz2 > 0 B immediately on thread completion |
download_https_atomic uses atomic rename; a non-empty file was fully written |
B — DWD Content-Length |
Sequential, after Phase 2 ProcessPoolExecutor exits |
94 parallel HEAD requests: local size == server Content-Length |
Must run after the ProcessPoolExecutor exits to avoid the Linux fork-after-thread deadlock; must run before bz2 cleanup so local files still exist to be stat()'d |
| C — GRIB magic | Sequential, after Phase 2 | stat() + size expansion + b"GRIB" header bytes |
Local disk only, < 100 ms; gates bz2 deletion so corrupt decompression is caught before source files are removed |
Why sequential in Phase 3? The bottleneck is CPU, not RAM.
Dask already saturates all ncores threads within one month's transform.
Running k months in parallel would give each month only ncores/k dask
workers — each month would take k× longer, so total wall time is unchanged
(or worse, due to dask-scheduler contention across concurrent graphs).
Months are therefore kept sequential so each month benefits from the full
core budget.
3.2 Interrupted runs: skip-if-done and --resume¶
Each phase has an independent skip-if-done check, so a re-run after an interrupted pipeline is always faster than a cold start:
| Phase | Skip condition | Who checks |
|---|---|---|
| Phase 1 download | .grb.bz2 exists and local size == DWD Content-Length |
download_https_atomic — automatic, no flag needed |
| Phase 2 decompress | .grb exists and size > 0 |
decompress_bz2_file — automatic, no flag needed |
| Phase 3 transform | output .nc exists |
--resume flag — must be passed explicitly |
What --resume does (and does not do):
- Skips the transform + export step for any month whose
COSMO_REA6_YYYY_MM_all_attrs.ncalready exists in the output directory. - Phase 1 and Phase 2 still run their skip-if-done checks regardless.
If the
.grbfiles for skipped months are still on disk, Phase 2 returns instantly for those months.
Recommended command after an interruption (e.g. stopped during month 5,
months 1–4 have .nc files, months 5–12 still have .grb files on disk,
but all .grb.bz2 were already cleaned up after Phase 2):
python src/weather/tests/test_cosmo_one_year.py --year 2018 --ncores 94 \
--skip-download --skip-decompress --resume --cleanup
--skip-download: bypass Phase 1 entirely (bz2 files already cleaned up)--skip-decompress: bypass Phase 2 entirely (grb files still on disk)--resume: skip Phase 3 for months whose.ncalready exists
This jumps straight to Phase 3 starting at the first unfinished month.
3.3 Cleanup sequence and disk management¶
Cleanup is split across Phase 2 and the per-month Phase 3 loop.
All cleanup is gated on the preceding integrity check passing —
a failed check raises RuntimeError before any files are deleted,
keeping source files available for diagnosis or re-run.
Phase 2 — Bulk decompress completes + CHECK C passes
└─► CLEANUP A remove each month's .grb.bz2 from dl_dir (frees ~12 GB)
Scoped to months_to_process × attributes (exact filenames,
never a glob — safe with --resume partial runs)
Condition: do_dl and cleanup
Phase 3 — per month, after export:
[3/3] Export NetCDF (export_netcdf raises on any write failure)
│
├─► close all xr.Dataset handles
└─► CLEANUP B remove THIS month's .grb + .idx + .lock from dc_dir
Exact filenames, plus a glob for cfgrib's hash-suffixed
index sidecar (`<grb_name>.<hash>.idx`, not `<grb_name>.idx`
— a real bug found and fixed this session: the plain-name
guess never matched, so .idx files were silently orphaned
on every cleanup-enabled run). Never touches other months'
.grb files (frees ~7 GB per month as each month completes).
Condition: do_dc and cleanup
After all months complete:
CLEANUP C rmdir per-attribute subfolders in dl_dir and dc_dir
(rmdir silently fails if non-empty — safe with
--parallel-years: other years may still hold their files)
Condition: cleanup
Peak disk usage (during Phase 3, just after Phase 2 cleanup):
| Data | Size | Location |
|---|---|---|
| All decompressed GRIBs (12 months × 9 attrs) | ~84 GB | dc_dir |
| NetCDF output being written (1 month at a time) | ~2 GB | out_dir |
| Peak total | ~86 GB |
Once Phase 3 starts consuming months, dc_dir shrinks by ~7 GB per month.
By the time month 12 completes, dc_dir is empty and out_dir holds the
12 final NetCDF files (~24 GB).
Both cleanups are gated on the do_dl / do_dc flags and --cleanup
(a positive flag, matching ERA5-Land/MERRA-2 — this used to be COSMO's
own negative --no-cleanup, renamed for consistency), so
--skip-download, --skip-decompress, and omitting --cleanup suppress
the relevant call. All of this logic now lives in
providers/cosmo_rea6/pipeline.py::run_pipeline(), not in the test
script itself — test_cosmo_one_year.py/test_cosmo_one_month.py are
thin CLI wrappers around it, matching ERA5-Land/MERRA-2.
4. Transform stage: dask temporal chunking¶
open_grib_month() opens GRIB files with:
Temporal chunking — chunks={"time": 168} (≈ 1 week)¶
A monthly GRIB file has 672–744 hourly timesteps. With chunk size 168, each attribute has 4–5 independent dask chunks. Combined with 9 attributes, the task graph contains dozens of independent tasks per variable, giving the 80-thread dask scheduler real parallelism to exploit.
Monthly GRIB (e.g. T_2M, January 2018):
time: 744 steps → ⌈744/168⌉ = 5 chunks (4 full × 168 = 672, + 1 partial of 72)
y: 824 (single chunk)
x: 848 (single chunk)
Why spatial chunking is NOT applied¶
- cfgrib reads the full COSMO-REA6 rotated-pole grid (824 × 848) as one
spatial tile per timestep. Sub-dividing
(y, x)would increase the dask task graph quadratically without reducing I/O (cfgrib still reads the full GRIB record). - The spatial grid at float32 per time-chunk is only ~470 MB — it fits
comfortably in memory and processing it as a single spatial tile avoids
chunk-boundary effects in operations like wind speed
√(u² + v²).
Why the Spencer formula is fully parallel¶
All transform operations (convert_temperature, compute_ghi, compute_dhi,
compute_wind_speed, compute_dni) are pure NumPy/Dask element-wise
operations that broadcast naturally over (time, y, x) arrays. No Python
loops, no GIL contention (NumPy releases the GIL for C-extension arithmetic),
so all 80 dask threads run simultaneously on different chunks.
See dni_methodology.md §3 for a detailed comparison with the pvlib loop-based approach.
Explicit dask worker count¶
Without this, dask defaults to os.cpu_count(). On an HPC node running
inside a SLURM allocation, os.cpu_count() returns the total machine CPUs
(not the allocated ones), so explicit configuration ensures the right count.
settings.cosmo_ncores() already reads SLURM_CPUS_PER_TASK as a fallback,
so passing --ncores 80 or setting COSMO_NCORES=80 is equivalent.
5. Export stage: compression and float32¶
export_netcdf() encodes all data variables as float32 with zlib
compression level 1:
| Choice | Reason |
|---|---|
complevel=1 (fastest) |
Levels 2–9 give diminishing file-size reduction (< 5%) for 10× more CPU time on large grids |
dtype=float32 |
Halves output file size vs float64; instruments have ≈ 0.1 W/m² precision — float32 (7 significant digits) is more than adequate |
HDF5_USE_FILE_LOCKING=FALSE |
Prevents deadlocks on GPFS/Lustre network file systems used on HPC; set at module level in test_cosmo_one_year.py and export.py |
Dask triggers .compute() during to_netcdf(), writing chunks concurrently
with the same 80-thread scheduler used for transform.
6. Memory footprint¶
| Item | Size (float32) |
|---|---|
| One chunk, one attribute: 168 × 824 × 848 | ≈ 470 MB |
| All 9 attributes, one chunk each | ≈ 4.2 GB |
| Peak active (4 concurrent chunks × 9 attrs) | ≈ 17 GB |
| Intermediate operations (add, divide, sqrt) | ≈ 1.5× peak = 25 GB |
| Dask task graph overhead | ≤ 5 GB |
| Total peak per month (80 workers) | ≈ 30 GB |
| Total peak per month (94 workers) | ≈ 45 GB |
The per-month peak scales with ncores because dask runs more chunks
concurrently:
$$\text{Peak RAM} \approx \underbrace{n_{\text{workers}} \times 470\,\text{MB}}_{\text{active chunks}} \times 1.5 + 5\,\text{GB overhead}$$
At 94 workers: 94 × 470 MB × 1.5 + 5 GB ≈ 45 GB per month, which matches observed usage. This is the expected and correct value — it means dask is fully utilising all allocated workers.
Only one month is held in memory at a time (Phase 3 is sequential), so 45 GB is also the total process peak, regardless of how many months are being processed in the year run.
SLURM memory allocation¶
When submitting via SLURM, request at least ncores × 0.7 GB to give a
comfortable margin:
# 94 cores × 0.7 GB/core = 66 GB headroom; round up to next SLURM unit
#SBATCH --mem=70G
# Or, to use the full node's RAM without an explicit limit:
#SBATCH --mem=0
On a dedicated 782 GB node the margin is ~17× and --mem=0 (use all
available) is safe.
7. Monthly vs annual comparison¶
| Feature | test_cosmo_one_month.py |
test_cosmo_one_year.py |
|---|---|---|
| Download+decompress | Producer-consumer (9+9 workers) | Bulk parallel: min(108, ncores) |
| Inline download check | size > 0 per future |
size > 0 per future |
| DWD size verification | — | ✓ sequential after Phase 2; 108 HEAD requests |
| GRIB magic check | — | ✓ sequential after Phase 2; local disk only |
| Transform | Dask threaded, 80 workers | Same (per-month, sequential months) |
| Export | zlib level 1, float32 | Same |
| Annual merge (post-process) | — | Separate: python -m weather.common.merge |
| Resume support | — | ✓ --resume flag |
| Per-month error isolation | — | ✓ try/except, continues |
| Peak disk usage | ~8 GB (1 month) | ~84 GB (all grb after Phase 2; shrinks per month in Phase 3) |
| Annual log file | — | COSMO_REA6_YYYY_annual_<timestamp>.log |
8. Effective throughput on the HPC node¶
Measured on HPC (96 cores, /data/soma, 2018 dataset, all 9 attributes):
one month ≈ 230 s end-to-end including download, decompress, transform,
and export.
Estimated phase breakdown (230 s = 100 %):
| Phase | Parallelism | Share | Time |
|---|---|---|---|
Download (9 attrs, ThreadPoolExecutor) |
9 threads | ~25 % | ~58 s |
Decompress (9 attrs, ProcessPoolExecutor) |
9 processes | ~10 % | ~23 s |
| Transform + Export (dask, 96 threads) | 96 threads | ~65 % | ~149 s |
| Total per month | 100 % | ~230 s |
Times vary with network speed to DWD OpenData, disk I/O speed on /data/soma,
and system load. Transform dominates on fast networks; download dominates on
slow networks.
8.1 Annual timing estimate and bulk-parallel savings¶
With the measured 230 s/month baseline, the annual run over 12 months proceeds in three bulk phases:
Phase 1 — Bulk download (all 108 bz2 files, 96 concurrent threads):
Network I/O for all months runs in parallel. Observed wall time on the HPC node with 96 threads is 3–4 min (180–240 s). The theoretical minimum equals one month's download time; the difference reflects DWD server response variability and retry overhead from transient 503 errors.
$$T_{\text{Phase1}} \approx 180\text{–}240\,\text{s}$$
DWD size check (background thread, concurrent with Phase 2):
108 HEAD requests fired with up to n_workers (96) threads. With
~50–150 ms RTT to DWD from the HPC network, ⌈108/96⌉ = 2 batches
complete in well under 5 s — negligible, and free since it overlaps
Phase 2.
$$T_{\text{DWD check}} \approx 2 \times 150\,\text{ms} \approx 1\text{–}5\,\text{s}$$
Phase 2 — Bulk decompress (all 108 grb files, 96 processes):
With 96 processes and 108 tasks there are ⌈108/96⌉ = 2 rounds. Each bzip2 task takes ~23 s, so:
$$T_{\text{Phase2}} \approx 2 \times 23\,\text{s} = 46\,\text{s}$$
Phase 3 — Transform + Export (sequential months, dask saturates 96 cores):
$$T_{\text{Phase3}} = 12 \times 149\,\text{s} = 1788\,\text{s}$$
Annual total¶
$$58 + 46 + 1788 = 1892\,\text{s} \approx \mathbf{32\,\text{min}}$$
Summary (comparison with older approaches):
| Mode | Phase 1 (dl) | Phase 2 (dc) | Phase 3 (tx+ex) | Total | Wall clock |
|---|---|---|---|---|---|
| Sequential | 12 × 58 s = 696 s | 12 × 23 s = 276 s | 12 × 149 s = 1788 s | 2760 s | ~46 min |
| Old pre-fetch | ~58 s (M1 only) | 12 × 23 s = 276 s | 1788 s | ~2122 s | ~35 min |
| Bulk parallel (current) | ~58 s | ~46 s | 1788 s | ~1892 s | ~32 min |
The key win over the old pre-fetch approach is Phase 2: the per-month sequential decompress (12 × 23 s = 276 s) is replaced by a single bulk pass with 96 processes (2 rounds × 23 s = 46 s), saving ~230 s.
Sensitivity to download speed¶
| Download time per month | Phase 1 wall time | Annual total |
|---|---|---|
| 30 s (fast HPC network) | ~30 s | ~30 + 46 + 1788 = ~1864 s ≈ 31 min |
| 58 s (measured baseline, 1-month test) | ~58 s | ~1892 s ≈ 32 min |
| 180–240 s (observed, full-year with 96 threads) | ~180–240 s | ~2014–2074 s ≈ 34–35 min |
| 90 s (moderate WAN) | ~90 s | ~1924 s ≈ 32 min |
| 120 s (slow WAN) | ~120 s | ~120 + 46 + 1788 = ~1954 s ≈ 33 min |
On a slow WAN connection the saving vs sequential processing can exceed 20 minutes — Phase 1 wall time is bounded by a single-month's connection speed while the sequential baseline scales as 12× that speed.
9. Configuration reference¶
# .env (or CLI flags)
COSMO_NCORES=94 # worker budget for all pools (download / decompress / dask)
# On a 96-core node use 94–95, not 96:
# - leaves 1–2 cores for OS scheduler, SSH daemon,
# monitoring agents and any residual background work
# - avoids starving other users' processes if the
# node is shared or lightly used by others
COSMO_WORK_DIR=/data/soma/cosmo_rea6
# SLURM job script — allocate all 96 but tell the script to use 94
#SBATCH --cpus-per-task=96
# SLURM_CPUS_PER_TASK is read automatically but --ncores overrides it
# Run (explicit --ncores recommended on shared nodes)
python src/weather/tests/test_cosmo_one_year.py --year 2018 --ncores 94 --resume
Choosing --ncores¶
| Node situation | Recommended value | Reason |
|---|---|---|
| Exclusively allocated SLURM job | ncores - 1 (e.g. 95) |
One core free for OS; no other users |
| Shared node, no other-user jobs | ncores - 2 (e.g. 94) |
OS daemon safety margin |
| Shared node, other jobs running | ncores / 2 or ncores - 4 |
Avoid starving other workloads |
All three executor pools (ThreadPoolExecutor for download and DWD checks,
ProcessPoolExecutor for decompression, and dask for transform/export) are
hard-capped at min(n_tasks, ncores), so a single --ncores 94 flag is
sufficient to control the entire pipeline's core budget.
9.1 COSMO_THREADS_PER_JOB — avoiding thread oversubscription¶
COSMO_THREADS_PER_JOB controls how many threads each individual bzip2
worker uses (default: 4). This interacts critically with COSMO_NCORES:
| Decompressor | COSMO_THREADS_PER_JOB |
Total OS threads | Effect |
|---|---|---|---|
Python bz2 stdlib |
any | ncores × 1 |
Ignored — Python bz2 is always single-threaded |
lbzip2 / pbzip2 |
4 (default) | 94 × 4 = 376 |
Oversubscribed — 376 threads / 94 cores |
lbzip2 / pbzip2 |
1 | 94 × 1 = 94 |
Correct — one thread per core; max throughput |
Rule: when running test_cosmo_one_year.py with bulk parallel decompression
(ProcessPoolExecutor, ncores workers), always set:
lbzip2 at 1 thread is still faster than Python bz2 at 1 thread because
it is a compiled C binary with lower per-byte overhead and no Python
interpreter cost. The multi-thread benefit of lbzip2/pbzip2 only applies
when decompressing one file at a time (e.g. the monthly script with
n = 9 workers on a 96-core node); for the annual bulk pass the
process-level parallelism already saturates all cores.
10. Related documentation¶
| Topic | File |
|---|---|
| DNI formula and Spencer vectorization | dni_methodology.md |
| Monthly pipeline script | src/weather/tests/test_cosmo_one_month.py |
| Annual pipeline script | src/weather/tests/test_cosmo_one_year.py |
| Environment configuration | .env.example |