Sharing neighbor lists¶
Every analysis finds the neighbors it needs by itself. When several
analyses of one system need the same neighbors, a neighbor
list built once and given to each with neighbors= saves
the searches. This page shows when that pays, and when it does not.
The system is a crystal of 108 000 copper atoms, displaced at random as at room temperature:
import time
import numpy as np
from mdapy.build import build_crystal
from mdapy.neighbor import NeighborList
crystal = build_crystal("fcc", 3.615, elements="Cu", repeat=(30, 30, 30))
rng = np.random.default_rng(0)
system = crystal.with_columns(
positions=crystal["positions"] + rng.normal(0.0, 0.1, (len(crystal), 3))
)
def timed(work):
"""The result of work() and how long it took, in ms."""
start = time.perf_counter()
result = work()
return result, 1000 * (time.perf_counter() - start)
One list for a metal¶
The analyses of the structure of a metal look at its neighbors within some 5 Å: the nearest 12 to 18 of each atom, or those within a cutoff. One list within 5 Å serves them all. An analysis of the nearest neighbors puts the nearest pairs of each atom first in the list, once, and reads them there; one within a cutoff reads the pairs closer than its own. Neither copies the list, and every result is the one the analysis gives alone.
from mdapy.analysis import (
BondAngleDistribution,
CoordinationNumber,
RadialDistributionFunction,
SteinhardtParameters,
)
from mdapy.identify import (
AcklandJonesAnalysis,
CentroSymmetryParameter,
CommonNeighborAnalysis,
PolyhedralTemplateMatching,
)
metal = [
CommonNeighborAnalysis(), # the 14 nearest
AcklandJonesAnalysis(), # the 14 nearest
PolyhedralTemplateMatching(), # the 18 nearest
CentroSymmetryParameter(12),
SteinhardtParameters((4, 6), num_neighbors=12),
CommonNeighborAnalysis(3.086), # within cutoffs
CoordinationNumber(3.086),
BondAngleDistribution(cutoff=3.086, bins=180),
RadialDistributionFunction(5.0, bins=200),
]
alone, before = timed(lambda: [a.compute(system) for a in metal])
def shared_metal():
nl = NeighborList.build(system, 5.0)
return [a.compute(system, neighbors=nl) for a in metal]
shared, after = timed(shared_metal)
for one, other in zip(alone, shared):
for name, value in vars(one).items():
if isinstance(value, np.ndarray):
assert np.array_equal(value, vars(other)[name]) # the same results
print(f"{before:.0f} ms alone, {after:.0f} ms with one list")
Without a list, each analysis finds the neighbors it needs as it goes, without storing the pairs; with one, the search is done once and the analyses only read it. The list must hold the neighbors each analysis needs: an analysis of the 18 nearest refuses a list in which an atom has fewer pairs.
Building the list costs about as much as the search of one analysis, as it stores every pair, and putting the nearest first a third of that. One list pays for many analyses, as here; for two or three, finding the neighbors as they go is about as fast.
Voronoi neighbors¶
A Voronoi tessellation costs far more than a search within a cutoff, so analyses of Voronoi neighbors gain most from sharing one list. Here three kinds of Steinhardt parameters over the faces of the cells:
voronoi = [
SteinhardtParameters((4, 6), voronoi=True),
SteinhardtParameters((4, 6), voronoi=True, weighted=True),
SteinhardtParameters((4, 6), voronoi=True, weighted=True, average=True),
]
_, before = timed(lambda: [a.compute(system) for a in voronoi])
def shared_voronoi():
nl = NeighborList.build(system, voronoi=True)
return [a.compute(system, neighbors=nl) for a in voronoi]
_, after = timed(shared_voronoi)
print(f"{before:.0f} ms alone, {after:.0f} ms with one list")
On a node of two Intel Xeon Gold 6248 (40 cores) with 40 threads, each list built and sorted inside the timing, the best of three:
| Analyses | Atoms | Each alone | One list for all |
|---|---|---|---|
| nine of a metal, one list within 5 Å | 108 000 | 231 ms | 142 ms |
| the same | 2 048 000 | 3.67 s | 2.31 s |
| the same without polyhedral template matching | 4 000 000 | 4.16 s | 2.16 s |
| three on Voronoi neighbors | 108 000 | 501 ms | 182 ms |
At 4 000 000 atoms, building the list takes 0.47 s and putting the 14 nearest of each atom first 0.15 s; then the centro-symmetry parameter takes 0.08 s instead of 0.51 s alone, and the radial distribution function 0.06 s instead of 0.34 s. Polyhedral template matching spends most of its time matching templates, which a list does not shorten.
A list within a cutoff is also the way to your own sums over pairs, with NumPy, as in Neighbor lists.
One list, the same atoms¶
A list serves every system with the positions and the box it was built for:
the system itself, one with more columns, or one read again from the same
file. Moved atoms or another box need a new list; analyses refuse the old
one. nl.fits(system) tells which.
from mdapy.errors import InvalidParameter
nl = NeighborList.build(system, 5.0)
cna = CommonNeighborAnalysis().compute(system, neighbors=nl)
labelled = system.with_columns(cna) # the same atoms, one more column
csp = CentroSymmetryParameter(12).compute(labelled, neighbors=nl)
nl.fits(labelled) # True
shifted = system.with_columns(positions=system["positions"] + [0.1, 0.0, 0.0])
nl.fits(shifted) # False
try:
CommonNeighborAnalysis().compute(shifted, neighbors=nl)
except InvalidParameter as error:
print(error) # the neighbor list was built for other positions or another box
Energy minimization keeps its own list between steps, with a margin beyond the cutoff of the calculator, and searches again only when an atom has moved far enough to need it.