For a small library dealing with molecules, I have to calculate a so called connectivity table for the chemical bonds.
I assume that there is a bond between atom i and atom j, if the distance between them is smaller than their summed radius.
This function has to be fast (So it's not premature optimisation ;) ).
At the moment I have two possible solutions, where give_bond_array1
is the faster one. I have two questions:
- Why is
give_bond_array1
faster? Because from the code I would expectgive_bond_array2
to be faster. The whole calculation is boiled down to vectorised calls to numpy. - Do you have an idea for a faster implementation, or is the next step Fortran, C...?
- Why is the subtraction step in
give_bond_array2
the bottleneck? It is onlyn**2
number of operations compared to3 / 2 * n * (n-1)
in other lines.
I have created a file minimal_example.py
with the following content:
import numpy as np
def give_bond_array1(positions, bond_radii, self_bonding_allowed=False):
"""Calculate a boolean array where ``A[i,j] is True`` indicates a
bond between the i-th and j-th atom.
"""
coords = ['x', 'y', 'z']
radii = np.add.outer(bond_radii, bond_radii)
squared_radii = radii ** 2
delta = {axis: None for axis in coords}
for i, axis in enumerate(coords):
coord = positions[:, i]
delta[axis] = coord - coord.reshape((len(coord), 1))
squared_distances = delta['x']**2 + delta['y']**2 + delta['z']**2
overlap = squared_radii - squared_distances
bond_array = overlap >= 0
if not self_bonding_allowed:
np.fill_diagonal(bond_array, False)
return bond_array
def give_bond_array2(positions, bond_radii, self_bonding_allowed=False):
"""Calculate a boolean array where ``A[i,j] is True`` indicates a
bond between the i-th and j-th atom.
"""
radii = np.add.outer(bond_radii, bond_radii)
squared_radii = radii ** 2
delta = (positions[None, :, :] - positions[:, None, :])
squared_delta = delta ** 2
squared_distances = np.sum(squared_delta, axis=2)
overlap = squared_radii - squared_distances
bond_array = overlap >= 0
if not self_bonding_allowed:
np.fill_diagonal(bond_array, False)
return bond_array
n_atoms = 15000
positions, bond_radii = np.random.rand(n_atoms, 3), np.random.rand(n_atoms)
positions, bond_radii = [np.array(x, dtype='float32', order='F') for x in [positions, bond_radii]]
For the testing I got the following output:
In [1]: from minimal_example import *
...: %load_ext line_profiler
...:
In [2]: %lprun -f give_bond_array1 give_bond_array1(positions, bond_radii)
Timer unit: 1e-06 s
Total time: 5.72649 s
File: /home/oweser/Dropbox/Tests_and_debugging/chemcoord_test/minimal_example.py
Function: give_bond_array1 at line 3
Line # Hits Time Per Hit % Time Line Contents
==============================================================
3 def give_bond_array1(positions, bond_radii, self_bonding_allowed=False):
4 """Calculate a boolean array where ``A[i,j] is True`` indicates a
5 bond between the i-th and j-th atom.
6 """
7 1 3 3.0 0.0 coords = ['x', 'y', 'z']
8 1 332504 332504.0 5.8 radii = np.add.outer(bond_radii, bond_radii)
9 1 383706 383706.0 6.7 squared_radii = radii ** 2
10 1 18 18.0 0.0 delta = {axis: None for axis in coords}
11 4 32 8.0 0.0 for i, axis in enumerate(coords):
12 3 35 11.7 0.0 coord = positions[:, i]
13 3 1114585 371528.3 19.5 delta[axis] = coord - coord.reshape((len(coord), 1))
14 1 3030061 3030061.0 52.9 squared_distances = delta['x']**2 + delta['y']**2 + delta['z']**2
15 1 640631 640631.0 11.2 overlap = squared_radii - squared_distances
16 1 224410 224410.0 3.9 bond_array = overlap >= 0
17 1 4 4.0 0.0 if not self_bonding_allowed:
18 1 504 504.0 0.0 np.fill_diagonal(bond_array, False)
19 1 2 2.0 0.0 return bond_array
In [1]: from minimal_example import *
...: %load_ext line_profiler
...:
In [2]: %lprun -f give_bond_array2 give_bond_array2(positions, bond_radii)
Timer unit: 1e-06 s
Total time: 75.13 s
File: /home/oweser/Dropbox/Tests_and_debugging/chemcoord_test/minimal_example.py
Function: give_bond_array2 at line 21
Line # Hits Time Per Hit % Time Line Contents
==============================================================
21 def give_bond_array2(positions, bond_radii, self_bonding_allowed=False):
22 """Calculate a boolean array where ``A[i,j] is True`` indicates a
23 bond between the i-th and j-th atom.
24 """
25 1 335759 335759.0 0.4 radii = np.add.outer(bond_radii, bond_radii)
26 1 383206 383206.0 0.5 squared_radii = radii ** 2
27 1 1106056 1106056.0 1.5 delta = (positions[None, :, :] - positions[:, None, :])
28 1 1578934 1578934.0 2.1 squared_delta = delta ** 2
29 1 29164470 29164470.0 38.8 squared_distances = np.sum(squared_delta, axis=2)
30 1 39366942 39366942.0 52.4 overlap = squared_radii - squared_distances
31 1 1611265 1611265.0 2.1 bond_array = overlap >= 0
32 1 3 3.0 0.0 if not self_bonding_allowed:
33 1 1583333 1583333.0 2.1 np.fill_diagonal(bond_array, False)
34 1 2 2.0 0.0 return bond_array
PS: I know that I should also cut my molecule into smaller pieces in order to make "divide and conquer". This is already implemented and greatly speeds up calculation. Nevertheless I would like to understand why the above functions differ so much in performance.
PPS: The number of atoms (n_atoms
in the example) may safely be assumed to be less than 10^5.