Skip to content
This repository was archived by the owner on Jul 2, 2026. It is now read-only.

Commit 3de7d81

Browse files
juaristi22claude
andcommitted
Parallelize chunked matrix build across Modal workers (#818)
Phase 2 of issue #818. Builds on the phase-1 coordinator extraction to fan chunked matrix building out across Modal workers, cutting the full-CPS build's wall time from the ~14 h serial ceiling into a wall-time window proportional to one chunk. Changes: - `policyengine_us_data/calibration/chunked_matrix_modal.py`: new `dispatch_chunks_modal` coordinator. Pickles `SharedBuildState` to `{chunk_root}/chunk_build_state.pkl` on the pipeline volume, partitions `range(n_chunks)` into contiguous batches, spawns one `build_matrix_chunk_worker` per batch via `modal.Function.from_name`, collects results, aggregates per-chunk errors, and streams the final CSR from shards on the volume. Contiguous batching (not round-robin) keeps resume-friendly prefixes on disk. - `modal_app/matrix_chunk_worker.py`: new `build_matrix_chunk_worker` `@app.function` registered on the existing `policyengine-us-data-fit-weights` app (same app as `build_package_remote`). Mirrors `build_areas_worker` resources (memory=16384, cpu=1.0, timeout=28800, max_containers=50, nonpreemptible). Worker reads the pickled shared state, constructs a `ChunkedMatrixAssembler` with `resume=True`, calls `run_chunks` on its batch, commits the volume, returns nnz + errors. - `SharedBuildState` grows a `chunk_size` field so a worker can reconstruct the assembler from the pickle alone (no extra args). - `build_matrix_chunked` accepts `parallel`, `num_matrix_workers`, `run_id`; routes to `dispatch_chunks_modal` when `parallel=True`, preserves the in-process serial path otherwise. - `unified_calibration` CLI gains `--parallel` (default off) and `--num-matrix-workers` (default 50). `--parallel` without `--chunked-matrix` logs an info message and runs the non-chunked path unchanged. `run_id` flows from env `POLICYENGINE_US_DATA_RUN_ID`, set by `build_package_remote`. - `build_package_remote` / `_build_package_impl` take `chunked_matrix`, `chunk_size`, `parallel_matrix`, `num_matrix_workers` kwargs and forward them to the `unified_calibration` subprocess. Tests: - 9 unit tests in `tests/unit/calibration/test_chunked_matrix_modal.py` cover `partition_chunk_ids_contiguous` (exact division, remainder, more workers than chunks, zero chunks, invalid num_workers) and `dispatch_chunks_modal` with injected `worker_function` + `volume` fakes (spawn/assemble happy path, zero-chunks short-circuit, error aggregation, shared-state pickle side effect). - New `test_shared_build_state_roundtrips_pickle` in the assembler unit tests guards the phase-2 boundary: if `SharedBuildState` stops pickling cleanly, we catch it here rather than in a Modal worker. 11 assembler unit tests total. - `tests/integration/test_matrix_chunk_worker_modal.py` is an env-gated smoke test (MODAL_TOKEN_ID + MODAL_TOKEN_SECRET + POLICYENGINE_US_DATA_MODAL_SMOKE=1) that validates the deployed worker is lookupable via `modal.Function.from_name` and that production-scale batching is sane. Full end-to-end validation (write shared state, spawn, verify shards on the volume) is a pre-merge manual step documented in the PR. Verified: - 392 calibration unit tests pass (up from 382; 10 new phase-2 tests; 0 regressions), plus the 6 existing chunked-matrix integration tests still green against the phase-1 facade. - `ruff check` and `ruff format --check` clean on all changed files. Validation still pending on real Modal (deploy + one-chunk benchmark + full-CPS `workflow_dispatch` run); gated on the Modal venv-activation PR landing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7937c4a commit 3de7d81

10 files changed

Lines changed: 775 additions & 4 deletions

changelog.d/818.changed.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
Extract `ChunkedMatrixAssembler` from `UnifiedMatrixBuilder.build_matrix_chunked` and replace the list-then-concat final assembly with a two-pass streaming CSR build. The facade signature and all existing chunked-matrix behaviour are unchanged.
2+
3+
Parallelize chunked matrix building across Modal workers. Adds `dispatch_chunks_modal` (`policyengine_us_data/calibration/chunked_matrix_modal.py`) that pickles `SharedBuildState` to the pipeline volume, fans contiguous chunk-id batches to `build_matrix_chunk_worker` (`modal_app/matrix_chunk_worker.py`, registered on the `policyengine-us-data-fit-weights` app), and streams the final CSR from shards on the volume. New CLI flags on `unified_calibration`: `--parallel` (default off) and `--num-matrix-workers` (default 50). `build_package_remote` threads `run_id` to the subprocess via the `POLICYENGINE_US_DATA_RUN_ID` env var and forwards `--parallel` / `--num-matrix-workers` when `parallel_matrix=True`. `--parallel` without `--chunked-matrix` logs an info message and runs the non-chunked path unchanged.

modal_app/matrix_chunk_worker.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Modal worker that materializes a batch of matrix chunks.
2+
3+
Registered on the same ``policyengine-us-data-fit-weights`` app as
4+
``build_package_remote`` so the coordinator can spawn workers via
5+
``modal.Function.from_name`` from inside the package build's
6+
subprocess. Each worker reads the shared ``ChunkedMatrixAssembler``
7+
state from ``pipeline_volume``, materializes its assigned chunks to
8+
COO shard files on the volume, and commits. The coordinator reads the
9+
shards back after all workers finish and streams them into the final
10+
CSR matrix.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import pickle
16+
import sys
17+
import traceback
18+
from pathlib import Path
19+
from typing import Dict, List
20+
21+
_baked = "/root/policyengine-us-data"
22+
_local = str(Path(__file__).resolve().parent.parent)
23+
for _p in (_baked, _local):
24+
if _p not in sys.path:
25+
sys.path.insert(0, _p)
26+
27+
from modal_app.images import cpu_image # noqa: E402
28+
from modal_app.remote_calibration_runner import ( # noqa: E402
29+
PIPELINE_MOUNT,
30+
app,
31+
hf_secret,
32+
pipeline_vol,
33+
)
34+
35+
36+
def _chunk_root(run_id: str) -> str:
37+
return f"{PIPELINE_MOUNT}/artifacts/{run_id}/matrix_build"
38+
39+
40+
@app.function(
41+
image=cpu_image,
42+
secrets=[hf_secret],
43+
volumes={PIPELINE_MOUNT: pipeline_vol},
44+
memory=16384,
45+
cpu=1.0,
46+
timeout=28800,
47+
max_containers=50,
48+
nonpreemptible=True,
49+
)
50+
def build_matrix_chunk_worker(run_id: str, chunk_ids: List[int]) -> Dict:
51+
"""Materialize ``chunk_ids`` from the pickled ``SharedBuildState``.
52+
53+
Args:
54+
run_id: Pipeline run identifier; selects the volume path for
55+
this worker's shared state and shard output directory.
56+
chunk_ids: Chunk indices this worker is responsible for.
57+
58+
Returns:
59+
Dict with ``chunk_ids``, ``nnz_per_chunk``, and ``errors``
60+
lists suitable for the coordinator to aggregate.
61+
"""
62+
from policyengine_us_data.calibration.chunked_matrix_assembler import (
63+
ChunkedMatrixAssembler,
64+
)
65+
66+
pipeline_vol.reload()
67+
chunk_root = Path(_chunk_root(run_id))
68+
state_path = chunk_root / "chunk_build_state.pkl"
69+
if not state_path.exists():
70+
return {
71+
"chunk_ids": list(chunk_ids),
72+
"nnz_per_chunk": [],
73+
"errors": [
74+
{
75+
"chunk_ids": list(chunk_ids),
76+
"error": f"Missing shared state at {state_path}",
77+
}
78+
],
79+
}
80+
81+
with open(state_path, "rb") as f:
82+
shared_state = pickle.load(f)
83+
84+
assembler = ChunkedMatrixAssembler(
85+
shared_state=shared_state,
86+
chunk_root=chunk_root,
87+
chunk_size=shared_state.chunk_size,
88+
resume=True,
89+
keep_chunks=False,
90+
)
91+
92+
errors: List[Dict] = []
93+
nnz_per_chunk: List[int] = []
94+
for chunk_id in chunk_ids:
95+
try:
96+
result = assembler.run_single_chunk(chunk_id)
97+
nnz_per_chunk.append(result.nnz)
98+
except Exception as exc:
99+
errors.append(
100+
{
101+
"chunk_id": chunk_id,
102+
"error": str(exc),
103+
"traceback": traceback.format_exc(),
104+
}
105+
)
106+
107+
pipeline_vol.commit()
108+
return {
109+
"chunk_ids": list(chunk_ids),
110+
"nnz_per_chunk": nnz_per_chunk,
111+
"errors": errors,
112+
}

modal_app/remote_calibration_runner.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,10 @@ def _build_package_impl(
366366
workers: int = 8,
367367
n_clones: int = 430,
368368
run_id: str = "",
369+
chunked_matrix: bool = False,
370+
chunk_size: int = 25_000,
371+
parallel_matrix: bool = False,
372+
num_matrix_workers: int = 50,
369373
) -> str:
370374
"""Read data from pipeline volume, build X matrix, save package."""
371375
_setup_repo()
@@ -408,10 +412,26 @@ def _build_package_impl(
408412
if workers > 1:
409413
cmd.extend(["--workers", str(workers)])
410414
cmd.extend(["--n-clones", str(n_clones)])
415+
if chunked_matrix:
416+
cmd.extend(["--chunked-matrix", "--chunk-size", str(chunk_size)])
417+
if parallel_matrix:
418+
cmd.extend(
419+
[
420+
"--parallel",
421+
"--num-matrix-workers",
422+
str(num_matrix_workers),
423+
]
424+
)
411425

426+
build_env = os.environ.copy()
427+
if run_id:
428+
# ``unified_calibration.py`` reads this env var so workers can
429+
# locate their shared state at {pipeline-artifacts}/{run_id}/
430+
# matrix_build/chunk_build_state.pkl on the pipeline volume.
431+
build_env["POLICYENGINE_US_DATA_RUN_ID"] = run_id
412432
build_rc, build_lines = _run_streaming(
413433
cmd,
414-
env=os.environ.copy(),
434+
env=build_env,
415435
label="build",
416436
)
417437
if build_rc != 0:
@@ -450,6 +470,10 @@ def build_package_remote(
450470
workers: int = 8,
451471
n_clones: int = 430,
452472
run_id: str = "",
473+
chunked_matrix: bool = False,
474+
chunk_size: int = 25_000,
475+
parallel_matrix: bool = False,
476+
num_matrix_workers: int = 50,
453477
) -> str:
454478
return _build_package_impl(
455479
branch,
@@ -458,6 +482,10 @@ def build_package_remote(
458482
workers=workers,
459483
n_clones=n_clones,
460484
run_id=run_id,
485+
chunked_matrix=chunked_matrix,
486+
chunk_size=chunk_size,
487+
parallel_matrix=parallel_matrix,
488+
num_matrix_workers=num_matrix_workers,
461489
)
462490

463491

policyengine_us_data/calibration/chunked_matrix_assembler.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ class SharedBuildState:
6767
n_records: int
6868
n_clones: int
6969
n_targets: int
70+
chunk_size: int
7071
target_variables: List[str]
7172
target_reform_ids: List[int]
7273
target_geo_info: List[Tuple[str, str]]
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
"""Coordinator-side Modal dispatch for chunked matrix building.
2+
3+
Writes shared ``SharedBuildState`` to a pipeline-volume path,
4+
spawns ``build_matrix_chunk_worker`` per contiguous batch of
5+
chunk ids, collects per-worker results, then streams the final CSR
6+
from all shards on the volume.
7+
8+
Kept separate from ``unified_matrix_builder`` so the core matrix
9+
builder doesn't import Modal; only the dispatch path does.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import logging
15+
import math
16+
import pickle
17+
import time
18+
from pathlib import Path
19+
from typing import Any, Dict, List, Optional
20+
21+
from scipy import sparse
22+
23+
from policyengine_us_data.calibration.chunked_matrix_assembler import (
24+
ChunkedMatrixAssembler,
25+
SharedBuildState,
26+
)
27+
28+
logger = logging.getLogger(__name__)
29+
30+
DEFAULT_NUM_MATRIX_WORKERS = 50
31+
MODAL_APP_NAME = "policyengine-us-data-fit-weights"
32+
WORKER_FUNCTION_NAME = "build_matrix_chunk_worker"
33+
34+
35+
def partition_chunk_ids_contiguous(n_chunks: int, num_workers: int) -> List[List[int]]:
36+
"""Split ``range(n_chunks)`` into ``num_workers`` contiguous batches.
37+
38+
Contiguous (not round-robin) so that a partial run leaves complete
39+
prefixes on disk that future `--resume-chunks` invocations can
40+
skip cleanly. Returns at most ``num_workers`` non-empty batches.
41+
"""
42+
if n_chunks <= 0:
43+
return []
44+
if num_workers <= 0:
45+
raise ValueError("num_workers must be positive")
46+
batch_size = math.ceil(n_chunks / num_workers)
47+
batches: List[List[int]] = []
48+
for start in range(0, n_chunks, batch_size):
49+
end = min(start + batch_size, n_chunks)
50+
batches.append(list(range(start, end)))
51+
return batches
52+
53+
54+
def _lookup_worker_function():
55+
"""Resolve the deployed Modal worker function.
56+
57+
Using ``Function.from_name`` avoids importing the worker module
58+
here (Modal imports are heavy and would pull into every caller).
59+
It also means unit tests can monkeypatch this function without
60+
touching the worker module.
61+
"""
62+
import modal
63+
64+
return modal.Function.from_name(MODAL_APP_NAME, WORKER_FUNCTION_NAME)
65+
66+
67+
def dispatch_chunks_modal(
68+
*,
69+
shared_state: SharedBuildState,
70+
chunk_root: Path,
71+
run_id: str,
72+
num_workers: int = DEFAULT_NUM_MATRIX_WORKERS,
73+
worker_function: Optional[Any] = None,
74+
volume: Optional[Any] = None,
75+
) -> sparse.csr_matrix:
76+
"""Fan chunk materialization across Modal workers, then assemble.
77+
78+
Args:
79+
shared_state: Read-only per-build state; pickled once to the
80+
volume so workers can reconstruct the assembler without
81+
receiving the arrays through ``.spawn()`` args.
82+
chunk_root: Directory on the pipeline volume where shards land
83+
(``{chunk_root}/coo/chunk_XXXXXX.npz``) and where the
84+
shared state pickle lives
85+
(``{chunk_root}/chunk_build_state.pkl``).
86+
run_id: Forwarded to each worker so its volume paths align
87+
with the coordinator's.
88+
num_workers: Upper bound on workers; actual count equals
89+
``min(num_workers, n_chunks)``.
90+
worker_function: Override for the Modal function (tests only).
91+
volume: Override for the pipeline volume (tests only). When
92+
omitted, resolves ``modal.Volume.from_name("pipeline-artifacts")``.
93+
94+
Raises:
95+
RuntimeError: if any worker reports one or more chunk errors
96+
after all workers finish. Raised after aggregating so no
97+
errors are silently dropped.
98+
"""
99+
chunk_root = Path(chunk_root)
100+
chunk_root.mkdir(parents=True, exist_ok=True)
101+
state_path = chunk_root / "chunk_build_state.pkl"
102+
with open(state_path, "wb") as f:
103+
pickle.dump(shared_state, f)
104+
105+
if volume is None:
106+
import modal
107+
108+
volume = modal.Volume.from_name("pipeline-artifacts", create_if_missing=True)
109+
# Make the shared-state pickle visible to workers.
110+
volume.commit()
111+
112+
n_chunks = math.ceil(shared_state.n_total / shared_state.chunk_size)
113+
batches = partition_chunk_ids_contiguous(n_chunks, num_workers)
114+
115+
if not batches:
116+
# Nothing to materialize; fall through to assembly (which will
117+
# return an empty CSR).
118+
volume.reload()
119+
assembler = ChunkedMatrixAssembler(
120+
shared_state=shared_state,
121+
chunk_root=chunk_root,
122+
chunk_size=shared_state.chunk_size,
123+
resume=True,
124+
keep_chunks=False,
125+
)
126+
return assembler.assemble_final()
127+
128+
if worker_function is None:
129+
worker_function = _lookup_worker_function()
130+
131+
logger.info(
132+
"Dispatching %d chunks across %d workers (batch sizes: %s)",
133+
n_chunks,
134+
len(batches),
135+
[len(b) for b in batches[:5]] + (["..."] if len(batches) > 5 else []),
136+
)
137+
t_dispatch = time.time()
138+
handles = []
139+
for batch_idx, chunk_ids in enumerate(batches):
140+
handle = worker_function.spawn(run_id=run_id, chunk_ids=chunk_ids)
141+
logger.info(
142+
"Worker %d/%d: %d chunks (%d-%d), fc=%s",
143+
batch_idx + 1,
144+
len(batches),
145+
len(chunk_ids),
146+
chunk_ids[0],
147+
chunk_ids[-1],
148+
getattr(handle, "object_id", "unknown"),
149+
)
150+
handles.append((batch_idx, handle))
151+
152+
aggregated_errors: List[Dict] = []
153+
for batch_idx, handle in handles:
154+
try:
155+
result = handle.get()
156+
except Exception as exc:
157+
aggregated_errors.append(
158+
{
159+
"batch": batch_idx,
160+
"error": f"Worker crashed: {exc}",
161+
}
162+
)
163+
logger.error("Worker %d crashed: %s", batch_idx, exc)
164+
continue
165+
if result is None:
166+
aggregated_errors.append(
167+
{"batch": batch_idx, "error": "Worker returned None"}
168+
)
169+
continue
170+
errors = result.get("errors", [])
171+
if errors:
172+
for err in errors:
173+
err_copy = dict(err)
174+
err_copy["batch"] = batch_idx
175+
aggregated_errors.append(err_copy)
176+
logger.info(
177+
"Worker %d done: %d chunks completed, %d errors",
178+
batch_idx,
179+
len(result.get("chunk_ids", [])) - len(errors),
180+
len(errors),
181+
)
182+
183+
logger.info(
184+
"All workers finished in %.1fs; %d errors total",
185+
time.time() - t_dispatch,
186+
len(aggregated_errors),
187+
)
188+
189+
if aggregated_errors:
190+
preview = "; ".join(
191+
f"batch {e['batch']}: {e.get('error', 'unknown')[:120]}"
192+
for e in aggregated_errors[:3]
193+
)
194+
raise RuntimeError(
195+
f"Parallel chunked matrix build failed with "
196+
f"{len(aggregated_errors)} error(s). First: {preview}"
197+
)
198+
199+
# All shards present on the volume; reload and assemble.
200+
volume.reload()
201+
assembler = ChunkedMatrixAssembler(
202+
shared_state=shared_state,
203+
chunk_root=chunk_root,
204+
chunk_size=shared_state.chunk_size,
205+
resume=True,
206+
keep_chunks=False,
207+
)
208+
return assembler.assemble_final()

0 commit comments

Comments
 (0)