Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Fast isValidCell #496

Draft
wants to merge 24 commits into
base: master
Choose a base branch
from
Draft

Conversation

ajfriend
Copy link
Contributor

@ajfriend ajfriend commented Jul 5, 2021

In working on a new compactCells implementation, isValidCell was showing up as a bottleneck in some of the benchmarks, so I started playing around with optimizing it.

This is still experimental (e.g., I'm still using 7 instead of the INVALID_DIGIT macro just because I find that easier for prototyping).

Putting this up now as a draft PR to get some early feedback, to take notes on what I've tried so far, and to discuss different implementation options.

Current benchmarks have this new implementation taking about 60% of the time of the current implementation. Note that the benchmarks can depend on the resolution of the cells; low resolution cells tend to go faster because the last INVALID_DIGIT check can avoid more looping (I think...).

@coveralls
Copy link

coveralls commented Jul 5, 2021

Coverage Status

Coverage decreased (-2.5%) to 96.201% when pulling 5a70747 on ajfriend:fast_isValidCell into 42f56e3 on uber:master.

// The 4 mode bits should be 0b0001 (H3_CELL_MODE)
// The 3 reserved bits should be 0b000
// In total, the top 8 bits should be 0b00001000
if (GT(h, 8) != 0b00001000) return 0;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Binary 0b literals might not be portable, in which case we can always switch to hex. Binary is a bit easier for prototyping, tho :)

Copy link
Contributor Author

@ajfriend ajfriend Jul 5, 2021

Choose a reason for hiding this comment

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

