Skip to content

refactor!: split the oversized utils modules, and cut the optimizer's host-device syncs - #141

Merged
isayev merged 2 commits into
mainfrom
debt/wave4-file-splits-and-cleanup
Aug 4, 2026
Merged

refactor!: split the oversized utils modules, and cut the optimizer's host-device syncs#141
isayev merged 2 commits into
mainfrom
debt/wave4-file-splits-and-cleanup

Conversation

@isayev

@isayev isayev commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Wave 4: file splits, cleanup, and the optimizer's host↔device syncs. Three lanes that
interleave on SPE.py and ANI2xt_no_rep.py, so they land together rather than as a commit
that cannot import.

A pre-existing crash, found while implementing

The old FIRE loop assigned into smallest_fmax with a boolean mask. That raises on any
custom NNP returning float64 forces
— but only when two or more molecules reduce in the
same step, because a single one took the masked_fill_ fast path and cast silently. A real
crash with a molecule-count-dependent trigger, which is why it was never reliably hit.

The explicit .to(dtype) at each index_copy_ fixes it, and it is the one place the rewrite
is deliberately not bit-identical.

This surfaced from correcting a claim in our own design: index_put_ does not silently
cast for tensor values — it raises too. Only the scalar masked_fill_ path casts.

Optimizer syncs

before after
syncs per FIRE step 18 2
ANI2xt syncs per forward 22 2
torch.compile subgraphs (ANI2xt element loop) 0 1, fullgraph=True passes

One nonzero() feeds index_select reads and index_copy_ writes; the
smallest_fmax/oscillating_count block becomes two torch.where calls needing no index at
all — torch.where and not torch.minimum, because < is False for NaN and minimum
would change NaN semantics.

18 → 2, not 18 → 0. nonzero is itself a sync, and clean() needs a second one. Stated
plainly because the honest number is the point of this cluster.

clean() now takes an int64 index and rejects a bool mask, which would otherwise have been
reinterpreted as the indices [1,1,0,0].

Two claims we could not deliver, reported rather than fudged

  • The design targeted 22 → 1 for ANI2xt and we reached 22 → 2. That target assumed the
    adapter could cache per bucket keyed on species identity, but the engine gathers a fresh
    species tensor every step, so there is no object identity to key on. Closing the last sync
    means threading bucket lifetime through the adapter contract — flagged as a follow-up, not
    claimed.
  • The manifest said the element loop broke torch.compile into 7 graphs. It compiled to
    zero.
    The data-dependent branch sits inside the for elem_idx, network loop, so Dynamo
    skipped the entire frame. A control test pins that the guarded loop gives 0, not 7.

Bit-identity, proven rather than asserted

Against a test-local reimplementation of the old bool-mask loop, running in the same process
so there is no checked-in golden file and no cross-platform float hazard:

  • M6: 17 scenarios × 5 state tensors, all torch.equal — staggered convergence,
    oscillation drops, all-oscillating, single molecule, n=0, batch 64, padded batch, seeds
    7–16. A meta-test asserts the staggered scenario really shrinks the active set in ≥3 stages,
    so the gathers are genuinely partial rather than trivially whole-batch.
  • M57 (n_steps, 200 lines on the hot path, decomposed into four helpers): step-for-step
    over 40 prefix lengths × 2 padding modes × 5 tensors, all torch.equal, plus a guard that
    the sampled prefixes are 4 distinct states.

No speedup is claimed

Every count above is exact and CI-enforced on CPU. No duration is measurable here — this
box's eight GPUs are all in use — so nothing states one.

benchmarks/run_perf_ab.sh v4.0.0 is one command for a maintainer with a free GPU. It times
both sides with identical instrumentation on the same hardware, and aborts rather than
reporting
if the two trees are the same, the hardware differs, there is no GPU, or the
converged counts / energies move. Noisy rows (IQR > 10% of median) are flagged and excluded,
and the summary quotes a range across batch sizes, never a best case.

Four tracked files claimed a ~1.25× torch.compile speedup for the path that compiled to
zero graphs
advanced_usage.rst, migration.rst, howto/hpc.rst, and both adapter
docstrings. All now state that it is off by default, that no figure has been measured, and how
to measure one.

File splits

utils/file_ops.py (849 lines) and utils/chemistry.py (580) are deleted with no shims,
becoming utils/{smi_io,sdf_io,reconciliation,output_guard,geometry,connectivity,molprops,atomic_io}.py
plus top-level id_mapping.py, job_layout.py, clash_relief.py and embedding.py.

output_guard had to come out of validation.py first: validation.py imports torch and
the model tree, so without that extraction a .smi writer pulled all of it in. A subprocess
test asserts the file-I/O modules load neither.

