Skip to content

EEM Partial Charges for 3D descriptors - #376

Merged
scal444 merged 8 commits into
NVIDIA-BioNeMo:mainfrom
scal444:feat/eem-charges-ready
Oct 23, 2026
Merged

scal444 merged 8 commits into
NVIDIA-BioNeMo:mainfrom
scal444:feat/eem-charges-ready

Conversation

@scal444

@scal444 scal444 commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

@greptile-apps

greptile-apps Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The changes appear safe to merge; no new blocking issue was found.

Summary

Adds per-atom EEM charges to the 3D descriptor API, including padded results and RDKit comparisons.

  • 3D descriptor requests can return EEM partial charges for each atom.
  • Dense descriptor results can include charges in padded per-atom tensors.
  • The descriptor benchmark can compare per-atom EEM charges with RDKit.

Reviews (4) · Last reviewed commit: "Bound EEM scratch allocation and handle ..." · Reviewed by Greptile

Comment thread src/descriptors3d_eem.cuh Resolved
Comment thread src/descriptors3d_eem.cuh Resolved
Comment thread benchmarks/descriptors3d_bench.py Resolved
Comment thread nvmolkit/types.py Resolved
Comment thread nvmolkit/descriptors3d.py Resolved
Comment thread src/descriptors3d_eem.cuh Resolved
@scal444
scal444 requested a review from evasnow1992 October 23, 2026 14:48
EEMcharges reproduces RDKit's CalcEEMcharges for every conformer. It is
the first property with one output row per atom: a new Property3DExtent
says whether a property's rows are conformers or atoms, and per-atom
outputs are laid out like the coordinate positions, indexed by the
batch's atomStarts. Device3DPropertyResult now carries atom_starts
(shared with Device3DResult input) and dense() pads per-atom properties
to (n_mols, max_confs, max_atoms) with an atom_mask; the padding helper
is shared with Device3DResult.dense().

One block per conformer builds RDKit's bordered EEM system and solves it
by LU with partial pivoting, in shared memory when it fits and per-block
global scratch otherwise. As in RDKit (which hands its row-major matrix
to Eigen as column-major), charges sum to the negated formal charge.
SINGLE refines the float32 solution once against a float64 residual:
float32 elimination alone drifted up to ~3e-4 on 160-340 atom conformers,
refinement brings SINGLE within 8e-7 of FULL. FULL matches RDKit to 1e-12.

Atoms the parameter set does not cover produce NaN for their conformer:
elements beyond bromine and bond orders above 3, where RDKit reads
undefined memory, and, unlike RDKit, zero-parameter atoms within its
tables (Si, single-bonded P, Na+ counter-ions, ...), which RDKit
silently solves to charges of -5 to -168. Molecules that fail to
kekulize and conformers with coincident atoms (where RDKit returns
all-zero charges) are NaN as well.
RDKit before 2026.9.2 solves a transposed EEM system (rdkit/rdkit#9688), so
its charges for charged molecules sum to the negated formal charge. Solve the
published equations instead, so charges sum to the formal charge.

Tests compare against RDKit only for neutral molecules on RDKit < 2026.9.2,
and add golden-value cases for charged molecules that hold on every version.
…rmers, harden dense()

- Conformers above 46,000 atoms give NaN instead of overflowing 32-bit indexes.
- Size global scratch from the largest conformer actually solved, not the
  largest molecule in the batch.
- Compare EEM charges in the benchmark only where the installed RDKit is correct.
- dense() leaves invalid atom ranges NaN and builds per-atom gather indexes
  only when a per-atom property is requested.
@scal444
scal444 force-pushed the feat/eem-charges-ready branch from 8ec23dc to 0d341ff Compare October 23, 2026 18:30
Comment thread nvmolkit/tests/test_descriptors3d.py Outdated
# RDKit before 2026.9.2 solved the transposed EEM system (rdkit/rdkit#9688), so its charges for charged molecules
# sum to the negated formal charge. nvMolKit gives the correct values; only neutral molecules are compared against
# older RDKit, and the golden tests below cover the charged ones.
RDKIT_EEM_CHARGES_CORRECT = tuple(int(part) for part in rdkit.__version__.split(".")[:3]) >= (2026, 9, 2)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RDKit built from its development branch may report a version like 2026.09.1pre, where int("1pre") raises a ValueError. The benchmark script has the same issue.
Could we use re.findall(r"\d+", rdkit.__version__)[:3] to extract the version numbers instead?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

Comment thread src/descriptors3d_eem.cuh Outdated
return; // Every row stays NaN.
}

const int numBlocks = std::min(coordinates.numConformers, numSms * 16);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scratch memory allocation scales with both the largest conformer in the batch and the number of GPU blocks. This could lead to excessive memory usage or even OOM when a batch contains many conformers but only a few large ones. Could we consider limiting the scratch allocation based on a memory budget maybe?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

@evasnow1992 evasnow1992 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two comments, non-blocker. Changes look good to me.

@scal444
scal444 merged commit efdde20 into NVIDIA-BioNeMo:main Oct 23, 2026
15 checks passed
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.

2 participants