Alternative implementation:
(also, could use #defines for the bit lengths here)

if (GT(h, 1) != 0) return 0;
h <<= 1;
if (GT(h, 4) != 1) return 0;
h <<= 4;
if (GT(h, 3) != 0) return 0;
h <<= 3;

This alternative might be clearer, and Clang is even able to compile each version to the same instructions. However, GCC doesn't seem to produce the same for the longer version: https://godbolt.org/z/sjKed8jn1

Screen Shot 2021-07-05 at 3 42 46 PM

(Even though the last shift in each example is optimized out to a no-op, still gives you the general idea.)

// The first nonzero digit can't be a 1 (i.e., "deleted subsequence",
// PENTAGON_SKIPPED_DIGIT, or K_AXES_DIGIT).
// Test for pentagon base cell first to avoid this loop if possible.
if (isBaseCellPentagonArr[bc]) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Using this lookup table seems to be a bit faster than calling _isBaseCellPentagon(bc).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Alternative:

int _isBaseCellPentagon(int baseCell) {
    switch(baseCell) {
        case 4:
        case 14:
        case 24:
        case 38:
        case 49:
        case 58:
        case 63:
        case 72:
        case 83:
        case 97:
        case 107:
        case 117:
            return 1;
        default:
            return 0;
    }
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

If you can use baseCellData[bc].isPentagon that should be equivalent? Not sure of the linking cost of going outside the file.

Comment on lines 171 to 180
// Now check that all the unused digits after `res` are
// set to 7 (INVALID_DIGIT).
// Bit shift operations allow us to avoid looping through digits;
// this saves time in benchmarks.
int shift = (15 - res) * 3;
uint64_t m = 0;
m = ~m;
m >>= shift;
m = ~m;
if (h != m) return 0;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Alternatives, but the one above using bit shifting seemed to be the fastest:

Loop

(slowest of all 3)

for (; r <= 15; r++) {
    if (GT(h, 3) != 7) return 0;
    h <<= 3;
}

Lookup table

static const uint64_t m7s[16] = {
    0b1111111111111111111111111111111111111111111110000000000000000000,
    0b1111111111111111111111111111111111111111110000000000000000000000,
    0b1111111111111111111111111111111111111110000000000000000000000000,
    0b1111111111111111111111111111111111110000000000000000000000000000,
    0b1111111111111111111111111111111110000000000000000000000000000000,
    0b1111111111111111111111111111110000000000000000000000000000000000,
    0b1111111111111111111111111110000000000000000000000000000000000000,
    0b1111111111111111111111110000000000000000000000000000000000000000,
    0b1111111111111111111110000000000000000000000000000000000000000000,
    0b1111111111111111110000000000000000000000000000000000000000000000,
    0b1111111111111110000000000000000000000000000000000000000000000000,
    0b1111111111110000000000000000000000000000000000000000000000000000,
    0b1111111110000000000000000000000000000000000000000000000000000000,
    0b1111110000000000000000000000000000000000000000000000000000000000,
    0b1110000000000000000000000000000000000000000000000000000000000000,
    0b0};

if (h != m7s[res]) return 0;

@ajfriend
Copy link
Contributor Author

ajfriend commented Jul 5, 2021

Some benchmark runs. New algo seems to take roughly 65% or 53% of the time of the current, depending on the input cells.

new algo

build/bin/benchmarkIsValidCell
    -- pentagonChildren_8_14: 1141.561000 microseconds per iteration (1000 iterations)
    -- pentagonChildren_2_8: 785.043000 microseconds per iteration (1000 iterations)
build/bin/benchmarkIsValidCell
    -- pentagonChildren_8_14: 1138.550000 microseconds per iteration (1000 iterations)
    -- pentagonChildren_2_8: 783.216000 microseconds per iteration (1000 iterations)
build/bin/benchmarkIsValidCell
    -- pentagonChildren_8_14: 1141.815000 microseconds per iteration (1000 iterations)
    -- pentagonChildren_2_8: 782.925000 microseconds per iteration (1000 iterations)

current algo

build/bin/benchmarkIsValidCell
    -- pentagonChildren_8_14: 1788.558000 microseconds per iteration (1000 iterations)
    -- pentagonChildren_2_8: 1462.292000 microseconds per iteration (1000 iterations)
build/bin/benchmarkIsValidCell
    -- pentagonChildren_8_14: 1750.815000 microseconds per iteration (1000 iterations)
    -- pentagonChildren_2_8: 1502.663000 microseconds per iteration (1000 iterations)
build/bin/benchmarkIsValidCell
    -- pentagonChildren_8_14: 1773.345000 microseconds per iteration (1000 iterations)
    -- pentagonChildren_2_8: 1467.033000 microseconds per iteration (1000 iterations)

Here are the ratios:

new = [
    [1141.561000, 785.043000],
    [1138.550000, 783.216000],
    [1141.815000, 782.925000],
]
old = [
    [1788.558000, 1462.292000],
    [1750.815000, 1502.663000],
    [1773.345000, 1467.033000],
]

np.array(new)/np.array(old)

gives

array([[0.63825775, 0.53685789],
       [0.65029715, 0.52121866],
       [0.6438764 , 0.5336792 ]])

Copy link
Collaborator

@nrabinowitz nrabinowitz left a comment

Choose a reason for hiding this comment

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

This is super fun :). The only optimization I have to add here is Duff's Device, not sure if it will gain you much but it could improve the remaining loops.

// Get Top t bits from h
#define GT(h, t) ((h) >> (64 - (t)))

static const bool isBaseCellPentagonArr[128] = {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why is this an improvement over baseCellData[baseCellNum].isPentagon?

// | ... | ... |
// | Digit 15 | 3 |
//
// Additionally, we try to group operations and void loops when possible.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: avoid loops?

// The first nonzero digit can't be a 1 (i.e., "deleted subsequence",
// PENTAGON_SKIPPED_DIGIT, or K_AXES_DIGIT).
// Test for pentagon base cell first to avoid this loop if possible.
if (isBaseCellPentagonArr[bc]) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

If you can use baseCellData[bc].isPentagon that should be equivalent? Not sure of the linking cost of going outside the file.

@@ -78,49 +78,108 @@ void H3_EXPORT(h3ToString)(H3Index h, char *str, size_t sz) {
sprintf(str, "%" PRIx64, h);
}

// Get Top t bits from h
#define GT(h, t) ((h) >> (64 - (t)))
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: This looks like "greater than" to me - GET_BITS?

if (digit != INVALID_DIGIT) return 0;
// After (possibly) taking care of pentagon logic, check that
// the remaining digits up to `res` are not 7 (INVALID_DIGIT).
// Don't see a way to avoid this loop :(
Copy link
Collaborator

Choose a reason for hiding this comment

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

You could unroll it into groups of 4 or 8, possibly with a macro to simplify the code. This would only work for hex base cells though, or for pentagon base cells if you re-check all the digits you just checked. Might be worth trying.

Choose a reason for hiding this comment

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

Not sure if this PR is still alive, but I've just stumbled on it.

Last week I've implemented my own quick isValidCell (a simpler one though, because for my use case I don't need to have any specific check for pentagons) and I think this loop can be avoided.

The search for the pattern 0b111 may not be easy to implement quickly, but on the other hand we can apply a bitwise NOT and look for 0b000 which is easier 🙂.
Indeed, looking for a null triplet is similar to looking for a nul-byte, something we do a lot in C (think strlen).

By digging into the annals of the Old Gods, a.k.a. comp.lang.c, we can extract this little gem: Alan Mycroft's null-byte detection algorithm, posted in 1987.

Tweaking the bitmasks to works on 3-bit instead of 8-bit does the trick and we end up with a code that would rougly look like

#include<stdint.h>
#include<stdio.h>
#include<assert.h>

#define MAX_RESOLUTION 15
#define CELL_BITSIZE   3


int has_invalid_digit(uint64_t index) {
    static const uint64_t LO_MAGIC = 0x49249249249ULL;  // ...001 001 001...
    static const uint64_t HI_MAGIC = 0x124924924924ULL; // ...100 100 100...

    const uint64_t resolution = (index >> 52 & 0xF);
    const uint64_t unused_count = MAX_RESOLUTION - resolution;
    const uint64_t unused_bitsize = unused_count * CELL_BITSIZE;
    const uint64_t digits_mask = (1ULL << (resolution * CELL_BITSIZE)) - 1;
    const uint64_t digits = (index >> unused_bitsize) & digits_mask;

    return ((~digits - LO_MAGIC) & (digits & HI_MAGIC)) != 0ULL;
}

int main(void) {
    // Valid H3 index
    // 0-0001-000-1100-0010101-110-101-110-001-100-000-101-001-100-110-110-101-111-111-111
    assert(!has_invalid_digit(0x08c2bae305336bffULL));
    // First digit invalid.
    // 0-0001-000-1100-0010101-111-101-110-001-100-000-101-001-100-110-110-101-111-111-111
    assert(has_invalid_digit(0x08c2bee305336bffULL));
    // Digit in the middle invalid.
    // 0-0001-000-1100-0010101-110-101-110-001-100-111-101-001-100-110-110-101-111-111-111
    assert(has_invalid_digit(0x08c2bae33d336bffULL));
    // Last digit invalid.
    // 0-0001-000-1100-0010101-110-101-110-001-100-000-101-001-100-110-110-111-111-111-111
    assert(has_invalid_digit(0x08c2bae305336fffULL));

    return 0;
}

So, in the end we can replace the loop by ((~digits - LO_MAGIC) & (digits & HI_MAGIC)) != 0ULL.

For sure, this would deserve some nice comments, because it's not obvious on a first read (I went heavy on the comments for my implementation xD).

If something is not clear I can give a more detailed explanation (but that basically boils down on how Alan Mycroft's null-byte detection works).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Wow, this is awesome! What a great find!

I spent a little time reworking this PR to add your logic. It definitely helps on the benchmarks.

It looks like you also came up with some improvements for the pentagon branch here: nmandery/h3ron#34

I'll have to parse that as well. Also happy if you want to make a pull against this PR!

Comment on lines 167 to 168
if (GT(h, nBitsDigit) == 7) return 0;
h <<= nBitsDigit;
Copy link
Collaborator

@dfellis dfellis Jul 15, 2021

Choose a reason for hiding this comment

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

Assuming bitshifts are as expensive as they were when I was in college, it may make sense to just have an array of 16 masks and do if (h & masks[r] == masks[r]) return 0; as the only part of the inner loop.

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 ran some toy timing tests and didn't see much difference between masking vs shifting: https://gist.github.com/ajfriend/32571f0af1f3ea3133b6836fc861c730

Choose a reason for hiding this comment

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

I wouldn't be surprised if both code generate the same (or almost) assembly behind.

Compiler are quite clever for this kind of simple expression nowadays, like you don't have to write n >> 1 instead of n / 2 and so on.
This is basic peephole optimisation.

@ajfriend
Copy link
Contributor Author

todo: maybe add a benchmark that runs through all the cells in a resolution.

@ajfriend ajfriend changed the title [experimental] Fast isValidCell [WIP] Fast isValidCell Feb 13, 2022
@ajfriend
Copy link
Contributor Author

@slaperche-zenly It looks like your fast version of the pentagon branch would depend on a C equivalent of leading_zeros(): https://gist.github.com/slaperche-zenly/204e9b8e305cfb8ce2eec49fbe7b9396#file-lib-rs-L62

Anyone have an idea if there is such a C equivalent?

@slaperche-zenly
Copy link

slaperche-zenly commented Feb 13, 2022

@slaperche-zenly It looks like your fast version of the pentagon branch would depend on a C equivalent of leading_zeros(): https://gist.github.com/slaperche-zenly/204e9b8e305cfb8ce2eec49fbe7b9396#file-lib-rs-L62

Anyone have an idea if there is such a C equivalent?

C compilers usually expose this as a builtin or intrinsic (IIRC it's __builtin_clz for GCC), but naming may differ across compilers and would require some conditional compilation to select the right one, plus a fallback on a handwritten version for unsupported compiler/architecture (this page lists some way to implement it, they call it "Find the log base 2 of an integer")

@ajfriend
Copy link
Contributor Author

C compilers usually expose this as a builtin or intrinsic (IIRC it's __builtin_clz for GCC), but naming may differ across compilers and would require some conditional compilation to select the right one, plus a fallback on a handwritten version for unsupported compiler/architecture (this page lists some way to implement it, they call it "Find the log base 2 of an integer")

That page is cool! I was thinking of the trick converting the int to a float and extracting the exponent, but not sure how portable that code would be. @isaacbrodsky @nrabinowitz @dfellis, thoughts?

@slaperche-zenly
Copy link

I was thinking of the trick converting the int to a float and extracting the exponent, but not sure how portable that code would be

As far as portability goes, you should be fine as long as you have IEEE-754 compliant floating point numbers, which should be everywhere (bar a few embedded or old/exotic systems).

I would be more worried about the type punning that may or may not run afoul of the strict aliasing rules (IIRC, the standard is not super clear on what’s allowed and what summon nasal daemons...)
Cf. here for a quick overview of the mess 😅

The approach based on DeBruijn sequence, adapted to 64-bit, sounds safer to me.

Comment on lines +349 to +350
g <<= 19;
g >>= 19; // at this point, g < 2^45 - 1
Copy link
Collaborator

@dfellis dfellis Feb 15, 2022

Choose a reason for hiding this comment

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

Why not replace this with a simple & mask that has 0s for the top 19 bits and 1s for the lower 45 bits? That should be much faster than shifting up and down 19 times each.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yup, agreed!

Comment on lines +360 to +361
// reinterpret the double bits as uint64_t
g = *(uint64_t *)&f;
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm unclear if this is safe - it may be necessary to use a different implementation on a platform like ARM (we can probably test this on Raspberry Pi). We should check if UBSan complains about this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

6 participants