Async Execution for Large Grids

Synchronous grid generation hits a hard wall the moment a synthetic spatial extent grows past what fits in RAM or what a single thread can serialize before downstream sinks stall. This page covers how to restructure large-grid synthesis as a non-blocking, backpressure-aware async pipeline. Part of Spatial Distribution & Pattern Generation — it supplies the execution substrate that the point, surface, and zone generators in that area rely on once their extents stop being toy-sized.

Problem Framing: Where Synchronous Grid Generation Breaks

A blocking generator processes one tile, serializes it, waits for the write to land, then starts the next. At small extents this is fine. At sub-meter resolution across a metropolitan or regional footprint, three failure signatures appear, and they appear together:

  • Memory ceiling. Materializing a full grid — or even a full row of tiles — before flushing forces the allocator to hold tens of gigabytes of geometry and attribute arrays. The process is killed by the OOM reaper, or it survives by paging and slows to a crawl.
  • I/O-bound idle CPU. When the writer blocks on cloud object storage or a distributed database, every CPU core sits idle waiting for the socket. Throughput collapses to the latency of the slowest write rather than the speed of geometry computation.
  • Boundary corruption. Naive parallelism that shards work across threads without ordering guarantees produces topology errors at tile seams: duplicated edges, gaps, and inconsistent random seeds that break statistical stationarity across the join.

Async execution attacks all three at once. A bounded producer/consumer task graph keeps only a few tiles in flight, so memory stays flat. Cooperative scheduling yields the CPU during I/O waits so cores stay saturated with geometry work. Deterministic, coordinate-derived seeding plus explicit boundary ordering keeps seams correct even while independent interior tiles run concurrently.

Bounded producer/consumer task graph with backpressure from the sink to the producer Left to right: a partition producer walks the extent and pushes one lazy coroutine per tile into an asyncio.Queue with a fixed maxsize, shown as a row of slots where three are filled and one is free. A consumer pulls each task, awaits the heavy geometry compute throttled at this point, and streams the result into a GeoParquet or Zarr sink that uses zero-copy Arrow buffers. A dashed loop over the top shows backpressure: when the sink throttles, the queue fills, put() suspends the producer, and enqueue pauses. A caption notes that peak memory equals queue_depth tiles in flight rather than the whole grid. backpressure sink throttles → queue fills → put() suspends → enqueue pauses Partition producer walks extent on stride 1 lazy coroutine / tile put() asyncio.Queue(maxsize=N) tile tile tile free bounded depth — only queue_depth tiles in flight full ⇒ put() suspends the producer get() Consumer await task — heavy compute throttled here stream GeoParquet / Zarr sink zero-copy Arrow buffers + spatial-index metadata Peak memory = queue_depth tiles in flight — flat regardless of grid size

Prerequisites & Toolchain

Pin the spatial and async stack explicitly; large-grid pipelines are exactly where silent minor-version drift turns into non-reproducible output.

python>=3.10
geopandas==0.14.*
shapely==2.*
pyproj==3.*
gdal==3.*
numpy>=1.26
aiofiles>=23.2          # async file I/O
pyarrow>=15.0           # zero-copy Arrow / GeoParquet
zarr>=2.17              # chunked array storage for raster grids

Two environment variables matter before the first tile is generated. PROJ_LIB must resolve to the PROJ data directory that matches the pinned pyproj, or projection lookups fall back inconsistently across workers and silently corrupt the coordinate reference system contract the whole grid is supposed to honor. GDAL_NUM_THREADS=1 is recommended inside async workers so GDAL’s own threading does not contend with the event loop’s cooperative scheduling — concurrency is owned by asyncio, not by the underlying C libraries.

A fast prerequisite check before any long run:

python
import os, pyproj, asyncio
assert os.environ.get("PROJ_LIB"), "PROJ_LIB must be set for deterministic CRS lookups"
assert pyproj.proj_version_str.startswith("9"), "pin PROJ 9.x to match pyproj 3.x"
assert hasattr(asyncio, "TaskGroup"), "Python 3.11+ recommended for TaskGroup; 3.10 falls back to gather"
print("toolchain OK")

Core Concept: Cooperative Concurrency Over a Bounded Queue

