Skip to content

Commit c0fd05e

Browse files
MP checkpointing fix
1 parent de119ed commit c0fd05e

1 file changed

Lines changed: 155 additions & 6 deletions

File tree

activitysim/core/calibration.py

Lines changed: 155 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,99 @@ def run_calibration_loop(
290290
state.filesystem.pipeline_file_name = original_pipeline_name
291291

292292

293+
def _get_pipeline_checkpoint_names(
294+
state: workflow.State,
295+
pipeline_path,
296+
) -> list[str]:
297+
"""Read checkpoint names from a pipeline store file without fully restoring."""
298+
from activitysim.core.workflow.checkpoint import HdfStore, ParquetStore
299+
300+
pipeline_path = Path(pipeline_path)
301+
if state.settings.checkpoint_format == "hdf":
302+
store = HdfStore(pipeline_path, mode="r")
303+
else:
304+
store = ParquetStore(pipeline_path, mode="r")
305+
try:
306+
return store.list_checkpoint_names()
307+
finally:
308+
store.close()
309+
310+
311+
def _resolve_checkpoint_name(
312+
state: workflow.State,
313+
resume_after: str,
314+
checkpoint_names: list[str],
315+
) -> str:
316+
"""Resolve a model-name ``resume_after`` to an existing pipeline checkpoint.
317+
318+
In single-process pipelines model names are used directly as checkpoint
319+
names. In multiprocess pipelines only step-level checkpoint names (e.g.
320+
``mp_households``) exist in the main pipeline store. This maps a model
321+
name to the enclosing multiprocess step checkpoint so the calibration
322+
module can restore the correct pipeline state.
323+
"""
324+
from activitysim.core.exceptions import CheckpointNameNotFoundError
325+
326+
if resume_after in checkpoint_names:
327+
return resume_after
328+
329+
all_models = state.settings.models
330+
mp_steps = state.settings.multiprocess_steps
331+
332+
if not mp_steps or resume_after not in all_models:
333+
raise CheckpointNameNotFoundError(
334+
f"Checkpoint '{resume_after}' not found in pipeline and cannot "
335+
f"be resolved. Available checkpoints: {checkpoint_names}"
336+
)
337+
338+
resume_idx = all_models.index(resume_after)
339+
step_boundaries = [all_models.index(s.begin) for s in mp_steps]
340+
step_boundaries.append(len(all_models))
341+
342+
for i, step in enumerate(mp_steps):
343+
if step_boundaries[i] <= resume_idx < step_boundaries[i + 1]:
344+
if step.name in checkpoint_names:
345+
return step.name
346+
# Also check for calibration-prefixed step names from a prior
347+
# calibration run's multiprocess steps.
348+
for cp in checkpoint_names:
349+
if cp.startswith(f"calibration_{step.name}"):
350+
return cp
351+
break
352+
353+
raise CheckpointNameNotFoundError(
354+
f"Cannot resolve '{resume_after}' to any available checkpoint. "
355+
f"Available: {checkpoint_names}"
356+
)
357+
358+
359+
def _effective_resume_index(
360+
state: workflow.State,
361+
checkpoint_name: str,
362+
) -> int:
363+
"""Return the index of the last model covered by a checkpoint.
364+
365+
For model-level checkpoints this is the model's own index in
366+
``settings.models``. For step-level checkpoints this is the index of
367+
the last model in the enclosing multiprocess step.
368+
"""
369+
all_models = state.settings.models
370+
if checkpoint_name in all_models:
371+
return all_models.index(checkpoint_name)
372+
373+
mp_steps = state.settings.multiprocess_steps or []
374+
step_boundaries = [all_models.index(s.begin) for s in mp_steps]
375+
step_boundaries.append(len(all_models))
376+
377+
for i, step in enumerate(mp_steps):
378+
if step.name == checkpoint_name or (
379+
checkpoint_name.startswith(f"calibration_{step.name}")
380+
):
381+
return step_boundaries[i + 1] - 1
382+
383+
return -1
384+
385+
293386
def _run_precursor_components(
294387
state: workflow.State,
295388
models: list[str],
@@ -306,7 +399,11 @@ def _run_precursor_components(
306399
prior_pipeline = state.checkpoint.store.filename
307400
state.checkpoint.close_store()
308401
state.filesystem.pipeline_file_name = f"pipeline_calibration_iter_{global_iter}"
309-
state.checkpoint.restore_from(prior_pipeline, checkpoint_name=resume_after)
402+
# In multiprocess pipelines the model-level checkpoint may not exist;
403+
# resolve to the enclosing step checkpoint.
404+
checkpoint_names = _get_pipeline_checkpoint_names(state, prior_pipeline)
405+
resolved = _resolve_checkpoint_name(state, resume_after, checkpoint_names)
406+
state.checkpoint.restore_from(prior_pipeline, checkpoint_name=resolved)
310407
else:
311408
_run_in_configured_mode(
312409
state,
@@ -1394,7 +1491,29 @@ def _run_in_configured_mode(
13941491
if not models:
13951492
return
13961493

1494+
# Remember the original last model — downstream code (e.g.
1495+
# _calibrate_component) references it as a checkpoint name.
1496+
original_last_model = models[-1]
1497+
13971498
if state.settings.multiprocess:
1499+
# In multiprocess mode the checkpoint system uses step-level names,
1500+
# so model-level resume_after cannot be passed through directly.
1501+
# Instead, trim the models list to exclude those already completed.
1502+
if resume_after:
1503+
all_models = state.settings.models
1504+
if resume_after in all_models:
1505+
resume_idx = all_models.index(resume_after)
1506+
models = [m for m in models if all_models.index(m) > resume_idx]
1507+
1508+
if not models:
1509+
# All models are covered by the resume point — restore the
1510+
# pipeline state and create a model-level checkpoint so that
1511+
# downstream code can reference it by name.
1512+
if not state.checkpoint.store_is_open():
1513+
state.checkpoint.restore(resume_after="_")
1514+
state.checkpoint.add(original_last_model)
1515+
return
1516+
13981517
_run_multiprocess_with_overrides(
13991518
state,
14001519
models=models,
@@ -1405,13 +1524,43 @@ def _run_in_configured_mode(
14051524
# Restore it into the parent process state so tables are accessible
14061525
# for calibration expression evaluation.
14071526
_restore_parent_state_from_pipeline(state)
1408-
# Add a checkpoint named after the last model so that model-name
1409-
# references (e.g. _prior_step_name, resume_after on global_iter > 1)
1410-
# resolve correctly. Without this, only the step-level coalesce name
1411-
# exists in the pipeline.
1412-
state.checkpoint.add(models[-1])
1527+
# Add a checkpoint named after the original last model so that
1528+
# model-name references (e.g. _prior_step_name, resume_after on
1529+
# global_iter > 1) resolve correctly. Without this, only the
1530+
# step-level coalesce name exists in the pipeline.
1531+
state.checkpoint.add(original_last_model)
14131532
return
14141533

1534+
# Single-process mode: resolve resume_after to an existing checkpoint
1535+
# when the pipeline has step-level checkpoints from a prior multiprocess
1536+
# run (model-level checkpoints won't exist in that pipeline).
1537+
if resume_after:
1538+
pipeline_path = state.checkpoint.default_pipeline_file_path()
1539+
try:
1540+
checkpoint_names = _get_pipeline_checkpoint_names(
1541+
state, pipeline_path
1542+
)
1543+
except Exception:
1544+
checkpoint_names = []
1545+
1546+
if checkpoint_names and resume_after not in checkpoint_names:
1547+
resolved = _resolve_checkpoint_name(
1548+
state, resume_after, checkpoint_names
1549+
)
1550+
# The resolved checkpoint may cover more models than
1551+
# resume_after — trim the models list accordingly.
1552+
end_idx = _effective_resume_index(state, resolved)
1553+
if end_idx >= 0:
1554+
all_models = state.settings.models
1555+
models = [m for m in models if all_models.index(m) > end_idx]
1556+
1557+
if not models:
1558+
state.checkpoint.restore(resolved)
1559+
state.checkpoint.add(original_last_model)
1560+
return
1561+
1562+
resume_after = resolved
1563+
14151564
state.run(
14161565
models=models,
14171566
resume_after=resume_after,

0 commit comments

Comments
 (0)