orthophoto merge: parallelize the block loop (in-order writer)#2035
orthophoto merge: parallelize the block loop (in-order writer)#2035Chouffe wants to merge 6 commits into
Conversation
4fdec37 to
bbd0295
Compare
merge() reads each source window with rasterio boundless=True, which builds an in-memory VRT and serializes it via Python's ElementTree (_serialize_xml) on every read — a large per-read overhead on big merges (tens of thousands of blocks x 3 passes x N submodels). _read_window_gated() keeps identical output but avoids the VRT for the common cases: a plain non-boundless read when the window is fully inside the source, zeros when fully outside (== the 0 nodata fill boundless produces there), and boundless only for the rare partial-edge windows. Pixel-identical (verified: hundreds of fully-in-bounds windows across a real merge grid compared boundless vs plain read, 0 mismatches). Serial; no behavior change beyond the speedup. Also a prerequisite for parallelizing the merge: boundless's per-read VRT serialization is pathological under concurrency.
bbd0295 to
fa47f06
Compare
|
Thanks @Chouffe - this could be a really nice change! 😁 Before I do a full review, could you help me with two things please:
Thanks a lot for the contribution! 🙌 |
|
Our policy, as it stands currently, id here: |
Thank you @Saijin-Naib! 🙏 I might make a little PR to ensure thats linked in the pull request template, so devs are definitely made aware when they create a new PR =) |
|
@spwoodcock thanks for taking the time to review this PR! For context: we’re using ODM on large drone surveys and letting users upload flight data to an online platform. We’ve already tuned ODM for our bird-monitoring domain so it runs reliably on AWS ECS Fargate. Even so, we hit one remaining issue: a large stress-test survey would deadlock after ~7 hours of compute. To address this, I ran ODM from a pinned base Docker image (v3.6.0) with the relevant runtime patches baked in. With these changes, our very large surveys complete successfully without the earlier hiccups. After extended investigation, the deadlock turned out to be caused by a combination of compounding factors:
To validate the merge changes, I reproduced the merge path in isolation by generating three orthophotos (via ODM’s split phase) and then running the merge step across those three outputs. I also confirmed the merged results are byte-identical whether run serially or with a high worker count. The sub-orthophotos are large (≈5–10GB each), so if you’d like to verify that the read stalls occur in the problematic scenario, tell me what’s the best way to share artifacts with you. Finally, this work was supported by an LLM, and the investigation itself took about a week of iterative runs to reproduce the exact prod behavior. |
spwoodcock
left a comment
There was a problem hiding this comment.
This is really nice - I only left two comments, for the same line of code - hopefully useful 😄
|
Thanks for the review @spwoodcock, both are good catches. Addressed in the latest push: 1. File-handle bound. Agreed on the EMFILE risk now that split-merge passes MERGE_WORKER_CAP = 16
if max_workers > MERGE_WORKER_CAP:
log.ODM_INFO("Capping orthophoto merge workers %d -> %d to limit open file handles"
% (max_workers, MERGE_WORKER_CAP))
max_workers = MERGE_WORKER_CAPThe cap is applied once inside 2. Skip cut opens in skip-blending mode. Done. The cut rasters are only read in the third (blend) pass, which is skipped in that mode, so there's no reason to open them: srcs = [(rasterio.open(o), None if merge_skip_blending else rasterio.open(c))
for o, c in inputs]The cleanup loop now guards the |
Assert the gated read is pixel-identical to a boundless=True read across all three branches (fully inside, fully outside each edge, partial-edge overlap), which is the correctness property the gated-read optimization relies on. Runs under the ODM image's rasterio/GDAL: python3 -m unittest tests.test_orthophoto.
…orkers) With boundless reads gated (previous commit), parallelize the per-block blend loop. Blocks are computed in a ThreadPoolExecutor and written from a single thread in strict block order with a bounded look-ahead (cap = 2 * max_workers), so writes to the compressed, tiled GeoTIFF stay sequential and incrementally flushable and memory stays small. GDAL's block cache is bounded during the merge (restored on exit). Per-thread source handles (GDAL/rasterio datasets are not thread-safe). Preserves --merge-skip-blending. Wired from stages/splitmerge.py as max_workers=args.max_concurrency. max_workers<=1 is byte-for-byte identical to the original serial loop.
Address review feedback on the parallel merge: - Hard-cap merge worker threads at MERGE_WORKER_CAP=16. Each worker opens every input pair, so handles scale as 2 * pairs * max_workers; with many submodels on a high-core machine (split-merge now passes max_concurrency) this could exhaust RLIMIT_NOFILE (EMFILE). A fixed cap keeps a meaningful speedup and is more portable than probing RLIMIT_NOFILE (absent on Windows). - Skip opening the cut rasters in --merge-skip-blending mode (they are never read), and guard their cleanup for the resulting None handle.
Re-stacking the parallel merge onto the updated gated-reads base (which defines _read_window_gated right before merge()) left a second copy of that helper at the top of the file; remove it and keep the canonical one. Also replace the remaining em-dashes in the bounded-cache / block-loop comments with plain punctuation.
13e9fa1 to
32af9dc
Compare
|
@spwoodcock let me know if there is anything else I can do for this PR to get merged. |
spwoodcock
left a comment
There was a problem hiding this comment.
Good to merge imo! Thanks again 🎉
|
Great to hear! I can't merge myself as I don't have the permissions on this repo. Looking forward to seeing this change land in ODM 🎉 |
Summary
Parallelizes the per-block blend loop in
merge(), behind amax_workersparameter (default1= unchanged serial behavior), wired fromstages/splitmerge.pyasargs.max_concurrency.Why it needs the gated reads (#2036)
The block loop is embarrassingly parallel, but a naive thread pool stalls: rasterio's
boundless=Truereads serialize a VRT (_serialize_xml) on every read, which under concurrency dominates and effectively hangs the merge (workers park in_serialize_xml). #2036 replaces those with gated reads (no VRT for in-bounds windows), which makes a parallel loop viable.Parallelization
ThreadPoolExecutor; one thread writes them in strict block order with a bounded look-ahead (cap = 2 * max_workers). A naive "write each block as it finishes" version can't flush incrementally — GDAL needs writes in row-major (block) order — so dirty blocks accumulate in RAM until it OOMs; the in-order writer keeps writes sequential/flushable and memory small.--merge-skip-blending.max_workers <= 1is a plain serial compute-then-write path, byte-for-byte identical to the original loop.Correctness
max_workers=1(serial, ~28 min) andmax_workers=16(~8 min) — the in-order writer makes the result independent of worker count, and the serial/default path completes cleanly with no read↔write stall.Notes
max_workers=1).