Skip to content

Vectorize per-element Python loops in hbond(), sasa() and set_structure() - #936

Open
steps-re wants to merge 6 commits into
biotite-dev:mainfrom
steps-re:perf/hbond-sasa-vectorization
Open

Vectorize per-element Python loops in hbond(), sasa() and set_structure()#936
steps-re wants to merge 6 commits into
biotite-dev:mainfrom
steps-re:perf/hbond-sasa-vectorization

Conversation

@steps-re

@steps-re steps-re commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Four hot paths in biotite.structure loop 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:

before after
hbond() (1gya, no BondList) 2.47 ms 0.44 ms 5.7x
hbond() (no BondList) 68.7 ms 18.8 ms 3.7x
set_structure() with bonds 18.3 ms 6.3 ms 2.9x
residue_iter() 16.1 ms 12.2 ms 1.32x
base_pairs() (4p5j) 37.8 ms 32.9 ms 1.15x
sasa() (ProtOr) 135 ms 129 ms 1.05x

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 the BondType of 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 the ProtOr and Single branches.
  • _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.
  • A benchmark for 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 via del_annotation(), indexing used to bring it back as a zero-length array while array_length() reported the indexed length:

array.del_annotation("chain_id")
sub = array[1:4]
sub.array_length()   # 3
len(sub.chain_id)    # 0
repr(sub)            # IndexError

The category now simply stays absent. test_indexing_after_annotation_deletion covers this and fails without the fix.

Verification

Full test suite passes. Output was also compared directly against main over 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 of set_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.py change 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.

steps-re and others added 5 commits August 7, 2026 08:55
`_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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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>
@codspeed-hq

codspeed-hq Bot commented Aug 10, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 28.77%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 3 improved benchmarks
✅ 103 untouched benchmarks
🆕 2 new benchmarks
⏩ 14 skipped benchmarks1

Performance Changes

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)

Open in CodSpeed

Footnotes

  1. 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.

@padix-key

Copy link
Copy Markdown
Member

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 🙂 .

@steps-re

Copy link
Copy Markdown
Contributor Author

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.

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