Skip to content

Conversation

@danrosher
Copy link
Contributor

@danrosher danrosher commented Jul 13, 2022

https://issues.apache.org/jira/browse/SOLR-16292

Description

N-Vector is a three-parameter representation that can be used to calculate the great-circle distance (assuming a spherical Earth).

It uses FastInvTrig, which for small distances it compares well with Math.acos. NVector is a faster way of calculating the great-circle distance than Haversine.

Solution

Store N-Vectors in solr index via CoordinateFieldType with 3 values for the nvector into single value double subfields, use java Math class for indexing these
Use an maclaurin approximation for acos for calculating great-circle distance at query-time via a function query

Tests

Tests for the FastInvTrigTest impl to compare it's acos with Math.acos.
Tests for NVectorUtil, NVectorDist , latLongToNVector and NVectorToLatLong
Tests for indexing N-Vectors and calculating the great-circle distance via the function query.

Checklist

Please review the following and check all that apply:

  • I have reviewed the guidelines for How to Contribute and my code conforms to the standards described there to the best of my ability.
  • I have created a Jira issue and added the issue ID to my pull request title.
  • I have given Solr maintainers access to contribute to my PR branch. (optional but recommended)
  • I have developed this patch against the main branch.
  • I have run ./gradlew check.
  • I have added tests for my changes.
  • I have added documentation for the Reference Guide

Copy link
Contributor

@madrob madrob left a comment

Choose a reason for hiding this comment

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

This looks interesting, and I don't quite understand the math behind all of it, but I left some comments about the code itself.

}
out[i] = externalVal.substring(start, end);
start = idx + 1;
end = externalVal.indexOf(',', start);
Copy link
Contributor

Choose a reason for hiding this comment

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

Numbers will always be stored as decimals with . and separator as ,? Need to make sure this doesn't mess up in other locales that use comma as the decimal separator.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've added a separator init param and parse doubles using NumberFormat with default locale


package org.apache.solr.util;

public class FastInvTrig {
Copy link
Contributor

Choose a reason for hiding this comment

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

Did you write this code? Is it borrowed from a library? Is it an implementation of something found in a text book?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes I wrote this, original in my github repo. It's a Maclaurin series expansion of acos. TABLE stores the coefficients that can be re-used for subsequent calculations, it also reuses x^2 for x^3,x^5 etc. Also initially my implementation required a lot of terms for convergence, until I found this https://stackoverflow.com/questions/20196000/own-asin-function-with-taylor-series-not-accurate which allows for faster convergence near -1,1. I have a benchmark in my repo to show it more performant than Math.acos or FastMath.acos, accuracy appears OK in my tests. NVector with acos faster than Haversine.

@madrob
Copy link
Contributor

madrob commented Jul 14, 2022

please also run ./gradlew tidy to make sure that your code adhered to our formatting conventions. :)

@madrob
Copy link
Contributor

madrob commented Jul 21, 2022

I found your source FastInvTrig repo and ran the benchmarks from there, with a few tweaks to improve measurement accuracy and also added Lucene's SloppyMath into the competition.

My results are directionally the same as yours -

Benchmark                                    Mode  Cnt    Score    Error  Units
FastInvTrigBenchmark.acosBM                  avgt    3   17.576 ±  1.243  ns/op
FastInvTrigBenchmark.fastMathAcosBM          avgt    3   87.301 ±  4.303  ns/op
FastInvTrigBenchmark.haversineBM             avgt    3   95.062 ± 17.137  ns/op
FastInvTrigBenchmark.mathAcosBM              avgt    3  140.391 ±  5.508  ns/op
FastInvTrigBenchmark.sloppyHaversineMeters   avgt    3   46.647 ±  8.927  ns/op
FastInvTrigBenchmark.sloppyHaversineSortKey  avgt    3   33.637 ±  6.364  ns/op

One concern for comparison that I have would be that the current Lucene implementation (which is only 2x slower than yours) has an upper error bound of 40cm. I think we can get there with your series expansions by adding additional terms, but I don't remember my calculus well enough to know how many we need. If we do that, how much does performance suffer? Are we still competitive? You're currently at 10m, which I suspect might be too large of a delta to be useful for the applications that I am familiar with.

@danrosher
Copy link
Contributor Author

Thanks for looking into this more @madrob .

I wasn't aware of SloppyMath. If we assume Math.acos correct, then to get the same accuracy with FastInvTrig to within 40cm, I've tested it takes 17 terms, which after running a benchmark means that:

Benchmark                                   (num_points)  Mode  Cnt    Score   Error  Units
FastInvTrigBenchmark.acosBM10                    2000000  avgt    2   44.149          ms/op
FastInvTrigBenchmark.acosBM17                    2000000  avgt    2   67.977          ms/op
FastInvTrigBenchmark.fastMathAcosBM              2000000  avgt    2  252.578          ms/op
FastInvTrigBenchmark.haversineBM                 2000000  avgt    2  288.209          ms/op
FastInvTrigBenchmark.mathAcosBM                  2000000  avgt    2  300.509          ms/op
FastInvTrigBenchmark.mathAsin                    2000000  avgt    2  320.407          ms/op
FastInvTrigBenchmark.sloppyAsin                  2000000  avgt    2   50.152          ms/op
FastInvTrigBenchmark.sloppyHaversinMeters        2000000  avgt    2  134.309          ms/op
FastInvTrigBenchmark.sloppyHaversinSortKey       2000000  avgt    2   82.845          ms/op