Phases 1 and 2 are pure moves, and the suite is the oracle for that: exactly one assertion
changed
, in a test reading hartree2ev off a module that no longer exists. Loggers are
preserved byte-for-byte — chemistry.py used getLogger("auto3d"), and a renamed logger
would have silently stopped reaching the run log.

isomers/ loses its adapter shell and, with parallel_embed moved out to embedding.py, its
import cycle: isomer_engine.py now imports nothing under Auto3D.isomers at any scope,
enforced by an AST test.

Engine construction stays deferred to run() deliberatelyRDKitIsomer.__init__ calls
rdk_tmp.mkdir(), so constructing eagerly would move a filesystem side effect to a new point
in the run. Two tests assert construction touches no filesystem.

tests/test_isomers.py needed real assertion changes rather than import churn, because it
asserted isinstance against the adapter classes being deleted. A spy fixture now pins the
full kwarg mapping per engine — strictly stronger than the attribute reads it replaces.

One way to replace a file

utils/atomic_io.py's atomic_write_path stages a sibling temp, copies the target's mode onto
it, and os.replace()s. reorder_sdf did neither — predictable .reorder.tmp name, target's
mode dropped — so a 0600 input came back 0664, a permission loosening. Confirmed failing
first:

AssertionError: reorder_sdf changed the output file's mode from 0600 to 0664
AssertionError: reorder_sdf still stages through a predictable name:
  ['.../out.sdf.reorder.tmp']

Now used at three call sites. The test that was supposed to guard this scanned for ".tmp"
in the filename, which mkstemp's tmpXXXXXXXX.sdf never matches — it would have passed
either way.
Rewritten as an exclusion scan.

Cleanup

  • smiles2mols built torch.device(f"cuda:{idx}") by hand with no bounds check, so an
    out-of-range index produced cuda:99 instead of an error. It now goes through get_device,
    which raises GPUError. Demonstrated: get_device(99, use_gpu=True) raises where the old
    code did not.
  • main()'s docstring advertised a SystemExit it never raises.
  • ev2hatree was defined twice and misspelled; it is now EV_TO_HARTREE in constants.py.
  • The name-or-default idiom repeated 11 times in thermo.py is one helper; three duplicated
    CLI error paths collapse.
  • utils/__init__.py's module list — which the previous wave made the authoritative
    description of this package — documented two modules that no longer exist and omitted nine
    that do. Corrected.

Deliberately not in this PR

  • B5 phase 4 — the behavior changes: unifying the two conformer filters, the overwrite
    gate, the drop-reason contract, the selector dispatch registry. Behavior, not moves, and it
    gets its own review. The E_tot strict xfail stays pinned until then.
  • C group 2 — M59's five .smi parsers consolidate onto iter_smi_records, which was
    inside the file this PR splits. Now unblocked.
  • M36–M39 — four unrelated one-site fixes that must not gate the sync work.
  • Renaming the colliding TautomerEngine Protocol and deleting create_isomer_engine
    both touch a documented public surface and want their own review. create_isomer_engine is
    confirmed to have zero src/ callers but three test modules import it.

Verification

1576 passed, 9 skipped, 1 xfailed, ruff clean, three randomized orderings. The remaining
xfail is the E_tot filter divergence, closed by the next phase.

isayev added 2 commits August 4, 2026 10:06
… syncs

Three lanes that interleave on SPE.py and ANI2xt_no_rep.py, so they land
together rather than as a commit that cannot import.

## File splits

utils/file_ops.py (849 lines) and utils/chemistry.py (580) are deleted with no
shims, becoming utils/{smi_io,sdf_io,reconciliation,output_guard,geometry,
connectivity,molprops,atomic_io}.py plus top-level id_mapping.py,
job_layout.py, clash_relief.py and embedding.py.

output_guard had to come out of validation.py first: validation.py imports
torch and the model tree, so without the extraction a .smi writer pulled all
of it in. A subprocess test asserts the file-I/O modules load neither.

Phases 1 and 2 are pure moves, and the suite is the oracle for that: exactly
one assertion changed, in a test reading hartree2ev off the module that no
longer exists. Loggers are preserved byte-for-byte, since chemistry.py used
getLogger("auto3d") and a renamed logger would silently stop reaching the run
log.

isomers/ loses its adapter shell and, with parallel_embed moved out to
embedding.py, its import cycle: isomer_engine.py now imports nothing under
Auto3D.isomers at any scope, enforced by an AST test. Engine construction
stays deferred to run() deliberately -- RDKitIsomer.__init__ calls
rdk_tmp.mkdir(), so constructing eagerly would move a filesystem side effect
to a new point in the run. Two tests assert construction touches no
filesystem.

