Array Jobs for LiDAR Tiles in AWS Batch
TL;DR: Write the tile list to a manifest in S3, submit one array job with arrayProperties.size equal to the tile count (up to 10,000), and have the container read AWS_BATCH_JOB_ARRAY_INDEX to pick its line from the manifest. Each child is an independent job with its own retries and logs, and a second array job can depend on the first with N_TO_N so tile i of stage two starts as soon as tile i of stage one finishes.
# Context and Motivation
This guide is part of AWS Batch Processing. Submitting one Batch job per tile works, but at thousands of tiles it means thousands of SubmitJob calls, API throttling, and a job list nobody can read. An array job is a single submission that fans out into child jobs, each told its index through an environment variable. The submission is instant, the console groups the children under one parent, and the retry strategy applies to each child independently.
For LiDAR the pattern maps cleanly: index i is tile i of a manifest, and the container is the same PDAL image used for single-tile runs, as described in scaling PDAL tile processing with AWS Batch.
# Prerequisites and Assumptions
- An AWS Batch compute environment and job queue (EC2 or Fargate), and a job definition using a PDAL image in ECR.
- An IAM job role with read access to the input bucket and write access to the output bucket.
- A tile list, for example from a tile index built with
pdal tindexor an S3 listing.
# Step-by-Step Implementation
# Step 1 — Write the manifest
One tile key per line, sorted, uploaded next to the run’s outputs so every run records exactly what it processed.
# Step 2 — Write the entrypoint
The container script reads AWS_BATCH_JOB_ARRAY_INDEX and MANIFEST_URI, fetches the manifest, and processes line index.
# Step 3 — Size the child job
Set vcpus and memory from a measured tile: PDAL memory scales with points per tile, so measure the largest tile rather than the average.
# Step 4 — Submit the array job
submit_job with arrayProperties={"size": n} and a retryStrategy that retries on host termination.
# Step 5 — Chain stages with N_TO_N
A second array job of the same size with dependsOn=[{"jobId": first, "type": "N_TO_N"}] starts child i as soon as child i of the first job succeeds.
# Complete Working Example
Entrypoint inside the container:
#!/usr/bin/env python3
"""entrypoint.py: process one tile chosen by the array index."""
import json
import os
import sys
import boto3
import pdal
s3 = boto3.client("s3")
idx = int(os.environ.get("AWS_BATCH_JOB_ARRAY_INDEX", "0"))
bucket, key = os.environ["MANIFEST_URI"].removeprefix("s3://").split("/", 1)
tiles = s3.get_object(Bucket=bucket, Key=key)["Body"].read().decode().split()
tile = tiles[idx]
stem = tile.rsplit("/", 1)[-1].removesuffix(".laz")
out = f"{os.environ['OUT_PREFIX']}/{stem}.tif"
spec = {"pipeline": [
f"/vsis3/lidar-in/{tile}",
{"type": "filters.smrf", "slope": 0.15, "window": 18},
{"type": "filters.range", "limits": "Classification[2:2]"},
{"type": "writers.gdal", "filename": f"/vsis3/lidar-out/{out}",
"resolution": 1.0, "output_type": "idw", "data_type": "float32"},
]}
n = pdal.Pipeline(json.dumps(spec)).execute()
print(json.dumps({"index": idx, "tile": tile, "ground_points": n}))
sys.exit(0 if n else 3)Submission from a workstation or an orchestrator:
import boto3
batch, s3 = boto3.client("batch"), boto3.client("s3")
tiles = sorted(o["Key"] for p in s3.get_paginator("list_objects_v2")
.paginate(Bucket="lidar-in", Prefix="tiles/") for o in p.get("Contents", [])
if o["Key"].endswith(".laz"))
s3.put_object(Bucket="lidar-out", Key="runs/2026-09-18/manifest.txt",
Body="\n".join(tiles).encode())
env = [{"name": "MANIFEST_URI", "value": "s3://lidar-out/runs/2026-09-18/manifest.txt"},
{"name": "OUT_PREFIX", "value": "runs/2026-09-18/dtm"}]
dtm = batch.submit_job(
jobName="dtm-2026-09-18", jobQueue="lidar-spot", jobDefinition="pdal-dtm:7",
arrayProperties={"size": len(tiles)},
containerOverrides={"environment": env,
"resourceRequirements": [{"type": "VCPU", "value": "2"},
{"type": "MEMORY", "value": "8192"}]},
retryStrategy={"attempts": 3, "evaluateOnExit": [
{"onStatusReason": "Host EC2*", "action": "RETRY"},
{"onExitCode": "3", "action": "EXIT"},
{"onReason": "*", "action": "EXIT"}]},
)["jobId"]
hillshade = batch.submit_job(
jobName="hillshade-2026-09-18", jobQueue="lidar-spot", jobDefinition="gdal-hillshade:2",
arrayProperties={"size": len(tiles)},
dependsOn=[{"jobId": dtm, "type": "N_TO_N"}],
containerOverrides={"environment": env},
)["jobId"]
print(dtm, hillshade)# Sizing Children From the Largest Tile
Batch places child jobs by their declared vcpus and memory. If the declaration is too small, the container is killed with OutOfMemoryError: Container killed due to memory usage and retries fail the same way; if it is too large, fewer children fit per instance and the run takes longer and costs more. Tiles in a project are rarely uniform — dense urban tiles and tiles covering overlap between flightlines can hold several times the points of a rural tile — so size the job definition from the largest tile, not the median. Measure peak memory on that tile locally as described in measuring peak memory of a PDAL pipeline, then add around 25% headroom.
When the spread is extreme, split the manifest into two array jobs — normal tiles with a small memory request and a handful of dense tiles with a large one. The cost saving from packing the common case tightly usually outweighs the small complexity of two submissions.
# Key Parameter Table
| Setting | Typical | Notes |
|---|---|---|
arrayProperties.size |
2–10,000 | One child per tile; split larger lists |
AWS_BATCH_JOB_ARRAY_INDEX |
0…size−1 | Set in each child |
resourceRequirements VCPU |
1–4 | PDAL is mostly single-threaded per tile |
resourceRequirements MEMORY |
from largest tile | +25% headroom |
retryStrategy.attempts |
2–3 | Per child |
evaluateOnExit |
retry on Host EC2* |
Exit early on permanent codes |
dependsOn type |
N_TO_N / sequential |
Per-index vs whole-job dependency |
# Verification
- Child count. The parent job’s array status counts total children equal to the manifest length.
- Outputs. The output prefix contains one raster per manifest line; list both and diff.
- Chaining. Hillshade children start while DTM children are still running, which is visible in the start times.
# Gotchas and Edge Cases
The 10,000 limit. An array job holds at most 10,000 children. For larger projects, split the manifest into chunks and submit one array job per chunk, or have each child process a small group of tiles.
Manifest immutability. Children read the manifest when they start, possibly hours after submission. Never overwrite a manifest during a run; write each run’s manifest to a new key.
Failure of the parent. The parent array job is marked failed if any child fails after its retries, and an N_TO_N dependant child whose upstream child failed never runs. Collect failures from the child statuses and resubmit only those tiles with a smaller manifest.
# Frequently Asked Questions
How does a child job know which tile to process?
AWS Batch sets AWS_BATCH_JOB_ARRAY_INDEX in every child. The container reads it and takes that line of a manifest file listing the tiles, so the same image serves every child.
How many tiles can one array job handle?
Up to 10,000 children. Split larger tile lists into several manifests and array jobs, or let each child process a small group of tiles.
What does an N_TO_N dependency do?
It links two array jobs of the same size index by index: child i of the second job starts once child i of the first succeeds, instead of waiting for the entire first job.
How should I set memory for PDAL array children?
Measure peak memory on the densest tile and add about a quarter as headroom. If tile sizes vary widely, submit dense tiles as a separate array job with a larger memory request.
# Related
- AWS Batch Processing — running PDAL in AWS Batch
- Scaling PDAL Tile Processing with AWS Batch — environments and job definitions
- Handling Spot Interruptions in PDAL Batch Jobs — retry strategies
- Estimating Cloud Cost per LiDAR Tile — pricing the run
- Dynamic Task Mapping for LiDAR Tiles — the Airflow equivalent