So then:

SloppyMath.HaversinSortKey is 1.9x slower than FastInvTrig with 10 terms (as you found)
SloppyMath.HaversinSortKey is 1.2x slower than FastInvTrig with 17 terms

However I also noticed that SloppyMath has an asin implementation, and with pi/2-asin(x) = acos(x)

https://www.wolframalpha.com/input?i2d=true&i=%5C%2840%29Divide%5Bpi%2C2%5D-asin%5C%2840%29x%5C%2841%29%5C%2841%29-acos%5C%2840%29x%5C%2841%29

after adding this into the benchmark, using nvector and this identity ^ for acos then:

FastInvTrig with 17 terms is 1.3x slower than SloppyMath.asin !

The caveat is that SloppyMath.asin uses more memory for caching values I think.

So I'm wondering now whether to abandon the FastInvTrig series expansion, and use SloppyMath.asin for NVector? What do you think ?

@madrob
Copy link
Contributor

madrob commented Jul 21, 2022

I'm a little confused as to what the sloppyAsin results are...

From your tests, we should still prefer NVector with MacLaurian expansion at 17 terms over using Sloppy Haversine, right? Is Sloppy Asin using NVector with no series expansion for the trig and instead the lookup tables from Sloppy Math? Which gives us the 40cm accuracy at almost the original 10 term expansion performance level?

@madrob
Copy link
Contributor

madrob commented Jul 21, 2022

Can we do a similar trick to split the calculation to get an n-vector sort key and an n-vector meters and get even more speedup for the cases where we don't care about absolute distances?

@danrosher
Copy link
Contributor Author

danrosher commented Jul 22, 2022

I'm a little confused as to what the sloppyAsin results are...

From your tests, we should still prefer NVector with MacLaurian expansion at 17 terms over using Sloppy Haversine, right? Is Sloppy Asin using NVector with no series expansion for the trig and instead the lookup tables from Sloppy Math? Which gives us the 40cm accuracy at almost the original 10 term expansion performance level?

We calculate the Great circle distance as d=R*acos(a.b) where d = distance, R = radius, and a,b are NVectors (a.b is the scalar dot product)

We also know acos(x) = pi/2-asin(x)

So I compared FastInvTrig.acos (with 17 terms) with SloppyMath.asin, and found that FastInvTrig.acos with 17 terms is 1.3x slower than SloppyMath.asin.

This is what I meant with FastInvTrigBenchmark.sloppyAsin

so SloppyMath.asin, at the required precision, is faster than FastInvTrig.acos,

So I was thinking of abandoning FastInvTrig.acos in favour of SloppyMath.asin, what do you think?

Can we do a similar trick to split the calculation to get an n-vector sort key and an n-vector meters and get even more speedup for the cases where we don't care about absolute distances?

