Skip to content

Commit d8a4bb2

Browse files
lwalewclaude
andcommitted
perf: lazy-load heavy deps to speed up CLI startup
Importing mlipaudit.benchmarks (which the CLI does to build the argument parser) eagerly pulled in mlip.models, JAX/JAX-MD and scikit-learn, making even `mlipaudit -h` take ~6s. These libraries are only needed when a benchmark is actually run. Defer those imports to call time: - benchmark.py / utils.{inference,simulation,stability,unallowed_elements}: move mlip.models / mlip.simulation.ase (JAX-MD) / jax / sklearn imports into the functions that use them; type-only refs go under TYPE_CHECKING with `from __future__ import annotations`. - Move ASESimulationEngineWithCalculator into utils/_ase_engine.py so the heavy ASE-engine base class is only imported when a simulation runs; keep the historical import paths working via module-level __getattr__. - nudged_elastic_band / conformer_selection / dihedral_scan / water_radial_distribution: defer their mlip / sklearn imports. - main.py: import launch_app / run_benchmarks inside their command branches. Tests patched the now-lazy symbols at the use-site module; update them to patch at the definition site (e.g. mlip.simulation.jax_md.JaxMDSimulationEngine), which the lazy `from ... import` picks up at call time. `mlipaudit -h` drops from ~5.6s to ~0.9s; importing mlipaudit.benchmarks drops from ~5.0s to ~1.1s. All 128 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d4779ae commit d8a4bb2

27 files changed

Lines changed: 233 additions & 88 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- Speed up CLI startup (e.g. `mlipaudit -h`) from ~6s to ~1s by importing the
6+
heavy `mlip`/JAX/JAX-MD/scikit-learn dependencies lazily, only when a benchmark
7+
is actually run, rather than at import time of the benchmark modules.
8+
39
## Release 0.1.3
410

511
- Populate `atoms.info["charge"]` and `atoms.info["spin"]` on every benchmark's

src/mlipaudit/benchmark.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,27 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from __future__ import annotations
16+
1517
import os
1618
import zipfile
1719
from abc import ABC, abstractmethod
1820
from pathlib import Path
19-
from typing import Any, Literal, TypeAlias
21+
from typing import TYPE_CHECKING, Any, Literal, TypeAlias
2022

2123
from ase import Atom
2224
from ase.calculators.calculator import Calculator as ASECalculator
2325
from huggingface_hub import hf_hub_download
24-
from mlip.models import ForceField
2526
from pydantic import BaseModel, Field
2627

2728
from mlipaudit.exceptions import ChemicalElementsMissingError
2829
from mlipaudit.run_mode import RunMode
2930

31+
if TYPE_CHECKING:
32+
# `mlip.models` pulls in the full model/JAX stack (~2s); importing it lazily
33+
# keeps benchmark classes cheap to import (e.g. for `mlipaudit -h`).
34+
from mlip.models import ForceField
35+
3036
RunModeAsString: TypeAlias = Literal["dev", "fast", "standard"]
3137