test_isomers.py needed real assertion changes, not import churn: it asserted
isinstance against the adapter classes being deleted. A spy fixture now pins
the full kwarg mapping per engine, which is strictly stronger than the
attribute reads it replaces.

## One way to replace a file

utils/atomic_io.py's atomic_write_path stages a sibling temp, copies the
target's mode onto it, and os.replace()s. reorder_sdf did neither: it used a
predictable .reorder.tmp name and dropped the target's mode, so a 0600 input
came back 0664 -- a permission loosening. Confirmed as a failing test first,
then fixed, across three call sites.

The test that was supposed to guard this scanned for ".tmp" in the filename,
which mkstemp's tmpXXXXXXXX.sdf never matches, so it would have passed either
way. Rewritten as an exclusion scan.

## Cleanup

ev2hatree was defined twice, misspelled, and is now EV_TO_HARTREE in
constants.py. smiles2mols built torch.device(f"cuda:{idx}") by hand with no
bounds check, so an out-of-range index produced cuda:99 instead of an error;
it now goes through get_device, which raises GPUError. main()'s docstring
advertised a SystemExit it never raises. The name-or-default idiom repeated 11
times in thermo.py is one helper. Three duplicated CLI error paths collapse.

## Optimizer syncs

The FIRE loop did 18 host-device syncs per step from boolean-mask indexing.
One nonzero() now feeds index_select reads and index_copy_ writes, and the
smallest_fmax/oscillating_count block becomes two torch.where calls needing no
index at all -- torch.where and not torch.minimum, because `<` is False for
NaN and minimum would change NaN semantics.

18 syncs become 2, not 0: nonzero is itself a sync, and clean() needs a second
one. Bit-identity is proven against a test-local reimplementation of the old
loop running in the same process -- 17 scenarios x 5 state tensors, all
torch.equal -- with a meta-test asserting the staggered scenario really does
shrink the active set in stages, so the gathers are genuinely partial.

One deliberate exception to bit-identity, and it fixes a pre-existing crash:
the old loop assigned into smallest_fmax with a bool mask, which raises on a
custom NNP returning float64 forces, but only with two or more molecules
reducing at once -- a single one took the masked_fill_ fast path and cast
silently. The explicit .to(dtype) at each index_copy_ makes that work.

clean() now takes an int64 index and rejects a bool mask, which would
otherwise have been reinterpreted as the indices [1,1,0,0].

n_steps, 200 lines on the hot path, decomposes into four helpers. Step-for-step
bit-identity over 40 prefix lengths x 2 padding modes x 5 tensors.

The ANI2xt element loop broke torch.compile into zero graphs, not seven: the
data-dependent branch sits inside the loop, so Dynamo skipped the whole frame.
It now compiles to one graph and satisfies fullgraph=True.

No speedup is claimed anywhere. Every count here is exact and CI-enforced on
CPU; no duration is measurable on a box whose GPUs are all busy.
benchmarks/run_perf_ab.sh is one command for a maintainer with a free GPU, and
it aborts rather than reporting if the two trees are the same, the hardware
differs, or the converged counts move.

Four tracked files claimed a ~1.25x torch.compile speedup for a path that
compiled to zero graphs. They now say it is off by default, that no figure has
been measured, and how to measure one.

Verified: 1576 passed, 9 skipped, 1 xfailed, ruff clean, three randomized
orderings. The remaining xfail is the E_tot filter divergence, which the next
phase closes.
The assertion was strengthened from "any valid molecule produced a result" to
"both did", which is the right assertion -- a batch that aborts right after the
corrupt record satisfies the weaker one. But it was derived from reading the
source, and the test needs a real NNP, so it could not be run where it was
written. CI's slow tier then failed with {'ethanol'} != {'ethanol', 'propanol'}.

The code was never the problem. The input was. RDKit's SDMolSupplier, given
"this is not a molecule\n$$$$\n" between two valid records, logs "moving to the
beginning of the next molecule" and consumes the following record while
resynchronizing: the supplier yields [ethanol, None] and propanol is never
handed over at all. No implementation of calc_thermo could have produced a
propanol result from that file.

The corrupt block is now a well-delimited record -- header, counts line
promising two atoms, garbage where the coordinates belong, M END, $$$$ -- so
the supplier yields exactly [ethanol, None, propanol]. Measured against this
repo's RDKit 2025.09.6, along with a check that iter_thermo_records skips the
None and keeps both valid records, so the guarded path is confirmed to fire
without loading a model.

The docstring now records both supplier behaviors the input depends on, since
each one silently makes the test vacuous in a different way.
@isayev
isayev merged commit 16e6ff3 into main Aug 4, 2026
8 checks passed
@isayev
isayev deleted the debt/wave4-file-splits-and-cleanup branch August 4, 2026 15:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant