AWS Batch Inference¶
Scale inference to hundreds of thousands of bridges in parallel using AWS Batch array jobs with SPOT instance support. Infrastructure is managed with Terraform; job submission, entrypoint, and post-run audit are Python scripts.
Each array child downloads the manifest and model, computes its chunk, then processes bridges one at a time: download → infer → upload → cleanup. Per-bridge upload with skip-if-exists makes SPOT interruption cheap (loses at most 1 in-progress bridge).
Key files:
| File | Purpose |
|---|---|
infra/terraform/bootstrap/ |
S3 bucket for Terraform remote state (once per account) |
infra/terraform/foundation/ |
Networking (VPC, subnets - optional, skip if using existing VPC) |
infra/terraform/app/ |
Workload: IAM, ECR (optional), compute env, queue, job definition |
infra/terraform/app/terraform.tfvars |
All configurable values - gitignored; copy from .tfvars.example |
infra/terraform/app/terraform.tfvars.example |
Template with placeholder values for new setups |
.github/workflows/build-dev-images.yml |
Build and publish image to GHCR (automatic on push to main) |
scripts/build_and_push.sh |
Build Docker image and push to ECR (manual, when create_ecr = true) |
scripts/submit_batch_job.py |
Submit single or array batch jobs |
scripts/batch_entrypoint.py |
Container entrypoint - per-bridge processing loop |
scripts/audit_outputs.py |
Post-run verification - checks all expected outputs exist in S3 |
scripts/post_run_report.py |
Post-run report - audit + CloudWatch aggregation + _run_report.json |
src/s3_client.py |
Generic S3 utilities (parse URIs, download/upload) |
src/s3_paths.py |
Bridge-specific S3 path resolution (manifest → S3 keys) |
src/s3_audit.py |
Thread-pool S3 output auditor (used by audit_outputs and post_run_report) |
Overview¶
flowchart LR
subgraph S3
M[Manifest file<br/>split_test_ids.txt]
CK[Model checkpoint<br/>.ckpt]
IN[Source LAS/LAZ files<br/>per bridge]
OUT[Classified output<br/>_bridge_masked / _predicted]
end
subgraph Batch["AWS Batch Array Job"]
B0[Child 0<br/>bridges 1–150]
B1[Child 1<br/>bridges 151–300]
BN[Child N-1<br/>bridges ...]
end
M --> Batch
CK --> Batch
IN --> Batch
Batch --> OUT
Each child processes bridges one at a time in a loop:
- Downloads the full manifest and model from S3
- Computes its chunk of manifest lines based on
AWS_BATCH_JOB_ARRAY_INDEXandARRAY_SIZE - For each bridge in its chunk:
- Skip if output already exists in S3 (resumability)
- Download input LAS/LAZ from S3
- Infer using
run_inference()(model loaded once, reused for all bridges) - Upload output immediately to S3
- Cleanup local files (O(1 bridge) disk usage, not O(chunk))
Prerequisites¶
- AWS account with IAM permissions for Batch and S3 (ECR permissions also needed when
create_ecr = true) - Terraform installed
- Docker installed (for building inference images)
- Python environment for management scripts (job submission, audit, reporting):
- Option A:
conda env create -f environment-data.yaml && conda activate bridge-classify-data(Linux only - contains platform-specific packages) - Option B:
pip install boto3(minimal, cross-platform - sufficient for submit/audit/report) - Trained model checkpoint uploaded to S3
- A manifest file listing bridges to process (one per line) - see Manifest File Format. Generated by
utils/split_data.py(split_test_ids.txt) orutils/prepare_run.pyfor custom runs.
Which environment for what:
| Task | Environment |
|---|---|
| Training / inference (GPU) | Docker (recommended) or environment.yaml |
| Data processing (CPU) | environment-data.yaml |
| Job submission, audit, reporting | environment-data.yaml or pip install boto3 |
| Tests | pip install -r requirements-test.txt |
AWS Profile Configuration¶
All scripts use AWS_PROFILE as the primary credential source. Set it to the account where infrastructure was deployed (Batch, CloudWatch):
Single account (infra and data in the same account) - this is all you need. Every script falls back to AWS_PROFILE for all AWS access.
Cross-account (S3 data in a different account than Batch infra) - pass --profile to specify the S3 data profile. AWS_PROFILE still controls Batch/CloudWatch access:
# Submit: Batch uses AWS_PROFILE, manifest is read via --profile
python scripts/submit_batch_job.py --manifest s3://my-bucket/path/manifest.txt --profile data-account
# Report: S3 audit via --profile, CloudWatch via --batch-profile
python scripts/post_run_report.py --bucket my-bucket --output-prefix my-output-prefix --profile data-account --batch-profile infra-account
| Script | AWS_PROFILE |
--profile |
--batch-profile |
|---|---|---|---|
build_and_push.sh |
ECR login + push (when using ECR) | - | - |
submit_batch_job.py |
Batch job submission | S3 manifest access (optional) | - |
audit_outputs.py |
- | S3 output checks | - |
post_run_report.py |
- | S3 audit | Batch/CloudWatch queries (optional) |
Quick Start¶
1. Configure & Deploy Infrastructure¶
Follow infra/terraform/README.md to deploy infrastructure. Only the app layer is required; bootstrap and foundation are optional (see Terraform README for when to use each). Each layer has a backend.hcl.example and terraform.tfvars.example - copy both and fill in your values. For day-to-day config changes (S3 paths, model URI, instance types), only the app layer needs re-applying:
See Configuration Reference for all app-layer variables.
2. Build and Push Docker Image¶
GHCR (default): images are published automatically on push to main by the build-dev-images GitHub Actions workflow.
Tags: sha-<short> (immutable) + dev (floating).
No manual steps needed.
ECR (manual, when create_ecr = true):
export AWS_PROFILE=my-profile
export AWS_REGION=us-east-1
chmod +x ./scripts/build_and_push.sh
./scripts/build_and_push.sh
Only needed when you change code (src/, scripts/, or Dockerfile).
Changing S3 paths or inference config in infra/terraform/app/terraform.tfvars does not require a rebuild - those are environment variables in the job definition.
3. Submit a Job¶
# Dry run (preview without submitting)
python scripts/submit_batch_job.py \
--manifest s3://my-bucket/bridge-classification/ml-data/split_test_ids.txt \
--dry-run
# Submit array job from S3 manifest
python scripts/submit_batch_job.py \
--manifest s3://my-bucket/bridge-classification/ml-data/split_test_ids.txt
# Override inference mode and timeout for one run
python scripts/submit_batch_job.py \
--manifest s3://my-bucket/bridge-classification/ml-data/split_test_ids.txt \
--env INFERENCE_MODE=both \
--env BRIDGE_TIMEOUT=300
The --profile flag controls which AWS profile is used to read the manifest from S3 (for line counting).
Run tracking (_run_config.json) is saved automatically - s3_bucket and s3_output_prefix are read from terraform outputs. Override via --env S3_BUCKET=... and --env S3_OUTPUT_PREFIX=... for a different output path.
4. Monitor¶
The submit script prints a link to the Batch console. Logs are written to CloudWatch log group /aws/batch/bridge-classifier with structured fields for querying.
Log format: [Child {idx}] [bridge={bridge_id}] EVENT key=value ...
Example log lines:
[Child 42] [bridge=bridge_10598181] INFER_START (5/150) mode=masked huc=02050206 manifest_line=6305
[Child 42] [bridge=bridge_10598181] INFER_OK bridge_seconds=87.3s (5/150) huc=02050206
[Child 42] [bridge=bridge_5069009] SKIP_EXISTS (6/150) manifest_line=6306 huc=03070101
[Child 42] [bridge=bridge_9921003] INFER_FAILED reason=timeout bridge_timeout=150s huc=02050206 manifest_line=6307
[Child 42] [bridge=bridge_1234567] INFER_FAILED reason=inference_error huc=02050206 manifest_line=6308
[Child 42] [bridge=bridge_8888888] SKIP_SMALL_FILE points<100 huc=03070101 manifest_line=6309
[Child 42] SUMMARY succeeded=138 failed=3 skipped_exists=5 skipped_too_few_points=2 download_failed=2 total=150 wall_clock_seconds=14400 wall_clock_hours=4.0000
CloudWatch Insights queries:
# Failures by reason (timeout vs inference_error vs other)
fields @timestamp, @message
| filter @message like /INFER_FAILED/
| parse @message "reason=* " as reason
| stats count() by reason
# Average bridge processing time per child
fields @timestamp, @message
| filter @message like /INFER_OK/
| parse @message "bridge_seconds=*s" as seconds
| stats avg(seconds), max(seconds), count() by @logStream
# Summary across all children
fields @timestamp, @message
| filter @message like /SUMMARY/
| parse @message "succeeded=* failed=* skipped_exists=* skipped_too_few_points=* download_failed=*" as ok, fail, skip_exists, skip_few_pts, dl_fail
| stats sum(ok) as total_ok, sum(fail) as total_fail, sum(skip_exists) as total_skip_exists, sum(skip_few_pts) as total_skip_too_few_points, sum(dl_fail) as total_dl_fail
# Find OOM errors
fields @timestamp, @message
| filter @message like /CUDA out of memory/ or @message like /OutOfMemoryError/
| sort @timestamp desc
5. Audit Outputs¶
After all children complete, verify that every expected output exists in S3:
# Check all outputs exist
python scripts/audit_outputs.py \
--manifest s3://my-bucket/bridge-classification/ml-data/split_test_ids.txt \
--bucket my-bucket \
--input-prefix bridge-classification/ml-data/source \
--output-prefix scratch/myfolder/bridge-classification-test/predictions \
--mode masked \
--profile my-profile
# Write missing entries to a file for re-submission
python scripts/audit_outputs.py \
--manifest s3://my-bucket/bridge-classification/ml-data/split_test_ids.txt \
--bucket my-bucket \
--input-prefix bridge-classification/ml-data/source \
--output-prefix scratch/myfolder/bridge-classification-test/predictions \
--mode masked \
--write-missing missing.txt \
--profile my-profile
# Tune concurrency (default: 200 threads)
python scripts/audit_outputs.py --manifest s3://my-bucket/path/manifest.txt --bucket my-bucket --output-prefix my-prefix --workers 100
If outputs are missing, upload the missing manifest and re-submit:
aws s3 cp missing.txt s3://my-bucket/bridge-classification/missing_manifest.txt --profile my-profile
python scripts/submit_batch_job.py \
--manifest s3://my-bucket/bridge-classification/missing_manifest.txt \
--profile my-profile
Re-submission is safe - skip-if-exists means already-completed bridges are skipped.
6. Post-Run Report¶
After all children complete, generate a report with audit results, CloudWatch aggregation, and per-bridge timing:
python scripts/post_run_report.py \
--bucket my-bucket \
--output-prefix bridge-classification/runs/my-run/predictions \
--mode masked \
--profile my-profile \
--batch-profile my-profile
Use --batch-profile when your S3 and Batch/CloudWatch credentials are on different AWS profiles.
Do not include a trailing slash (/) on --output-prefix.
This reads _run_config.json (saved at submission), audits S3 outputs, queries CloudWatch for SUMMARY and INFER_OK lines, queries failure reasons for missing bridges, and saves _run_report.json to the output prefix.
Use --skip-timing for a faster report without per-bridge p50/p95 stats.
7. Cleanup¶
To tear down Batch infrastructure, destroy layers in reverse order:
cd infra/terraform/app && terraform destroy # workload (IAM, ECR if created, Batch)
cd ../foundation && terraform destroy # networking (if deployed)
cd ../bootstrap && terraform destroy # state bucket (optional - safe to keep)
Destroying app alone is usually sufficient (removes IAM, compute env, queue, job def, and ECR if create_ecr = true). Foundation and bootstrap are shared infrastructure rarely torn down. S3 data is not affected.
How Chunking Works¶
The submit script computes how many array children to create:
chunk_targetdefaults to 60 (files per container)array_sizeis capped at 10,000 (AWS Batch hard limit)
Each child computes its chunk at runtime from the actual manifest:
chunk_size = ceil(total_lines / array_size)
start = job_index * chunk_size
end = min(start + chunk_size, total_lines)
Example with 1,500,000 bridges:
| Value | |
|---|---|
| chunk_target | 60 (requested) |
| array_size | 10,000 (capped from ideal 25,000) |
| Files per child | ~150 (auto-adjusted) |
When capped, the submit script reports the actual chunk size per child.
SPOT Instance Handling¶
Three layers of protection against SPOT interruption:
1. SIGTERM Handler (batch_entrypoint.py)¶
When AWS reclaims a SPOT instance, it sends SIGTERM with ~2 minutes before SIGKILL. The handler sets shutdown_requested = True, and the loop finishes the current bridge's full cycle (inference + upload) before exiting. No half-uploaded files.
2. Retry Strategy (Terraform)¶
The job definition includes retry_strategy that auto-retries on SPOT interruption (status reason "Host EC2*") up to retry_attempts (default 3) times. Other failures (OOM, app errors) exit immediately - no wasted retries.
3. Skip-if-Exists (Resumability)¶
When a retried child starts, it checks S3 for each bridge's output before processing. Bridges completed before the interruption are skipped. A retry only processes the remaining bridges.
Flow: SPOT reclaim → SIGTERM → finish current bridge → child exits → Batch auto-retries → retried child skips completed bridges → continues.
Inference Modes¶
Set via inference_mode in terraform or --env INFERENCE_MODE=... at submit time.
| Mode | Output file(s) | Description |
|---|---|---|
masked (default) |
{stem}_bridge_masked.laz |
Bridge deck only (model class 2 → ASPRS 17) overlaid on original classification |
raw |
{stem}_predicted.laz |
All model classes mapped to ASPRS codes (replaces original classification) |
both |
{stem}_predicted.laz + {stem}_bridge_masked.laz |
Both outputs per bridge |
Output files preserve the input extension (.laz or .las) and are organized by HUC:
Manifest File Format¶
One bridge per line. Extensionless or with extension:
02050206/bridge_10598181_USGS_LPC_PA_South_Central_B2_2017_LAS_2019
03070101/bridge_5069009_USGS_LPC_PA_South_Central_B2_2017_LAS_2019.laz
11010009/bridge_1234567_USGS_Some_Dataset.las
If a line has no extension, the entrypoint probes S3 for .laz first, then .las via head_object.
The split manifest produced by utils/split_data.py (split_test_ids.txt) is directly usable.
Configuration Reference¶
See infra/terraform/README.md for the full variable reference and app outputs.
Local Inference & Testing¶
Direct Inference (no S3)¶
Use src/inference.py to run inference on local files without any S3 or Batch setup.
Requires an NVIDIA GPU (spconv-cu120).
The model is loaded once and reused for all files.
python src/inference.py \
--model ./experiments/bridge-base-all-data-v0/version_0/checkpoints/epoch=35.ckpt \
--input ./data/ml-data/testing/02050206/bridge_10598181_USGS_LPC_PA_SouthCentral_B2_2017.laz \
--output ./data/ml-data/predictions/bridge_10598181_bridge_masked.laz \
--mode masked
Modes: masked (default), raw, both. With --mode both, both _predicted.laz and _bridge_masked.laz are written. For batch processing, use --pairs-file with a tab-separated input/output file. Run python src/inference.py --help for all options.
Testing Batch Entrypoint Locally¶
Test the full S3-based entrypoint locally before submitting to Batch:
# Set env vars to simulate Batch
export AWS_PROFILE=my-profile
export S3_BUCKET=my-bucket
export S3_INPUT_PREFIX=bridge-classification/ml-data/source
export S3_MANIFEST_URI=s3://my-bucket/bridge-classification/test_manifest.txt
export S3_MODEL_URI=s3://my-bucket/bridge-classification/models/v3/epoch=35.ckpt
export S3_OUTPUT_PREFIX=scratch/your-name/predictions-test
export ARRAY_SIZE=1
export AWS_BATCH_JOB_ARRAY_INDEX=0
export INFERENCE_MODE=masked
python scripts/batch_entrypoint.py
Cost Tracking¶
All Batch resources are tagged with Project = bridge-classifier. Tags propagate to the underlying ECS tasks via propagate_tags = true on the job definition.
Cost Explorer: Go to AWS Cost Explorer, group by Tag → Project, and filter to bridge-classifier.
Estimated costs (printed by submit script with --dry-run):
- g4dn.xlarge SPOT: ~$0.234/hr per child (fluctuates - check https://aws.amazon.com/ec2/spot/pricing/)
- Per-bridge inference: 2-5 seconds typical (varies with point cloud size)
- The submit script's cost estimate is based on child compute time only. Actual AWS billing includes instance startup overhead, SPOT idle time, and minimum billing increments - expect 3-5x the estimate. Use Cost Explorer filtered by
Projecttag for actual spend.
Troubleshooting¶
Job stuck in RUNNABLE: Compute environment may not have capacity. Check that max_vcpus is sufficient and the instance type is available in your subnets/AZs.
"Required environment variables not set" error: The entrypoint validates that all S3 env vars are set. These come from the Terraform job definition. Run cd infra/terraform/app && terraform apply to ensure the job definition has all required env vars.
Model loading errors: Ensure the checkpoint was saved by BridgeLightningModule (Lightning format with state_dict key). The inference script handles both Lightning checkpoints and raw state dicts.
GPU out of memory: Large bridges with dense point clouds can exceed GPU memory. Use a larger instance or increase --voxel-size (coarser voxels = fewer voxels = less memory).
S3 permission denied: The Batch job IAM role is managed by the app layer and scoped to the data_bucket. Verify that s3_bucket matches data_bucket in the app tfvars.
SPOT instance interruptions: The job definition auto-retries up to retry_attempts times on SPOT interruption. Combined with skip-if-exists, retries are cheap. For critical runs with no tolerance for delay, set use_spot = false.
Per-bridge timeout (INFER_FAILED reason=timeout in logs): A bridge exceeded bridge_timeout seconds during inference.
Usually caused by large point clouds.
Increase bridge_timeout in infra/terraform/app/terraform.tfvars or via --env BRIDGE_TIMEOUT=300 at submit time.
S3 throttling (503 SlowDown): The S3 client uses adaptive retry (3 attempts). If you see persistent throttling, your request rate may exceed the prefix partition limit. Input files distributed across HUC prefixes naturally mitigate this.
Audit shows missing outputs: Re-submit with the missing manifest. Skip-if-exists ensures already-completed bridges are not reprocessed. Repeat audit → re-submit until all outputs are present.