Vectorize per-element Python loops in hbond(), sasa() and set_structure() - #936
Vectorize per-element Python loops in hbond(), sasa() and set_structure()#936steps-re wants to merge 6 commits into
Conversation
`_get_bonded_h_via_distance()` looped over every donor and built a mask over the entire atom array on each iteration, making the detection of bonded hydrogen atoms scale with the product of donor and atom count. Now the hydrogen atoms are sorted by residue ID once, the candidates for each donor are located via binary search and all distances are computed in a single vectorized call. The result is unchanged: donor-hydrogen assignments are bit-identical across all structures in the test suite, with and without a periodic box. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both radii sets were resolved with a Python level loop over every atom, although a structure contains far fewer distinct residue/atom name combinations than atoms. The lookup is now performed once per distinct combination and mapped back onto the atoms, which also removes the duplication between the 'ProtOr' and 'Single' branches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both code paths are covered, as identifying bonded hydrogen atoms via distance is substantially more expensive than reading them from a `BondList`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_subarray()` created an `AtomArray`/`AtomArrayStack` via its constructor, which allocates the seven standard annotation arrays, and then immediately replaced all of them with the indexed ones. Bypassing the constructor removes those redundant allocations, which speeds up code that slices repeatedly, e.g. iterating over residues. It also fixes an inconsistency: if an annotation category was removed via `del_annotation()`, indexing resurrected it as a zero-length array while `array_length()` reported the indexed length. The resulting object could not even be represented as a string. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_set_intra_residue_bonds()` looped over every bond to map its `BondType` to the `chem_comp_bond` representation, although a structure contains only a handful of distinct bond types. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| def test_indexing_after_annotation_deletion(array, stack): | ||
| """ | ||
| Indexing must not resurrect a deleted annotation category, as the | ||
| resurrected array would not match the length of the indexed object. |
There was a problem hiding this comment.
Could you add the motivation here, i.e. why there indexing could resurrect a deleted annotation array in the first place?
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merging this PR will improve performance by 28.77%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ⚡ | benchmark_set_structure[bcif-True] |
28.5 ms | 16.8 ms | +69.57% |
| ⚡ | benchmark_match_kmer_selection[KmerTable-11*11*1*1***111] |
276.4 µs | 244.6 µs | +13.03% |
| ⚡ | benchmark_set_structure[cif-True] |
115.7 ms | 103.9 ms | +11.4% |
| 🆕 | benchmark_hbond |
N/A | 12.8 ms | N/A |
| 🆕 | benchmark_hbond_without_bonds |
N/A | 5.1 ms | N/A |
Tip
Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.
Comparing steps-re:perf/hbond-sasa-vectorization (11921a9) with main (d9e7f49)
Footnotes
-
14 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩
|
Good idea to only run a Python loop over the unique elements of an array instead of every element 👍. I wonder if we can cast this general pattern into some function to avoid the addition of complexity in each of the improved functions. If you allow, I'd like to play around with this idea on top of your PR in the next few days 🙂 . |
|
yeah go for it, i would be curious to see what the general version looks like. the per-function repetition bugged me too, i just did not want to scope-creep the PR into an api change. |
Four hot paths in
biotite.structureloop in Python over every atom or bond, where the number of distinct values involved is only a handful. This replaces those loops with vectorized equivalents.Measured on an antibody structure (1igy, 12750 atoms) unless noted, against
main:hbond()(1gya, noBondList)hbond()(noBondList)set_structure()with bondsresidue_iter()base_pairs()(4p5j)sasa()(ProtOr)What changed
hbond()-_get_bonded_h_via_distance()iterated over every donor and built a mask over the whole atom array each time, so the cost scaled with donor count times atom count. The hydrogen atoms are now sorted by residue ID once, each donor's candidates are located by binary search, and all distances are computed in one call. Structures without hydrogen atoms previously paid the full cost for a guaranteed empty result.set_structure()-_set_intra_residue_bonds()translated theBondTypeof every bond individually. Writing 1igy meant 11710 calls for about five distinct types.sasa()- both radii sets were resolved with a per-atom lookup, although a structure has far fewer distinct residue/atom name combinations than atoms. This also removes the duplication between theProtOrandSinglebranches._subarray()- indexing an atom array constructed the new object through its constructor, which allocates the seven standard annotation arrays, then immediately replaced all of them. Bypassing the constructor helps code that slices repeatedly.hbond()is added, since neither code path was covered.One behaviour change
The
_subarray()commit changes one thing beyond performance. If an annotation category had been removed viadel_annotation(), indexing used to bring it back as a zero-length array whilearray_length()reported the indexed length:The category now simply stays absent.
test_indexing_after_annotation_deletioncovers this and fails without the fix.Verification
Full test suite passes. Output was also compared directly against
mainover all 23 structures in the test data: donor/hydrogen assignments, SASA for both radii sets, sliced arrays with their annotations and bonds, residue iteration, and the bytes ofset_structure()round trips through both CIF and BinaryCIF. All 759 compared arrays are identical, including 16.2 MB of written file content, with and without a periodic box.Happy to split this up if you would rather review the
atoms.pychange separately.Disclosure: I use Claude Code to help with these contributions. Every change here was reproduced, benchmarked and tested by me before opening, and this description is my own.