Asynchronous grid execution shifts spatial workloads from monolithic, thread-bound execution to cooperative concurrency. A single event loop schedules tile generation, applies spatial operators, and flushes results without blocking the main thread. The architecture rests on three primitives:

  1. A bounded work queue. An asyncio.Queue(maxsize=N) caps the number of in-flight tiles. When the consumer falls behind, queue.put() suspends the producer — this is the backpressure mechanism, and it is what keeps memory flat regardless of grid size.
  2. Yield points inside compute. Every heavy tile operation awaits at least once (an I/O boundary or an explicit await asyncio.sleep(0)), returning control to the loop so other tiles’ I/O can progress. Python’s cooperative scheduling model, documented in the Python asyncio reference, yields during I/O waits so CPU cores stay busy with geometry rather than idle on socket waits.
  3. Deterministic boundary ordering. Independent interior tiles run in any order, but boundary-adjacent tiles are stitched under an explicit ordering so edge topology resolves identically on every run.

Backpressure prevents worker saturation when downstream sinks — cloud object storage, distributed databases, or columnar data lakes — throttle ingestion. The queue_depth parameter is the single most important tuning knob: it is the ratio of compute latency to write latency, expressed as a buffer size.

Within pattern generation, async execution preserves statistical stationarity by ensuring tile boundaries are processed with consistent random seeds and deterministic boundary conditions. Each tile derives its seed from its integer coordinates and the pipeline version hash, so a tile at (i, j) produces identical geometry whether it runs first, last, or concurrently with its neighbors. That property is what makes a concurrent generator reproducible at all.

Step-by-Step Implementation

Step 1 — Partition the grid into independently seeded tile tasks

The producer walks the extent on a fixed stride (an adaptive quadtree decomposition is a drop-in alternative for skewed densities) and enqueues one lazy coroutine per tile. A bounded queue means the producer naturally pauses when the consumer lags.

python
import asyncio
import numpy as np
from typing import Tuple, Dict, Any
import aiofiles
import json
import time


class AsyncGridGenerator:
    def __init__(self, bounds: Tuple[float, float, float, float],
                 resolution: float, chunk_size: int = 1024,
                 queue_depth: int = 4):
        self.bounds = bounds          # (minx, miny, maxx, maxy) in EPSG:4326
        self.resolution = resolution
        self.chunk_size = chunk_size
        self.queue = asyncio.Queue(maxsize=queue_depth)
        self._running = False

    async def _generate_tile(self, tile_id: str,
                             bounds: Tuple[float, float, float, float],
                             seed: int) -> Dict[str, Any]:
        """Simulate heavy spatial computation without blocking the loop."""
        await asyncio.sleep(0.01)              # yield to the event loop
        rng = np.random.default_rng(seed)      # deterministic per-tile RNG
        # Placeholder for real geometry/attribute synthesis:
        data = {
            "tile_id": tile_id,
            "bounds": bounds,
            "features": int(rng.integers(100, 1000)),
            "timestamp": time.time_ns(),
        }
        return data

    async def _producer(self):
        """Partition the grid and push lazy tasks onto the bounded queue."""
        minx, miny, maxx, maxy = self.bounds
        step_x = (maxx - minx) / self.chunk_size
        step_y = (maxy - miny) / self.chunk_size

        for i in range(self.chunk_size):
            for j in range(self.chunk_size):
                tile_bounds = (
                    minx + i * step_x,
                    miny + j * step_y,
                    minx + (i + 1) * step_x,
                    miny + (j + 1) * step_y,
                )
                # Seed derived from tile coordinates -> reproducible output.
                seed = hash(f"{i}_{j}") & 0xFFFFFFFF
                task = self._generate_tile(f"tile_{i}_{j}", tile_bounds, seed)
                await self.queue.put(task)     # suspends when queue is full
        await self.queue.put(None)             # completion sentinel

Step 2 — Drain the queue and stream results under backpressure

The consumer resolves each queued coroutine and streams the result straight to disk. Because the producer enqueues lazy coroutines, the heavy compute only fires here, inside the bounded-queue throttle — so peak memory is queue_depth tiles, not the whole grid.

python
    async def _consumer(self, output_path: str):
        """Stream results to disk with backpressure-aware flushing."""
        async with aiofiles.open(output_path, mode="w") as f:
            await f.write("[\n")
            first = True
            while True:
                task = await self.queue.get()
                if task is None:
                    break
                result = await task            # heavy compute, throttled here
                if not first:
                    await f.write(",\n")
                await f.write(json.dumps(result))
                first = False
            await f.write("\n]")

    async def run(self, output_path: str):
        self._running = True
        await asyncio.gather(self._producer(), self._consumer(output_path))
        self._running = False

Step 3 — Wire in the spatial generators per tile

Each tile is an independent sampling domain, which is exactly what the generators in this area expect. Swap the placeholder in _generate_tile for a real synthesis call: route discrete events through the point process simulation models so each tile draws localized event densities from its own RNG state without cross-contaminating its neighbors; build continuous surfaces by coupling to density mapping and heat generation with halo buffers across seams; and construct zones with the polygon tessellation algorithms that run a per-chunk constrained Delaunay or Voronoi pass. The async scheduler coordinates these independent samplers and merges their outputs into a unified spatial index only after all boundary conditions resolve.

Step 4 — Serialize with zero-copy buffers

Replace the JSON writer with a columnar sink once tiles carry real geometry. Stream tile outputs through async generators into GeoParquet or Zarr, buffering chunks until a configurable threshold, then flushing with spatial-index metadata. Serializing through zero-copy Arrow buffers aligns with columnar storage engines and eliminates redundant allocations during write, which is what avoids heap fragmentation during large tessellation passes.

Validation & Testing

Async execution introduces non-deterministic scheduling; the test suite’s job is to prove that scheduling order never leaks into output. Three assertions belong in the CI gate, the same gate that the broader CI/CD integration for spatial data workflow enforces:

python
import asyncio, json, pytest


@pytest.mark.asyncio
async def test_reproducible_across_queue_depths(tmp_path):
    """Same extent + seed must yield identical tiles regardless of concurrency."""
    bounds = (0.0, 0.0, 1.0, 1.0)  # EPSG:4326 unit cell
    out_a, out_b = tmp_path / "a.json", tmp_path / "b.json"

    await AsyncGridGenerator(bounds, 0.01, chunk_size=8, queue_depth=2).run(str(out_a))
    await AsyncGridGenerator(bounds, 0.01, chunk_size=8, queue_depth=16).run(str(out_b))

    a = {t["tile_id"]: t["features"] for t in json.loads(out_a.read_text())}
    b = {t["tile_id"]: t["features"] for t in json.loads(out_b.read_text())}
    assert a == b, "tile output changed with queue_depth -> seeding is order-dependent"


@pytest.mark.asyncio
async def test_tile_count_matches_grid(tmp_path):
    out = tmp_path / "grid.json"
    await AsyncGridGenerator((0, 0, 1, 1), 0.01, chunk_size=8, queue_depth=4).run(str(out))
    tiles = json.loads(out.read_text())
    assert len(tiles) == 8 * 8, "missing or duplicated tiles at boundaries"

Beyond shape and reproducibility, compare tile-level statistical moments — mean, variance, and spatial autocorrelation — against the baseline distribution. Anchoring those thresholds to the same scoring used in realism metrics and evaluation keeps the async path honest: a grid that passes shape checks but drifts in Moran’s I or Wasserstein distance is still a regression. Serialize every validation result as a pipeline artifact so a release is traceable from raw bounds to final raster.

Performance & Scale Considerations

The optimal queue_depth scales with the ratio of compute latency to write latency. When downstream storage shows high tail latency, a deeper queue prevents worker starvation but raises memory pressure — the two are in direct tension, and the sweet spot is empirical per sink. Start at 4, profile, and raise it only while resident memory stays flat.

For grids that exceed a single machine, the bounded-queue pattern composes with distributed schedulers rather than competing with them. The same chunking discipline carries straight into scaling density-based spatial generation with Dask, where lazy evaluation, automatic memory spilling, and fault-tolerant retries extend the in-process model across many worker machines. Two local optimizations matter before reaching for distribution: align chunk sizes to L3 cache boundaries so per-tile arrays stay hot, and back intermediate density matrices with memory-mapped arrays so large tessellation passes never fragment the heap.

KaTeX makes the privacy cost explicit when async pipelines inject differential privacy mechanisms at the streaming-flush boundary. Adding calibrated Laplace noise to an aggregated tile count cc before it leaves the worker context releases c~=c+Lap(Δf/ε)\tilde{c} = c + \operatorname{Lap}(\Delta f / \varepsilon), where Δf=1\Delta f = 1 for a single-count query, so each tile spends a slice of the run-level ε\varepsilon budget without degrading spatial resolution. Composed ε\varepsilon must be tracked across tiles, not just per tile.

Failure Modes & Troubleshooting