Yes! From looking at the acos plot ( https://www.wolframalpha.com/input?i2d=true&i=acos%5C%2840%29x%5C%2841%29 ) it's a 1 to 1 function, so well suited for comparison. I did a quick test which confirmed that the dot product is enough for comparison between values (which is all we need to do, as we cache the nvectors in the NVectorField). So we then have the following benchmark (with NVectorSortKey as this comparison and the fastest) :

Benchmark                                   (num_points)  Mode  Cnt    Score   Error  Units
FastInvTrigBenchmark.NVectorSortKey              2000000  avgt    2   13.846          ms/op
FastInvTrigBenchmark.acosBM10                    2000000  avgt    2   45.620          ms/op
FastInvTrigBenchmark.acosBM17                    2000000  avgt    2   68.673          ms/op
FastInvTrigBenchmark.fastMathAcosBM              2000000  avgt    2  249.075          ms/op
FastInvTrigBenchmark.haversineBM                 2000000  avgt    2  289.814          ms/op
FastInvTrigBenchmark.mathAcosBM                  2000000  avgt    2  304.411          ms/op
FastInvTrigBenchmark.mathAsin                    2000000  avgt    2  323.126          ms/op
FastInvTrigBenchmark.sloppyAsin                  2000000  avgt    2   49.749          ms/op
FastInvTrigBenchmark.sloppyHaversinMeters        2000000  avgt    2  135.847          ms/op
FastInvTrigBenchmark.sloppyHaversinSortKey       2000000  avgt    2   82.149          ms/op

So in Solr perhaps we can use NVectorSortKey for sort comparisons then.

@madrob
Copy link
Contributor

madrob commented Jul 22, 2022

Ok, I get it now. Yes, let's do the SloppyMath.asin approach, splitting out the sort key (I would call it dot product and add comments about why we can use it as sort key instead of calling the method SortKey)

@danrosher
Copy link
Contributor Author

  • SloppyMath.asin now replaces FastInvTrig.acos
  • Sorting in the function query is on the dot product, values still fetch distance

Copy link
Contributor

@madrob madrob left a comment

Choose a reason for hiding this comment

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

I think the core of the idea is good here. Big thing missing is an update to the ref guide, probably function-queries.adoc or spatial-search.adoc?

Another thought I had was how exactly do we expect users to use this. If they're still going to be providing indexable data in lat/long and also expecting lat/long for output information, then will this really be faster than using haversine? Or does it move the computation to the indexing side when we only have to do it once, so over multiple queries the total time taken gets reduced...

import java.util.Locale;
import java.util.Map;

public class NVectorField extends CoordinateFieldType {
Copy link
Contributor

Choose a reason for hiding this comment

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

should this extend from PointType instead? is there more that we get from it?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

NVectorField is different I think to PointType. NVector replaces lat/lon and overrides most of the important methods in PointType anyway. NVectorField would need a specialized getSpecializedRangeQuery too (not sure how to implement this for nvector yet)

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok, fair enough about not extending PointType. As for range queries... does NVector efficiently support shape intersections? I'm under the impression that it doesn't - that's part of the way that we get such a speed up on distance...

* @return An array of the values that make up the point (aka vector)
* @throws SolrException if the dimension specified does not match the number found
*/
public static String[] parseCommaSeparatedList(String externalVal, int dimension, String separator)
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we have some unit tests for this? Parsing is one of those thing that seems easy but there are always edge cases and it's easy to introduce regressions.

use Math for radian calc
@danrosher
Copy link
Contributor Author

I think the core of the idea is good here. Big thing missing is an update to the ref guide, probably function-queries.adoc or spatial-search.adoc?

When i get a moment I'll ad something to spatial-search.adoc perhaps?

Another thought I had was how exactly do we expect users to use this. If they're still going to be providing indexable data in lat/long and also expecting lat/long for output information, then will this really be faster than using haversine? Or does it move the computation to the indexing side when we only have to do it once, so over multiple queries the total time taken gets reduced...

This moves most of the calculation to the indexing side. We then only need to calculate an n-vector for the input lat/lon. The sorting can then be done on the dot-product alone. Users can optionally index spatial data in multiple formats (e.g. LanLonSpatialField and NVectorField) should they find that a performance boost. N-Vector provides faster comparison (and great circle distance calculation) than haversine, additionally without caveats, or accuracy degradation, for calculations at poles/equator etc.

Copy link
Contributor

@madrob madrob left a comment

Choose a reason for hiding this comment

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

Few more comments, mainly about documentation of the feature.

import java.util.Locale;
import java.util.Map;

public class NVectorField extends CoordinateFieldType {
Copy link
Contributor

Choose a reason for hiding this comment

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

Ok, fair enough about not extending PointType. As for range queries... does NVector efficiently support shape intersections? I'm under the impression that it doesn't - that's part of the way that we get such a speed up on distance...

};
}

public static double[] NVectorToLatLong(double[] n) {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: method names should start with lowercase (here and others)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

updated


import static org.locationtech.spatial4j.distance.DistanceUtils.EARTH_MEAN_RADIUS_KM;

public class NVectorUtil {
Copy link
Contributor

Choose a reason for hiding this comment

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

can we add javadoc for the class and methods?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

added


public static double[] NVectorToLatLong(double[] n) {
return new double[] {
Math.asin(n[2]) * (180 / Math.PI), Math.atan(n[1] / n[0]) * (180 / Math.PI)
Copy link
Contributor

Choose a reason for hiding this comment

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

Math.toDegrees

Copy link
Contributor Author

Choose a reason for hiding this comment

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

changed

req(
"defType", "lucene",
"q", "*:*",
"nvd", "nvdist(52.01966071979866, -0.4983083573742952,nvector)",
Copy link
Contributor

Choose a reason for hiding this comment

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

I missed where 'nvdist' is defined as a function. Do we need to add to ValueSourceParser static init block?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

added to ValueSourceParser

Dan Rosher added 2 commits August 9, 2022 14:44
- lc method names
- use Math static method where appropriate
Dan Rosher added 2 commits August 9, 2022 14:45
@github-actions
Copy link

This PR had no visible activity in the past 60 days, labeling it as stale. Any new activity will remove the stale label. To attract more reviewers, please tag someone or notify the dev@solr.apache.org mailing list. Thank you for your contribution!

@github-actions github-actions bot added the stale PR not updated in 60 days label Feb 19, 2024
@github-actions
Copy link

github-actions bot commented Oct 9, 2024

This PR is now closed due to 60 days of inactivity after being marked as stale. Re-opening this PR is still possible, in which case it will be marked as active again.

@github-actions github-actions bot added the closed-stale Closed after being stale for 60 days label Oct 9, 2024
@github-actions github-actions bot closed this Oct 9, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

closed-stale Closed after being stale for 60 days stale PR not updated in 60 days

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants