Package: pyfnntw
FNNTW: Fastest Nearest Neighbor (in the) WestFastest Neighbor Nearest Neighbor (in the) West is an in-development kD-tree library that aims to be one of the most, if not the most, performant parallel kNN libraries that exist.Basic Usageimportpyfnntw;importnumpyasnp;fromscipy.spatialimportcKDTreeasTreefromtimeimporttimeND=10**5NQ=10**6RUNS=10TRIALS=10WARMUP=5# Get some data and queriesdata=np.random.uniform(size=(ND,3))query=np.random.uniform(size=(NQ,3))# Build and query the pynntw treetree1=pyfnntw.Tree(data,32,1)(_,ids1)=tree1.query(query)# Build and query the scipy treetree2=Tree(data,32)(_,ids2)=tree2.query(query)ifnp.all(ids1==ids2):print("Success")else:print("Failure")There are several key components of the building and querying process of the kD-trees that allows for its performance.1. Building The TreeMany kD-Tree build implementations are not parallel despite being very easy to parallelize. Here we discuss parallelization strategies for individual subtrees and across subtrees.a. Using QuickselectBy using the quickselect in the form ofselect_nth_unstable_byin Rust'score::sliceinstead of an average or even some other median algorithm, we get theleftandrightsubsets for free in the same O(N) algorithm instead of having to do yet another O(N) comparisons to obtain theleftandrightbins. As such, the build is still O(N log(N)), but removes a whole O(N) operation that is found in many other libraries during each splitting. For trees with >=100,000 nodes, a homemade parallel approximate median finder is used to find the split point for the subtrees. This speeds up the building of large trees tremendously, as ≈95% of a sequential tree build is spent finding medians.b. Parallel buildEvery subtree of a kD-tree is an independent kD-tree, so at each splitting one could build each subtree in parallel up to some minimum subtree size. This is done here with the user-specifiedpar_split_level, which is the tree depth at which the parallelism begins. See note inBenchmark Against Other Codesabout recommended values.2. Unsafe AccessesBecause we know the shape of all arrays (i.e. the dimension of the tree) at compile time, and we know the tree size and topology post-build at run time, theunsafemethodsget_uncheckedandget_unchecked_mutare used liberally throughout the code. This means virtually no bounds checks are done.3. AllocatorsWe tested many allocators, including the default allocated andjemalloc,tcmalloc,snmalloc,mimalloc,rpmalloc, and found the best performance withtcmalloc.Benchmark Against Other CodesThis library is intended to be used by the author to calculate summary statistics in cosmology. As such, the parameters of the benchmark chosen are close to those that would be used in analyzing the output of a cosmological simulation. In such an application, often many subsamples or simulation boxes are used. So, the combined build + query time is important since many different trees may be constructed in an analysis. We useA mock dataset of 100,000 uniform random points in the unit cube.A query set of 1,000,000 uniform random points in the unit cube.Over 100 realizations of the datasets and query points, the following results are obtained for the average build (serial) and 1NN query times on an AMD EPYC 7502P using 48 threads. The results are sorted by the combined build and query time.CodeBuild (ms)Query (ms)Total (ms)FNNTW122839pykdtree (python)123648nabo-rs (rust)253055Scipy's cKDTree (python)314778kiddo (rust)2684110With FNNTW's parallel build capability, the build time can go as low as 5 ms on the AMD EPYC 7502P (atsplit_level = 2) (and under 5 ms at single precision). Since the overhead of the parallelism and atomic operations slows down the build when the number of datapoints is small, both a parallel build and non_parallel build are available viaTree:new(..)andTree::new_parallel(..). The latter takes the aforementioned parameterpar_split_level, which is the split depth after which the parallelism stops. Although for our applications of O(1e5) points we see the biggest improvement forpar_split_level = 2, we expect that the optimalpar_split_levelwill increase with the size of the dataset. For tree sizes of O(1e8) points, for example, we see peak performance atpar_split_level = 4(16 threads).