diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/asa/AsaCalculator.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/asa/AsaCalculator.java index 0f46b7f416..ed576b9abe 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/asa/AsaCalculator.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/asa/AsaCalculator.java @@ -27,7 +27,6 @@ import org.slf4j.LoggerFactory; import javax.vecmath.Point3d; -import javax.vecmath.Vector3d; import java.util.*; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -101,12 +100,56 @@ public void run() { } } - static class IndexAndDistance { - final int index; - final double dist; - IndexAndDistance(int index, double dist) { - this.index = index; - this.dist = dist; + /** + * The neighbors of a single atom, as parallel primitive arrays of neighbor atom indices and their distances to + * the central atom, sorted by increasing distance. + *

+ * Parallel primitive arrays are used rather than an array of index-distance objects because there are ~30 + * neighbors per atom: for a large structure that would mean millions of small short-lived objects. + */ + static class Neighbors { + + /** The neighbor atom indices, ordered by increasing distance to the central atom */ + final int[] indices; + /** The distances to the central atom, in increasing order and parallel to {@link #indices} */ + final double[] dists; + + private Neighbors(int[] indices, double[] dists) { + this.indices = indices; + this.dists = dists; + } + + /** + * Creates a Neighbors from the first count elements of the given buffers, copying them to exact-size arrays + * and sorting them by increasing distance. + * @param indicesBuffer the neighbor indices, only the first count elements are used + * @param distsBuffer the neighbor distances, only the first count elements are used + * @param count the number of neighbors + * @return the sorted neighbors + */ + static Neighbors createSorted(int[] indicesBuffer, double[] distsBuffer, int count) { + int[] indices = Arrays.copyOf(indicesBuffer, count); + double[] dists = Arrays.copyOf(distsBuffer, count); + // Sorting by closest to farthest away neighbors achieves faster runtimes when checking for occluded + // sphere sample points in calcSingleAsa. This follows the ideas exposed in + // Eisenhaber et al, J Comp Chemistry 1994 (https://onlinelibrary.wiley.com/doi/epdf/10.1002/jcc.540160303) + // This is essential for performance: it brings down the number of occlusion checks to + // an average of n_sphere_points/10 per atom, producing ~ x4 performance gain overall. + // An insertion sort is used because the arrays are small (~30 elements on average) and because it avoids + // both the boxing of a comparator-based sort and the object allocation an index-distance array would need. + for (int i = 1; i < count; i++) { + double dist = dists[i]; + int index = indices[i]; + int j = i - 1; + while (j >= 0 && dists[j] > dist) { + dists[j + 1] = dists[j]; + indices[j + 1] = indices[j]; + j--; + } + dists[j + 1] = dist; + indices[j + 1] = index; + } + return new Neighbors(indices, dists); } } @@ -116,9 +159,16 @@ static class IndexAndDistance { private final double[] radii; private final double probe; private final int nThreads; - private Vector3d[] spherePoints; + /** + * The sphere points to sample, as a flat array of interleaved x,y,z coordinates (thus of size 3 x nSpherePoints). + * A flat array of primitives (rather than an array of Vector3d objects) is used for performance: it keeps the + * points contiguous in memory and avoids a pointer dereference per point in the innermost loop of + * {@link #calcSingleAsa(int)}. + */ + private double[] spherePoints; + private int nSpherePoints; private double cons; - private IndexAndDistance[][] neighborIndices; + private Neighbors[] neighbors; private boolean useSpatialHashingForNeighbors; @@ -239,7 +289,8 @@ private void initSpherePoints(int nSpherePoints) { logger.debug("Will use {} sphere points", nSpherePoints); // initialising the sphere points to sample - spherePoints = generateSpherePoints(nSpherePoints); + this.nSpherePoints = nSpherePoints; + this.spherePoints = generateSpherePoints(nSpherePoints); cons = 4.0 * Math.PI / nSpherePoints; } @@ -285,10 +336,10 @@ public double[] calculateAsas() { long start = System.currentTimeMillis(); if (useSpatialHashingForNeighbors) { logger.debug("Will use spatial hashing to find neighbors"); - neighborIndices = findNeighborIndicesSpatialHashing(); + neighbors = findNeighborIndicesSpatialHashing(); } else { logger.debug("Will not use spatial hashing to find neighbors"); - neighborIndices = findNeighborIndices(); + neighbors = findNeighborIndices(); } long end = System.currentTimeMillis(); logger.debug("Took {} s to find neighbors", (end-start)/1000.0); @@ -334,109 +385,126 @@ void setUseSpatialHashingForNeighbors(boolean useSpatialHashingForNeighbors) { * Returns list of 3d coordinates of points on a unit sphere using the * Golden Section Spiral algorithm. * @param nSpherePoints the number of points to be used in generating the spherical dot-density - * @return the array of points as Vector3d objects + * @return a flat array of interleaved x,y,z coordinates, of size 3 x nSpherePoints */ - private Vector3d[] generateSpherePoints(int nSpherePoints) { - Vector3d[] points = new Vector3d[nSpherePoints]; + private double[] generateSpherePoints(int nSpherePoints) { + double[] points = new double[3 * nSpherePoints]; double inc = Math.PI * (3.0 - Math.sqrt(5.0)); double offset = 2.0 / nSpherePoints; for (int k=0;k thisNbIndices = new ArrayList<>(initialCapacity); + int count = 0; for (int i = 0; i < atomCoords.length; i++) { if (i == k) continue; double dist = atomCoords[i].distance(atomCoords[k]); - if (dist < radius + radii[i]) { - thisNbIndices.add(new IndexAndDistance(i, dist)); + if (areNeighbors(k, i, dist)) { + if (count == indicesBuffer.length) { + indicesBuffer = Arrays.copyOf(indicesBuffer, count * 2); + distsBuffer = Arrays.copyOf(distsBuffer, count * 2); + } + indicesBuffer[count] = i; + distsBuffer[count] = dist; + count++; } } - IndexAndDistance[] indicesArray = thisNbIndices.toArray(new IndexAndDistance[0]); - nbsIndices[k] = indicesArray; + nbs[k] = Neighbors.createSorted(indicesBuffer, distsBuffer, count); } - return nbsIndices; + return nbs; } /** - * Returns the 2-dimensional array with neighbor indices for every atom, + * Returns the neighbors of every atom, sorted by increasing distance, * using spatial hashing to avoid all to all distance calculation. - * @return 2-dimensional array of size: n_atoms x n_neighbors_per_atom + * @return array of size n_atoms */ - IndexAndDistance[][] findNeighborIndicesSpatialHashing() { - - // looking at a typical protein case, number of neighbours are from ~10 to ~50, with an average of ~30 - int initialCapacity = 60; + Neighbors[] findNeighborIndicesSpatialHashing() { List contactList = calcContacts(); - Map> indices = new HashMap<>(atomCoords.length); + + // A first pass to count the neighbors per atom, so that exact-size arrays can be allocated in the second + // pass. Atom indices are dense, so plain arrays are used rather than a map: that avoids boxing the indices + // and the repeated hashing, which are significant given that there are ~30 contacts per atom. + int[] counts = new int[atomCoords.length]; for (Contact contact : contactList) { // note contacts are stored 1-way only, with j>i int i = contact.getI(); int j = contact.getJ(); - - List iIndices; - List jIndices; - if (!indices.containsKey(i)) { - iIndices = new ArrayList<>(initialCapacity); - indices.put(i, iIndices); - } else { - iIndices = indices.get(i); - } - if (!indices.containsKey(j)) { - jIndices = new ArrayList<>(initialCapacity); - indices.put(j, jIndices); - } else { - jIndices = indices.get(j); - } - - double radius = radii[i] + probe + probe; - double dist = contact.getDistance(); - if (dist < radius + radii[j]) { - iIndices.add(new IndexAndDistance(j, dist)); - jIndices.add(new IndexAndDistance(i, dist)); + if (areNeighbors(i, j, contact.getDistance())) { + counts[i]++; + counts[j]++; } } - // convert map to array for fast access - IndexAndDistance[][] nbsIndices = new IndexAndDistance[atomCoords.length][]; - for (Map.Entry> entry : indices.entrySet()) { - List list = entry.getValue(); - IndexAndDistance[] indexAndDistances = list.toArray(new IndexAndDistance[0]); - nbsIndices[entry.getKey()] = indexAndDistances; + int[][] indices = new int[atomCoords.length][]; + double[][] dists = new double[atomCoords.length][]; + for (int i = 0; i < atomCoords.length; i++) { + // note that some atoms might have no neighbors at all, in which case these are empty arrays + indices[i] = new int[counts[i]]; + dists[i] = new double[counts[i]]; } - // important: some atoms might have no neighbors at all: we need to initialise to empty arrays - for (int i=0; i calcContacts() { private double calcSingleAsa(int i) { Point3d atom_i = atomCoords[i]; - int n_neighbor = neighborIndices[i].length; - IndexAndDistance[] neighbor_indices = neighborIndices[i]; - // Sorting by closest to farthest away neighbors achieves faster runtimes when checking for occluded - // sphere sample points below. This follows the ideas exposed in - // Eisenhaber et al, J Comp Chemistry 1994 (https://onlinelibrary.wiley.com/doi/epdf/10.1002/jcc.540160303) - // This is essential for performance. In my tests this brings down the number of occlusion checks in loop below to - // an average of n_sphere_points/10 per atom i, producing ~ x4 performance gain overall - Arrays.sort(neighbor_indices, Comparator.comparingDouble(o -> o.dist)); + // note the neighbors are already sorted by increasing distance (see Neighbors#createSorted), which is + // essential for the performance of the occlusion checks in the loop below + Neighbors nbs = neighbors[i]; + int[] neighbor_indices = nbs.indices; + double[] neighbor_dists = nbs.dists; + int n_neighbor = neighbor_indices.length; double radius_i = probe + radii[i]; @@ -476,36 +542,47 @@ private double calcSingleAsa(int i) { int[] numDistsCalced = null; if (logger.isDebugEnabled()) numDistsCalced = new int[n_neighbor]; - // now we precalculate anything depending only on i,j in equation 3 in Eisenhaber 1994 - double[] sqRadii = new double[n_neighbor]; - Vector3d[] aj_minus_ais = new Vector3d[n_neighbor]; + // Now we precalculate anything depending only on i,j in equation 3 in Eisenhaber 1994. + // The per-neighbor data is laid out in a single flat array as quadruplets + // [aj_minus_ai.x, aj_minus_ai.y, aj_minus_ai.z, cutoff], so that the innermost loop below is a purely + // sequential scan over contiguous primitives, with no object dereferencing. That matches the access + // pattern of the early break and is significantly faster than an array of Vector3d objects. + double[] nbData = new double[4 * n_neighbor]; for (int nbArrayInd =0; nbArrayInd> 2]++; - if (dotProd > sqRadii[nbArrayInd]) { + if (dotProd > nbData[off + 3]) { is_accessible = false; break; } diff --git a/biojava-structure/src/test/java/org/biojava/nbio/structure/asa/TestAsaCalc.java b/biojava-structure/src/test/java/org/biojava/nbio/structure/asa/TestAsaCalc.java index 7be56be156..c6a268f1f5 100644 --- a/biojava-structure/src/test/java/org/biojava/nbio/structure/asa/TestAsaCalc.java +++ b/biojava-structure/src/test/java/org/biojava/nbio/structure/asa/TestAsaCalc.java @@ -126,19 +126,19 @@ public void testNeighborIndicesFinding() throws StructureException, IOException AsaCalculator.DEFAULT_PROBE_SIZE, 1000, 1, false); - AsaCalculator.IndexAndDistance[][] allNbsSh = asaCalc.findNeighborIndicesSpatialHashing(); + AsaCalculator.Neighbors[] allNbsSh = asaCalc.findNeighborIndicesSpatialHashing(); - AsaCalculator.IndexAndDistance[][] allNbs = asaCalc.findNeighborIndices(); + AsaCalculator.Neighbors[] allNbs = asaCalc.findNeighborIndices(); for (int indexToTest =0; indexToTest < asaCalc.getAtomCoords().length; indexToTest++) { //int indexToTest = 198; - AsaCalculator.IndexAndDistance[] nbsSh = allNbsSh[indexToTest]; - AsaCalculator.IndexAndDistance[] nbs = allNbs[indexToTest]; + int[] nbsSh = allNbsSh[indexToTest].indices; + int[] nbs = allNbs[indexToTest].indices; List listOfMatchingIndices = new ArrayList<>(); for (int i = 0; i < nbsSh.length; i++) { for (int j = 0; j < nbs.length; j++) { - if (nbs[j].index == nbsSh[i].index) { + if (nbs[j] == nbsSh[i]) { listOfMatchingIndices.add(j); break; } @@ -229,21 +229,21 @@ public void testNoNeighborsIssue() { AsaCalculator.DEFAULT_PROBE_SIZE, 1000, 1); - AsaCalculator.IndexAndDistance[][] allNbsSh = asaCalc.findNeighborIndicesSpatialHashing(); + AsaCalculator.Neighbors[] allNbsSh = asaCalc.findNeighborIndicesSpatialHashing(); - AsaCalculator.IndexAndDistance[][] allNbs = asaCalc.findNeighborIndices(); + AsaCalculator.Neighbors[] allNbs = asaCalc.findNeighborIndices(); assertEquals(3, allNbs.length); assertEquals(3, allNbsSh.length); for (int indexToTest =0; indexToTest < asaCalc.getAtomCoords().length; indexToTest++) { - AsaCalculator.IndexAndDistance[] nbsSh = allNbsSh[indexToTest]; - AsaCalculator.IndexAndDistance[] nbs = allNbs[indexToTest]; + int[] nbsSh = allNbsSh[indexToTest].indices; + int[] nbs = allNbs[indexToTest].indices; List listOfMatchingIndices = new ArrayList<>(); for (int i = 0; i < nbsSh.length; i++) { for (int j = 0; j < nbs.length; j++) { - if (nbs[j].index == nbsSh[i].index) { + if (nbs[j] == nbsSh[i]) { listOfMatchingIndices.add(j); break; } @@ -256,7 +256,7 @@ public void testNoNeighborsIssue() { } // first atom should have no neighbors - assertEquals(0, allNbsSh[0].length); + assertEquals(0, allNbsSh[0].indices.length); } private Atom getAtom(double x, double y, double z) {