refactor!: split the oversized utils modules, and cut the optimizer's host-device syncs - #141
Merged
Merged
Conversation
… 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Wave 4: file splits, cleanup, and the optimizer's host↔device syncs. Three lanes that
interleave on
SPE.pyandANI2xt_no_rep.py, so they land together rather than as a committhat cannot import.
A pre-existing crash, found while implementing
The old FIRE loop assigned into
smallest_fmaxwith a boolean mask. That raises on anycustom 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 realcrash with a molecule-count-dependent trigger, which is why it was never reliably hit.
The explicit
.to(dtype)at eachindex_copy_fixes it, and it is the one place the rewriteis deliberately not bit-identical.
This surfaced from correcting a claim in our own design:
index_put_does not silentlycast for tensor values — it raises too. Only the scalar
masked_fill_path casts.Optimizer syncs
torch.compilesubgraphs (ANI2xt element loop)fullgraph=TruepassesOne
nonzero()feedsindex_selectreads andindex_copy_writes; thesmallest_fmax/oscillating_countblock becomes twotorch.wherecalls needing no index atall —
torch.whereand nottorch.minimum, because<is False for NaN andminimumwould change NaN semantics.
18 → 2, not 18 → 0.
nonzerois itself a sync, andclean()needs a second one. Statedplainly 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 beenreinterpreted as the indices
[1,1,0,0].Two claims we could not deliver, reported rather than fudged
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.
torch.compileinto 7 graphs. It compiled tozero. The data-dependent branch sits inside the
for elem_idx, networkloop, so Dynamoskipped 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:
torch.equal— staggered convergence,oscillation drops, all-oscillating, single molecule,
n=0, batch 64, padded batch, seeds7–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.
n_steps, 200 lines on the hot path, decomposed into four helpers): step-for-stepover 40 prefix lengths × 2 padding modes × 5 tensors, all
torch.equal, plus a guard thatthe 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.0is one command for a maintainer with a free GPU. It timesboth 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.compilespeedup for the path that compiled tozero graphs —
advanced_usage.rst,migration.rst,howto/hpc.rst, and both adapterdocstrings. 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) andutils/chemistry.py(580) are deleted with no shims,becoming
utils/{smi_io,sdf_io,reconciliation,output_guard,geometry,connectivity,molprops,atomic_io}.pyplus top-level
id_mapping.py,job_layout.py,clash_relief.pyandembedding.py.output_guardhad to come out ofvalidation.pyfirst:validation.pyimports torch andthe model tree, so without that extraction a
.smiwriter pulled all of it in. A subprocesstest 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
hartree2evoff a module that no longer exists. Loggers arepreserved byte-for-byte —
chemistry.pyusedgetLogger("auto3d"), and a renamed loggerwould have silently stopped reaching the run log.
isomers/loses its adapter shell and, withparallel_embedmoved out toembedding.py, itsimport cycle:
isomer_engine.pynow imports nothing underAuto3D.isomersat any scope,enforced by an AST test.
Engine construction stays deferred to
run()deliberately —RDKitIsomer.__init__callsrdk_tmp.mkdir(), so constructing eagerly would move a filesystem side effect to a new pointin the run. Two tests assert construction touches no filesystem.
tests/test_isomers.pyneeded real assertion changes rather than import churn, because itasserted
isinstanceagainst the adapter classes being deleted. A spy fixture now pins thefull kwarg mapping per engine — strictly stronger than the attribute reads it replaces.
One way to replace a file
utils/atomic_io.py'satomic_write_pathstages a sibling temp, copies the target's mode ontoit, and
os.replace()s.reorder_sdfdid neither — predictable.reorder.tmpname, target'smode dropped — so a 0600 input came back 0664, a permission loosening. Confirmed failing
first:
Now used at three call sites. The test that was supposed to guard this scanned for
".tmp"in the filename, which
mkstemp'stmpXXXXXXXX.sdfnever matches — it would have passedeither way. Rewritten as an exclusion scan.
Cleanup
smiles2molsbuilttorch.device(f"cuda:{idx}")by hand with no bounds check, so anout-of-range index produced
cuda:99instead of an error. It now goes throughget_device,which raises
GPUError. Demonstrated:get_device(99, use_gpu=True)raises where the oldcode did not.
main()'s docstring advertised aSystemExitit never raises.ev2hatreewas defined twice and misspelled; it is nowEV_TO_HARTREEinconstants.py.thermo.pyis one helper; three duplicatedCLI error paths collapse.
utils/__init__.py's module list — which the previous wave made the authoritativedescription of this package — documented two modules that no longer exist and omitted nine
that do. Corrected.
Deliberately not in this PR
gate, the drop-reason contract, the selector dispatch registry. Behavior, not moves, and it
gets its own review. The
E_totstrict xfail stays pinned until then..smiparsers consolidate ontoiter_smi_records, which wasinside the file this PR splits. Now unblocked.
TautomerEngineProtocol and deletingcreate_isomer_engine—both touch a documented public surface and want their own review.
create_isomer_engineisconfirmed 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_totfilter divergence, closed by the next phase.