Parallel Batch Rendering with ProcessPoolExecutor
A nightly job that renders 900 spatial PDFs one after another wastes every core but one. PDF rendering is CPU-bound — Pango shapes text, Cairo rasterizes, Matplotlib draws figures — so the right tool is concurrent.futures.ProcessPoolExecutor, which sidesteps the GIL by running each render in a separate OS process. This guide, part of the Scheduled Batch Report Generation workflow, goes deep on the worker-pool mechanics that matter in production: initializing the heavy Jinja2 Environment once per worker, chunking to amortize inter-process overhead, capping memory with max_workers and worker recycling, and collecting per-report failures so one broken dataset never sinks the whole batch.
Prerequisites
- Python 3.10+ (
concurrent.futuresis in the standard library) - A single-report render function you can call in isolation — for example one wrapping
weasyprint - Enough RAM headroom to hold
max_workersconcurrent renders; know your per-render peak RSS - Familiarity with passing data to templates from Loop Mapping for Dynamic Attribute Tables, since each worker renders the same template family
Step 1: Choose Processes Over Threads
ThreadPoolExecutor shares one interpreter and one GIL, so CPU-bound renders run effectively serially even with many threads. ProcessPoolExecutor forks separate interpreters, each with its own GIL, giving true parallelism on multiple cores. The trade-off is that arguments and return values are pickled across the process boundary — so pass small, picklable job descriptors, not open file handles or GeoDataFrames.
# render_job.py
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class RenderJob:
"""A small, picklable descriptor — NOT the data itself."""
report_id: str
dataset_path: str # path, not a loaded GeoDataFrame
out_path: str
Keeping RenderJob to plain strings means pickling is cheap and no unpicklable spatial object ever crosses the process boundary.
Step 2: Initialize Heavy State Per Worker
Building a Jinja2 Environment — loading templates, compiling macros, registering filters — is expensive. Doing it inside the per-task function repeats that cost 900 times. The initializer hook runs once per worker process at startup; stash the Environment in a module global the task function reads.
# worker.py
from __future__ import annotations
from jinja2 import Environment, FileSystemLoader, select_autoescape
_ENV: Environment | None = None
def init_worker(template_dir: str) -> None:
"""Runs once per worker process, not once per task."""
global _ENV
_ENV = Environment(
loader=FileSystemLoader(template_dir),
autoescape=select_autoescape(["html", "xml"]),
)
def render_one(job: "RenderJob") -> tuple[str, str]:
import geopandas as gpd
from weasyprint import HTML
assert _ENV is not None, "worker not initialized"
gdf = gpd.read_file(job.dataset_path)
context = {"rows": gdf.drop(columns="geometry").to_dict("records")}
html = _ENV.get_template("report.html").render(**context)
HTML(string=html).write_pdf(job.out_path)
return (job.report_id, job.out_path)
Amortizing the Environment across a worker’s whole task run is the single biggest throughput win — often larger than adding another core.
Step 3: Submit Tasks and Chunk the Work
executor.map with a chunksize batches several jobs into one IPC round-trip, which matters when individual tasks are short relative to pickling cost. For longer renders, submit + as_completed gives finer-grained result handling.
# batch.py
from __future__ import annotations
from concurrent.futures import ProcessPoolExecutor, as_completed
from worker import init_worker, render_one
from render_job import RenderJob
def run_batch(jobs: list[RenderJob], template_dir: str, max_workers: int) -> None:
with ProcessPoolExecutor(
max_workers=max_workers,
initializer=init_worker,
initargs=(template_dir,),
) as pool:
futures = {pool.submit(render_one, j): j for j in jobs}
for fut in as_completed(futures):
job = futures[fut]
rid, out = fut.result() # re-raises worker exceptions here
print(f"rendered {rid} -> {out}")
A good starting chunksize for executor.map is len(jobs) // (max_workers * 4), which keeps every worker fed while still rebalancing near the end of the batch.
Step 4: Bound Memory With max_workers and maxtasksperchild
Each worker renders independently, so peak memory scales with max_workers. If one render peaks at 400 MB and you set eight workers, you need ~3.2 GB of headroom or the OOM killer strikes. Cap workers to min(os.cpu_count(), ram_budget // peak_rss). Long-running workers can also leak native memory (Cairo/Pango), so recycle them with max_tasks_per_child (Python 3.11+).
import os
def safe_worker_count(peak_rss_mb: int, ram_budget_mb: int) -> int:
by_cpu = os.cpu_count() or 2
by_ram = max(1, ram_budget_mb // peak_rss_mb)
return min(by_cpu, by_ram)
pool = ProcessPoolExecutor(
max_workers=safe_worker_count(peak_rss_mb=400, ram_budget_mb=6000),
initializer=init_worker,
initargs=("templates",),
max_tasks_per_child=50, # recycle a worker every 50 renders to cap native leaks
)
On a constrained CI runner, this memory ceiling is why a runner-per-region matrix (see Scheduling Nightly PDF Runs with Cron and GitHub Actions) can be safer than one giant in-process pool.
Step 5: Collect Failures Without Losing the Batch
The whole point of batch rendering is resilience: one malformed dataset must not abort the other 899 reports. Catch each future’s exception individually and record it, then decide the exit code from the tally.
# batch.py (resilient variant)
from __future__ import annotations
import logging
from concurrent.futures import ProcessPoolExecutor, as_completed
log = logging.getLogger("batch")
def run_batch_resilient(jobs, template_dir: str, max_workers: int) -> int:
failures: list[tuple[str, str]] = []
with ProcessPoolExecutor(max_workers=max_workers,
initializer=init_worker,
initargs=(template_dir,)) as pool:
futures = {pool.submit(render_one, j): j for j in jobs}
for fut in as_completed(futures):
job = futures[fut]
try:
fut.result()
except Exception as exc: # noqa: BLE001
failures.append((job.report_id, repr(exc)))
log.error("render failed for %s: %r", job.report_id, exc)
for rid, err in failures:
log.warning("FAILED %s -> %s", rid, err)
return 1 if failures else 0
Returning a non-zero exit code only when there are failures lets the scheduler flag a degraded run while still keeping every successful PDF. Those survivors are then handed to durable storage, as covered in Versioning Generated PDFs in S3 Object Storage.
Key Parameters / Configuration Reference
| Parameter | Type | Default | Effect |
|---|---|---|---|
max_workers |
int |
os.cpu_count() |
Concurrent processes; bound by RAM, not just cores |
initializer |
callable |
None |
Runs once per worker to build the Environment |
initargs |
tuple |
() |
Args passed to the initializer (template dir) |
max_tasks_per_child |
int | None |
None |
Recycle a worker after N tasks to cap native leaks |
chunksize (map) |
int |
1 |
Jobs per IPC batch; raise for short tasks |
Common Pitfalls
- Rebuilding the Environment per task. Constructing the Jinja2
Environmentinsiderender_onethrows away the initializer’s benefit and dominates runtime. Build it once ininit_worker. - Passing unpicklable arguments. A GeoDataFrame, open file, or DB connection cannot cross the process boundary and raises
PicklingError. Pass paths and small descriptors; load data inside the worker. - Setting
max_workersfrom cores alone. Eight renders at 400 MB each overrun a 4 GB runner. Compute the ceiling fromram_budget // peak_rssand take the min with core count. - Letting one exception kill the pool. Calling
fut.result()without a try/except re-raises and, combined with thewithblock exit, can drop in-flight work. Catch per-future and tally failures.
Verification
Confirm the pool actually parallelizes and that a deliberately broken job is isolated rather than fatal:
import time
from batch import run_batch_resilient
start = time.perf_counter()
exit_code = run_batch_resilient(jobs, "templates", max_workers=4)
elapsed = time.perf_counter() - start
# With 4 workers, wall time should be well under the serial sum.
print(f"exit={exit_code} wall={elapsed:.1f}s for {len(jobs)} reports")
assert exit_code in (0, 1) # 1 only when some report failed, others still rendered
Compare the parallel wall time against a serial baseline: a healthy four-worker run on CPU-bound renders lands near one-third to one-quarter of the serial total, and a single injected failure returns exit code 1 while every other PDF is still written.
Related
- Scheduled Batch Report Generation — parent section covering the overall batch workflow
- Scheduling Nightly PDF Runs with Cron and GitHub Actions — the sibling guide that triggers this batch on a schedule
- Loop Mapping for Dynamic Attribute Tables — the template pattern each worker renders
- Versioning Generated PDFs in S3 Object Storage — storing the batch’s successful outputs