3238
#: Default total charge applied to `atoms.info["charge"]` when a benchmark's
@@ -139,6 +145,8 @@ def __init__(
139145

140146
self.force_field = force_field
141147

148+
from mlip.models import ForceField # noqa: PLC0415
149+
142150
if not (
143151
isinstance(self.force_field, ForceField)
144152
or isinstance(self.force_field, ASECalculator)

src/mlipaudit/benchmarks/conformer_selection/conformer_selection.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,19 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from __future__ import annotations
16+
1517
import functools
1618
import logging
1719
import os
1820
import statistics
19-
from typing import Literal, TypeAlias
21+
from typing import TYPE_CHECKING, Literal, TypeAlias
2022

2123
import numpy as np
2224
from ase import Atoms, units
2325
from ase.calculators.calculator import Calculator as ASECalculator
24-
from mlip.models import ForceField
2526
from pydantic import BaseModel, Field, NonNegativeFloat, TypeAdapter
2627
from scipy.stats import spearmanr
27-
from sklearn.metrics import mean_absolute_error, root_mean_squared_error
2828

2929
from mlipaudit.benchmark import (
3030
DEFAULT_CHARGE,
@@ -38,6 +38,10 @@
3838
from mlipaudit.scoring import compute_benchmark_score
3939
from mlipaudit.utils import run_inference
4040

41+
if TYPE_CHECKING:
42+
# `mlip.models` pulls in the heavy model/JAX stack; only needed for typing here.
43+
from mlip.models import ForceField
44+
4145
DatasetName: TypeAlias = Literal["wiggle150", "folmsbee"]
4246

4347
logger = logging.getLogger("mlipaudit")
@@ -279,6 +283,11 @@ def analyze(self) -> ConformerSelectionResult:
279283
Raises:
280284
RuntimeError: If called before `run_model()`.
281285
"""
286+
from sklearn.metrics import ( # noqa: PLC0415
287+
mean_absolute_error,
288+
root_mean_squared_error,
289+
)
290+
282291
if self.model_output is None:
283292
raise RuntimeError("Must call run_model() first.")
284293

src/mlipaudit/benchmarks/dihedral_scan/dihedral_scan.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
from ase import Atoms, units
2222
from pydantic import BaseModel, Field, NonNegativeFloat, TypeAdapter
2323
from scipy.stats import pearsonr
24-
from sklearn.metrics import mean_absolute_error, root_mean_squared_error
2524

2625
from mlipaudit.benchmark import (
2726
DEFAULT_CHARGE,
@@ -331,6 +330,11 @@ def _compute_metrics(
331330
ref_energy_profile: np.ndarray,
332331
predicted_energy_profile: np.ndarray,
333332
) -> tuple[float, float, float, float, float]:
333+
from sklearn.metrics import ( # noqa: PLC0415
334+
mean_absolute_error,
335+
root_mean_squared_error,
336+
)
337+
334338
mae = mean_absolute_error(ref_energy_profile, predicted_energy_profile)
335339
rmse = root_mean_squared_error(ref_energy_profile, predicted_energy_profile)
336340

src/mlipaudit/benchmarks/nudged_elastic_band/nudged_elastic_band.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from __future__ import annotations
16+
1517
import functools
1618
import logging
19+
from typing import TYPE_CHECKING
1720

1821
import numpy as np
1922
from ase import Atoms
2023
from mlip.simulation import SimulationState
21-
from mlip.simulation.ase import ASESimulationEngine
22-
from mlip.simulation.configs import ASESimulationConfig
2324
from pydantic import BaseModel, ConfigDict, TypeAdapter
2425

2526
from mlipaudit.benchmark import (
@@ -29,12 +30,16 @@
2930
BenchmarkResult,
3031
ModelOutput,
3132
)
32-
from mlipaudit.benchmarks.nudged_elastic_band.engine import (
33-
NEBSimulationConfig,
34-
NEBSimulationEngine,
35-
)
3633
from mlipaudit.run_mode import RunMode
3734

35+
if TYPE_CHECKING:
36+
# Used only in type annotations. The matching runtime imports happen lazily inside
37+
# the run methods so that importing this benchmark module (e.g. for the CLI
38+
# benchmark listing) does not pull in the heavy mlip/JAX-MD stack.
39+
from mlip.simulation.configs import ASESimulationConfig
40+
41+
from mlipaudit.benchmarks.nudged_elastic_band.engine import NEBSimulationConfig
42+
3843
logger = logging.getLogger("mlipaudit")
3944

4045
NEB_DATASET_FILENAME = "grambow_dataset_neb.json"
@@ -230,6 +235,13 @@ class NudgedElasticBandBenchmark(Benchmark):
230235

231236
def run_model(self) -> None:
232237
"""Run the NEB calculation."""
238+
# Imported lazily (pulls in the heavy mlip/JAX-MD stack via the NEB engine).
239+
from mlip.simulation.configs import ASESimulationConfig # noqa: PLC0415
240+
241+
from mlipaudit.benchmarks.nudged_elastic_band.engine import ( # noqa: PLC0415
242+
NEBSimulationConfig,
243+
)
244+
233245
self.model_output = NEBModelOutput(
234246
simulation_states=[],
235247
)
@@ -364,6 +376,8 @@ def _run_minimization(
364376
atoms_initial_em: The initial atoms after energy minimization.
365377
atoms_final_em: The final atoms after energy minimization.
366378
"""
379+
from mlip.simulation.ase import ASESimulationEngine # noqa: PLC0415
380+
367381
em_engine_initial = ASESimulationEngine(initial_atoms, ff, em_config)
368382
em_engine_initial.run()
369383

@@ -398,6 +412,10 @@ def _run_neb(
398412
Returns:
399413
neb_engine_climb: The nudged elastic band engine with climbing image method.
400414
"""
415+
from mlipaudit.benchmarks.nudged_elastic_band.engine import ( # noqa: PLC0415
416+
NEBSimulationEngine,
417+
)
418+
401419
neb_engine = NEBSimulationEngine(
402420
initial_atoms, final_atoms, ff, neb_config, transition_state=ts_atoms
403421
)

src/mlipaudit/benchmarks/water_radial_distribution/water_radial_distribution.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
from ase.io import read as ase_read
2323
from mlip.simulation import SimulationState
2424
from pydantic import ConfigDict, NonNegativeFloat
25-
from sklearn.metrics import mean_absolute_error, root_mean_squared_error
2625

2726
from mlipaudit.benchmark import (
2827
DEFAULT_CHARGE,
@@ -177,6 +176,11 @@ def analyze(self) -> WaterRadialDistributionResult:
177176
Raises:
178177
RuntimeError: If called before `run_model()`.
179178
"""
179+
from sklearn.metrics import ( # noqa: PLC0415
180+
mean_absolute_error,
181+
root_mean_squared_error,
182+
)
183+
180184
if self.model_output is None:
181185
raise RuntimeError("Must call run_model() first.")
182186

src/mlipaudit/main.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,11 @@
1717
from argparse import ArgumentParser, Namespace, RawDescriptionHelpFormatter
1818

1919
import mlipaudit
20-
from mlipaudit.app import launch_app
2120
from mlipaudit.benchmark import Benchmark
2221
from mlipaudit.benchmarks import (
2322
BENCHMARK_NAMES,
2423
BENCHMARKS,
2524
)
26-
from mlipaudit.benchmarks_cli import run_benchmarks
2725
from mlipaudit.run_mode import RunMode
2826

2927
logger = logging.getLogger("mlipaudit")
@@ -202,6 +200,8 @@ def main():
202200
mlip_logger.setLevel(logging.WARNING)
203201

204202
if args.command == "benchmark":
203+
from mlipaudit.benchmarks_cli import run_benchmarks # noqa: PLC0415
204+
205205
benchmarks_to_run = _get_benchmarks_to_run(args)
206206
run_benchmarks(
207207
model_paths=args.models,
@@ -213,6 +213,8 @@ def main():
213213
log_timings=args.log_timings,
214214
)
215215
elif args.command == "gui":
216+
from mlipaudit.app import launch_app # noqa: PLC0415
217+
216218
launch_app(args.results_dir, args.is_public)
217219
else:
218220
parser.print_help()

src/mlipaudit/utils/__init__.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,22 @@
1313
# limitations under the License.
1414

1515
from mlipaudit.utils.inference import run_inference
16-
from mlipaudit.utils.simulation import (
17-
ASESimulationEngineWithCalculator,
18-
run_simulation,
19-
)
16+
from mlipaudit.utils.simulation import run_simulation
2017
from mlipaudit.utils.trajectory_helpers import (
2118
create_ase_trajectory_from_simulation_state,
2219
create_mdtraj_trajectory_from_simulation_state,
2320
)
2421
from mlipaudit.utils.unallowed_elements import skip_unallowed_elements
22+
23+
24+
def __getattr__(name: str):
25+
# `ASESimulationEngineWithCalculator` is built lazily (it derives from a heavy
26+
# mlip base class). Re-export it via PEP 562 so importing `mlipaudit.utils` does
27+
# not pull in the JAX-MD stack unless the class is actually used.
28+
if name == "ASESimulationEngineWithCalculator":
29+
from mlipaudit.utils._ase_engine import ( # noqa: PLC0415
30+
ASESimulationEngineWithCalculator,
31+
)
32+
33+
return ASESimulationEngineWithCalculator
34+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

src/mlipaudit/utils/_ase_engine.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Copyright 2025 InstaDeep Ltd
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""Custom ASE simulation engine.
15+
16+
This module is kept separate from :mod:`mlipaudit.utils.simulation` because
17+
importing it pulls in the heavy ``mlip.simulation.ase`` (and therefore JAX-MD)
18+
stack. It is only imported lazily, when a simulation is actually run, so that
19+
merely importing the benchmark modules (e.g. for the CLI) stays fast.
20+
"""
21+
22+
import logging
23+
from typing import Callable
24+
25+
import ase
26+
from ase.calculators.calculator import Calculator as ASECalculator
27+
from mlip.simulation import SimulationState
28+
from mlip.simulation.ase import ASESimulationEngine
29+
from mlip.simulation.configs import ASESimulationConfig
30+
from mlip.simulation.enums import SimulationType
31+
from mlip.simulation.temperature_scheduling import get_temperature_schedule
32+
33+
logger = logging.getLogger("mlipaudit")
34+
35+
36+
class ASESimulationEngineWithCalculator(ASESimulationEngine):
37+
"""Class derived from mlip's ASE simulation engine but allowing for a passed
38+
ASE calculator object.
39+
"""
40+
41+
def __init__(
42+
self,
43+
atoms: ase.Atoms,
44+
ase_calculator: ASECalculator,
45+
config: ASESimulationConfig,
46+
) -> None:
47+
"""Overridden constructor that takes in an ASE calculator instead of an
48+
mlip force field class.
49+
50+
Args:
51+
atoms: The ASE atoms.
52+
ase_calculator: The ASE calculator to use in the simulation.
53+
config: The simulation config.
54+
"""
55+
self.state = SimulationState()
56+
self.loggers: list[Callable[[SimulationState], None]] = []
57+
58+
logger.debug("Initialization of simulation begins...")
59+
self._config = config
60+
self.atoms = atoms
61+
self.atoms.center()
62+
positions = atoms.get_positions()
63+
self._num_atoms = positions.shape[0]
64+
self.state.atomic_numbers = atoms.numbers
65+
66+
self._init_box()
67+
68+
self.is_md_simulation = self._config.simulation_type == SimulationType.MD
69+
self.is_npt_simulation = (
70+
self.is_md_simulation and self._config.md_integrator.ensemble == "npt"
71+
)
72+
73+
self.model_calculator = ase_calculator
74+
75+
self._temperature_schedule = get_temperature_schedule(
76+
self._config.temperature_schedule_config, self._config.num_steps
77+
)
78+
79+
logger.debug("Initialization of simulation completed.")

src/mlipaudit/utils/inference.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,19 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14+
from __future__ import annotations
15+
1416
import logging
17+
from typing import TYPE_CHECKING
1518

1619
import ase
1720
from ase.calculators.calculator import Calculator as ASECalculator
18-
from mlip.inference import run_batched_inference
19-
from mlip.models import ForceField
20-
from mlip.typing import Prediction
21+
22+
if TYPE_CHECKING:
23+
# These pull in the heavy mlip/JAX stack; imported lazily inside `run_inference`
24+
# so that merely importing this module stays cheap.
25+
from mlip.models import ForceField
26+
from mlip.typing import Prediction
2127

2228
logger = logging.getLogger("mlipaudit")
2329

@@ -45,6 +51,12 @@ def run_inference(
4551
Raises:
4652
ValueError: If force field type is not compatible.
4753
"""
54+
# Imported lazily to keep importing this module cheap (these pull in the heavy
55+
# mlip/JAX stack, only needed when inference is actually run).
56+
from mlip.inference import run_batched_inference # noqa: PLC0415
57+
from mlip.models import ForceField # noqa: PLC0415
58+
from mlip.typing import Prediction # noqa: PLC0415
59+
4860
if isinstance(force_field, ForceField):
4961
try:
5062
predictions = run_batched_inference(

0 commit comments

Comments
 (0)