Checkpoint and recovery state machine for a crash-safe async grid run Four states. Generating produces a tile; on a successful atomic write (write-temp-then-rename) plus a manifest append it transitions to Flushed, the checkpoint state. A looping arrow returns from Flushed to Generating to advance to the next tile coordinate. A worker death from either Generating or Flushed transitions to Crashed, shown with dashed failure arrows. From Crashed the restart reads the manifest into Resuming, which re-enters Generating at the last flushed boundary and skips already-committed tiles, making recovery idempotent. A legend marks solid arrows as normal progress and dashed arrows as failure and recovery. Generating synth tile (i, j) seed = f(i, j, version) Flushed · checkpoint tile committed atomically boundary written to manifest Crashed worker dies mid-run Resuming read manifest of committed boundaries atomic write + manifest append next tile · advance (i, j) crash while generating crash post-flush restart resume at last flushed boundary — skip committed tiles (idempotent) normal progress failure · recovery

Worker starvation under a shallow queue. When queue_depth is too small relative to write latency, consumers drain the queue faster than producers can refill it and CPUs idle between writes. Diagnose by logging queue occupancy; if it sits near zero, raise queue_depth until occupancy stabilizes above one.

Memory pressure under a deep queue. The mirror image: a queue depth chosen to hide tail latency holds too many heavy tiles resident. Resident set climbs run-over-run until the OOM reaper fires. Cap queue_depth and shrink chunk_size so each in-flight tile is smaller, rather than reducing concurrency to one.

Boundary topology corruption. Independent tiles stitched without ordering produce slivers, gaps, or doubled edges at seams. The root cause is almost always a per-tile RNG seeded from wall-clock time or PID instead of tile coordinates. Derive every seed from (i, j) plus the pipeline version hash and stitch boundary-adjacent tiles under an explicit priority so edges resolve deterministically.

Silent write throttling from the sink. A cloud object store that rate-limits returns success with rising latency, so the pipeline appears healthy while throughput quietly collapses. Add a circuit breaker that pauses tile generation when measured write throughput drops below a floor, and surface backpressure events in structured logs:

yaml
# async_grid.yaml — circuit breaker + backpressure policy
executor:
  queue_depth: 4
  chunk_size: 1024
  crs: "EPSG:4326"
circuit_breaker:
  min_write_mbps: 25          # pause generation below this sustained rate
  cooldown_seconds: 10        # wait before probing the sink again
  max_consecutive_trips: 3    # abort the run after repeated stalls
checkpointing:
  enabled: true
  manifest: "s3://grids/run-{version}/manifest.json"

Non-idempotent recovery after a crash. If a worker dies mid-flush and the restart re-emits a partially written tile, downstream consumers see corruption. Make tile writes atomic (write-temp-then-rename) and record a spatial-index manifest of committed tile boundaries. On restart, the scheduler reads the manifest and resumes from the last successfully flushed boundary, skipping committed tiles instead of regenerating them.

Frequently Asked Questions

Does async execution speed up CPU-bound geometry computation?
Not on its own. A single event loop runs on one core, so async hides I/O latency but does not parallelize raw computation. The win for large grids is that cores stay saturated with geometry work instead of idling on writes. For genuine CPU parallelism, combine the bounded-queue pattern with a process pool or a distributed scheduler such as Dask.
How do I keep output reproducible when tiles run in non-deterministic order?
Derive every tile's random seed from its integer coordinates and the pipeline version hash, never from wall-clock time or PID. Then a tile at (i, j) produces identical geometry regardless of when it runs, and the only ordering that must be fixed is the stitching of boundary-adjacent tiles. A CI test that runs the same extent at two different queue depths and asserts identical output catches any order leakage.
What value should queue_depth start at?
Start at 4 and profile. queue_depth is effectively the ratio of compute latency to write latency expressed as a buffer size. Raise it while resident memory stays flat and queue occupancy holds above one; stop as soon as memory climbs run-over-run. There is no universal value because it depends on tile cost and sink latency.
Where in the async pipeline should differential privacy noise be applied?
At the streaming-flush boundary, inside the worker context, before any aggregate leaves the process. Adding calibrated Laplace or Gaussian noise to per-tile counts there lets each tile spend a slice of the run-level epsilon budget. Track composed epsilon across all tiles so repeated flushes do not silently exceed the declared bound.

Conclusion

Async execution is not an optional optimization for large-grid synthetic spatial generation — it is the prerequisite for stable throughput at regional or continental extents. The bounded-queue producer/consumer pattern delivers backpressure, deterministic seeding, and stream-based I/O in a testable, auditable structure. Couple it with coordinate-derived seeds, tile-level validation gates, atomic checkpointed writes, and per-tile privacy accounting, and synthetic grid generation scales with available I/O bandwidth rather than slamming into the memory ceiling of synchronous batch processing.