Skip to content

Parallel embedding by default with cores-scaled workers; time-capped ETKDG with one random-coordinates retry - #184

Merged
isayev merged 11 commits into
mainfrom
feat/embedding-throughput
Oct 5, 2026
Merged

isayev merged 11 commits into
mainfrom
feat/embedding-throughput

Conversation

@isayev

@isayev isayev commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Workstream 3 of the review remediation plan: embedding throughput. Parallel conformer embedding becomes the default with workers scaled to the machine, and every ETKDG call is time-capped and retried once with random initial coordinates.

Embedding time cap and retry (Task 17)

  • Every ETKDG call in the package (the SMILES engine's serial and parallel paths and the SDF engine) now goes through one embed_with_retry, which sets RDKit's EmbedParameters.timeout to EMBED_TIMEOUT_S (60 s) and, when the first attempt embeds nothing, retries once with random initial coordinates and the same seed. A stereoisomer that ETKDG cannot embed used to run unbounded; on the 2026-09-21 bench set one such species cost about 67 s, serially, before failing.
  • Geometry is unchanged for molecules that embed on the first attempt (byte-identical coordinates in the reviewer's probe); the retry fires only on zero conformers and logs at DEBUG, so callers keep their existing "produced no conformers" warning.
  • The retry runs on the budget the first attempt left: both attempts together honor the 60 s cap, and a first attempt that used the whole cap is not retried. A species whose embedding exceeds the cap keeps the conformers it managed to generate, so its conformer count is not reproducible run to run; the usage guide says so.

Parallel embedding by default (Task 18)

  • use_parallel_embedding defaults to on (spec decision D1). parallel_workers=None now means "resolve at dispatch": min(cores, species, 32), divided by the per-worker RDKit thread count so workers times threads never exceed the cores. An explicit integer is honored as given. The serial path remains for --no-parallel-embedding / use_parallel_embedding=False and for inputs below parallel_embedding_threshold (unchanged, 10).
  • smiles2mols keeps its documented single-process contract: it embeds in parallel only when called with the new keyword parallel_embedding=True. The use_parallel_embedding option governs main() only, because parallel embedding spawns worker processes that re-import the calling script, and the shipped smiles2mols examples are plain scripts without a main guard. An inferred opt-in was tried first and found to be defeated by replace() and YAML-built configs, so the keyword replaced it. A broken worker pool now carries a note saying exactly that.
  • Embedding pool workers now run the same parent-death initializer as the optimizer workers, so a parent killed mid-embedding no longer strands up to 32 RDKit processes. Worker-side skip reasons (unparseable SMILES, dummy atoms) are raised to the parent and logged once through the run's logging, instead of falling through to an unformatted stderr line. Conformer coordinates cross the pool boundary as doubles, so serial and parallel embedding are byte-identical.
  • auto3d run gains --parallel-embedding/--no-parallel-embedding, mapped like --tf32. YAML accepts parallel_workers: None; parameters.yaml shows the new defaults. The usage guide and the CLI reference document the option and the time cap.
  • Measured on one 128-core box, 100 species: 9.5 s with 4 workers, 2.0 s with the resolved 32, identical conformer counts. This number is for the PR record only and is not in the docs.

Test tiers (rider Task 37)

  • The sixteen slowest tests of the default suite (five seconds or more each; together about 210 of its 279 seconds on one box) now carry the slow marker. CI already runs the two tiers separately on every PR, so their coverage is unchanged; the local default run is about four times shorter.

Verification

  • Full fast suite on the branch head: 1923 passed, 1 skipped, 93 deselected (slow tier), in 101 s.
  • ruff check and ruff format --check clean.
  • Every fix carries a regression test that failed before the change; reviewers reproduced the RED state independently (retry disabled, timeout absent, worker resolution disabled, the serial default restored, the smiles2mols opt-in capture moved after replace()).
  • The slow end-to-end suite was run locally once after the default flip (six passed, exercising the parallel path through main()) and runs in CI's slow job on every push.
  • An unguarded script calling smiles2mols with twelve SMILES was run against the pre-fix and post-fix commits: a broken worker pool before, a serial run after.

Notes for reviewers

  • Behavior change (spec decision D1): main() and the CLI embed in parallel by default for inputs of ten or more species after enumeration. smiles2mols does not, unless asked.
  • parallel_workers=None is resolved per run as min(cores divided by RDKit threads per worker, species, 32). An explicit integer is honored as given.
  • os.cpu_count() ignores cgroup CPU quotas and affinity; a container with a small quota on a large host resolves more workers than it has CPU. Deferred to the performance-levers workstream.
  • CHANGELOG entries are under [Unreleased] for 3.2.0.

isayev added 11 commits October 2, 2026 11:36
embed_params now sets EmbedParameters.timeout (EMBED_TIMEOUT_S, guarded
for older RDKit) so a geometrically impossible species cannot stall the
whole run. embed_with_retry wraps EmbedMultipleConfs and retries once
with useRandomCoords when the first attempt embeds nothing; _embed_single
now goes through it.
RDKitIsomer.embed_conformer and RDKitSdfIsomer.run now call
embed_with_retry instead of EmbedMultipleConfs directly, so the timeout
and random-coords retry apply everywhere Auto3D embeds a species, not
only in the parallel worker.
Conformer embedding ran serially unless a caller found and set
use_parallel_embedding, and the worker count behind it was a fixed 4 --
which left 124 of 128 cores idle on the bench box (P-C3). The switch is
now on by default and the count is no longer a class default at all:
parallel_workers=None resolves to min(cores, species, 32) in
resolve_embedding_workers, called at dispatch, where both the machine and
the number of species this run enumerated are known.

The serial path is unchanged and still runs for inputs below
parallel_embedding_threshold and for use_parallel_embedding=False.
parallel_workers joins SENTINEL_FIELDS so None passes the bounds loop
while a concrete count below 1 is still refused, and the log line names
the resolved worker count rather than the unresolved request.
With parallel embedding on by default, the serial path needed a way back
from the command line: the flag maps to use_parallel_embedding the same
way --tf32/--no-tf32 maps to allow_tf32, defaulting to None so a value in
a -c config file survives a flag the user did not pass.

The shipped parameters.yaml now shows the new defaults, with
parallel_workers spelled None -- this file's convention for unset, which
load_yaml_config converts -- rather than a worker count the code no
longer picks. cli.rst gains the option row and usage.rst a paragraph on
the three embedding options and the per-species time cap.
Two consequences of the default flip, both found in review.

smiles2mols runs in the caller's own process, and the spawn pool re-imports
the calling script in every worker -- so a default-on parallel path broke
every documented smiles2mols example, none of which has an
`if __name__ == "__main__":` guard, as soon as the batch reached
parallel_embedding_threshold. It now embeds in parallel only when the
caller sets use_parallel_embedding explicitly, read from model_fields_set
before replace() rebuilds the config and marks every field as set. main()
and `auto3d run` still honor the default; they have always required the
guard. BrokenProcessPool keeps its type, since an OOM kill must surface the
way it always has, but now carries a note naming the guard and the serial
fallback -- previously the only clue was a child-process traceback about
freeze_support.

Each worker also hands mpi_np threads to EmbedMultipleConfs, so one worker
per core put cores x threads runnable threads on the box: a 4x
oversubscription on a small machine. resolve_embedding_workers takes
threads_per_worker and divides the core count by it before the cap, which
the engine supplies from its own np.

Also: the constructor defaults on RDKitIsomer and the factory stay off
while the product default is on, so both docstrings now say which is which
and the two tests that pin them say "constructor_default_off"; the
threshold and the species term are documented as counting species after
stereoisomer enumeration, not input molecules; the 60 s ETKDG cap and its
one retry are documented as applying to every embedding call, serial or
parallel, SDF engine included; and check_field_bounds no longer names the
CLIConfig class that no longer exists.
Document the parallel embedding default, the per-worker-thread
division behind resolve_embedding_workers, and smiles2mols' opt-in
exception and broken-pool guidance. Record the ETKDG timeout and
random-coords retry, now applied to every embedding call site, and
the unbounded-embedding bug it fixes.
Fast suite wall clock dropped from 279 s to 116 s (pytest-reported;
131 s real) by moving the 16 slowest test items (14 functions, two
parametrized) to @pytest.mark.slow. CI's `slow` job already runs
`-m slow` on every PR, so their coverage is unchanged.
Parallel embedding is on by default on this branch, so an ordinary run now
starts a ProcessPoolExecutor -- and it was the one pool in the package that
armed neither PR_SET_PDEATHSIG nor the parent-sentinel watchdog. Under spawn
each child holds a dup of the call queue's write end as well as its read end,
so the parent's death never produces EOF, and a parent-only signal (the
orchestrator's own p1.terminate(), an OOM kill, a plain kill) runs none of the
`with` block's shutdown sentinels either: an idle worker blocked on get()
forever and a busy one kept burning a core on ETKDG. Measured twice before the
fix, both workers reparented to init and still sleeping 15 s later. A terminal
Ctrl-C was never the gap -- that is a process-group signal the children share.

The same initializer sets CoordsAsDouble, because the worker is where the
pickling happens. RDKit pickles conformer coordinates as float32 by default, so
the result travelling back from a worker lost its low bits: at SDF write
precision that flipped one coordinate of one species in a 28-species
comparison, which made a documented performance switch change the bytes of the
output. Set in the initializer rather than at module import: the pickle
properties are process-global, every spawned worker runs the initializer, and
importing the module must not reconfigure RDKit for a caller that never starts
a pool -- the same stance the module already takes on set_start_method.

_embed_single no longer logs its own skip reasons. A pool child has no
QueueHandler (the run-log wiring is for the optimizer workers), so those
warnings fell through to logging.lastResort: raw on stderr, carrying no level
or logger prefix, ungoverned by --quiet, and absent from the run log -- which
recorded the parent's "produced no conformers" line, naming the species but not
the reason, for a console total of two lines per bad species where the serial
path shows one. The reason now rides a SpeciesSkipped back to the parent, which
emits exactly one formatted warning and skips the generic empty-result branch
for that species. The serial path (RDKitIsomer.embed_conformer) is unchanged:
its caller is two frames away, so a sentinel return still reaches the right
logger.
…word

Two defects the whole-branch review found in the embedding work.

embed_with_retry fired its random-coordinates retry whenever the first
attempt returned nothing, including when it returned nothing because it
had burned the whole 60 s cap -- so the per-species worst case was two
caps, 120 s, against the ~67 s one impossible stereoisomer measured
serially on the 2026-09-21 bench set, which is the number the cap was
written to bound. The retry now runs on the remainder of the budget:
attempt 1 is timed, the retry gets ceil(timeout_s - elapsed) seconds, and
under a second left it is skipped with a DEBUG line. Both attempts
together stay inside timeout_s, to within the second the rounding can
add, and a species that fails fast still gets its recovery.

smiles2mols inferred its parallel-embedding opt-in from
"use_parallel_embedding" in args.model_fields_set, which asks a config
object a question it cannot answer: replace() rebuilds the model through
its constructor and every YAML-built config names its keys, so both mark
every field as explicitly set and both silently took the spawn path --
including the parameters.yaml this repo ships. The opt-in is now a
keyword-only argument, smiles2mols(smiles, args, parallel_embedding=True),
and args.use_parallel_embedding is ignored by this entry point (one DEBUG
line when it is True, so a debug log says why the run was serial). That
field keeps governing main() and auto3d run.

The regression test for the broken-pool note drives a real unguarded
script through a subprocess: the note is a property of module re-import
under spawn, and the existing test breaks the pool by killing a worker,
which is the OOM shape rather than the commoner one.
Nine text items from the whole-branch review; no behavior change.

The cap's cost is now stated where a user would look. usage.rst says that
a species near the 60 s cap keeps the conformers it managed to embed, so
its conformer count depends on machine load and on mpi_np and is not
reproducible run to run -- the one case CONFORMER_RANDOM_SEED does not
pin -- and it names EMBED_TIMEOUT_S for anyone who would rather have the
reproducibility than the bound. The constant's own comment drops an
aggregate extrapolation nothing measured and keeps the measurement: ~67 s
per impossible species, serially, on the 2026-09-21 bench set.
PARALLEL_EMBED_MAX_WORKERS gains the term nobody had written down, ~85 MiB
RSS per warm worker on the 2026-10-02 box, which makes the 32-worker cap
roughly a 2.5 GiB ceiling as well.

cli.rst stops pointing the CLI user at parallel_workers as though it were
a flag: it is a config-file / Python-API option and the row says so. The
--parallel-embedding help string, and that row, now say the worker count
is per RDKit thread, which is what the division by mpi_np means.

"After stereoisomer enumeration" was one step short of what the gate
counts -- the set it reads has already been through enantiomer removal --
so parallel_embedding_threshold's docstring is now the canonical statement
carrying the full phrase, and the four places that repeat it (cli.rst,
usage.rst, parameters.yaml, the changelog) repeat all of it.
resolve_embedding_workers says that its one-worker floor can still
overshoot a box with fewer cores than threads_per_worker, since closing
that would mean overriding the caller's own mpi_np.

config.py's prose still described CLIConfig as a live second declaration
of the fields, including a claim that a test pins its Literals against
ENGINE_CHOICES; that class and that comparison are both gone. Every
reference now either names the class as deleted or names Auto3DOptions and
both entry points, and the four stale mentions of __post_init__ name the
validator that replaced it.

The changelog's Fixed bullet for the embedding timeout no longer leans on
a Changed bullet further down to define "the cap" and "the retry"; both
are named inline. The Changed bullet picks up the remaining-budget retry,
the smiles2mols keyword, and the three user-visible results of the pool
initializer work -- workers die with their parent, a refused species
reports its reason once in the run log, and serial and parallel embedding
produce byte-identical output.
The note still told every caller to pass use_parallel_embedding=False, which is inert for smiles2mols now that its opt-in is the parallel_embedding keyword. Name the right switch for each entry point; the guard advice is unchanged.
@isayev
isayev marked this pull request as ready for review October 5, 2026 00:03
@isayev
isayev merged commit 18e9905 into main Oct 5, 2026
9 checks passed
@isayev
isayev deleted the feat/embedding-throughput branch October 5, 2026 00:15
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