DEV Community

Cover image for Part 12: XOR Filters
Federico Kereki
Federico Kereki

Posted on

Part 12: XOR Filters

In the previous articles in the series, we saw several implementations of filters with a common set of operations, including additions and (not always) removals. Here we'll study XOR Filters, which after creation do not permit any change to its values, but provide great performance with low memory requirements.


XOR Filters

An XOR Filter (XF) is another probabilistic search data structure, invented rather recently by Thomas Mueller Graf and Daniel Lemire in their 2020 article "XOR Filters: Faster and Smaller Than Bloom and Cuckoo Filters". Like the previous filters we saw, if asked whether some value is stored in it, it will reply either "absent" with 100% certitude (no false negatives), or "may be present" (with a certain probability of a false positive). Compared to the previous filters, an XF is smaller and faster to query, but at a cost: you build it once from the full data set, and you cannot add or remove values afterward unless you rebuild it from scratch.

(Another difference we had already mentioned in a previous article is that the create operation will take the whole array of items to be stored instead of just the expected number, and there will be no add or remove operations, just find to search the filter.)

How does an XF work? The core idea is to use an array (call it B) divided into three blocks of p-bit slots; the original article allowed different block sizes, but for simplicity we'll make them equal length. For each x value, we have:

  • three hash positions (h₀(x), h₁(x), and h₂(x)), one for each block
  • a p-bit fingerprint fp(x)

The array is built in a special way (which we'll soon see) so for every key in the set, it holds that fp(x) = B[h₀(x)] XOR B[h₁(x)] XOR B[h₂(x)]_. So, how do we query it? To check whether a value is in the XF, compute its fingerprint and hashes, then check whether the equality holds; if not, the value definitely isn't in the XF.

Sizing an XOR Filter

Let's start with the fingerprint size. If x was never inserted in the XF, the three cells hold values determined by other keys, and the XOR will in effect produce a random p-bit number, which will match fp(x) with false-positive probability ε=2^(‑p), so p=8 gives ε≈ 0.39%, p=16 gives ε ≈ 0.0015%, and p=24 goes down to 0.00000596%.

Now, for the array size. For n values, the array size is about 1.23_n_; for smaller n, adding 32 is recommended. The 1.23 constant comes from a threshold for a "peeling" graph algorithm we'll use; that number makes it very probable that the procedure succeeds.

The following code is part of the XF creation function; we'll get to the rest later.

const EPSILON = 0.01;

const createXorFilter = (items, eps = EPSILON) => {
➊const p = Math.ceil(Math.log2(1 / eps));
  if (p > 24) {
    throw new Error(
      "XOR filter: epsilon exceeds 24-bit fingerprint limit"
    );
  }

➋const s = Math.ceil((1.23 * items.length) / 3);

  if (items.length === 0) {
    return { p, seed: 0, s: 0, b: [] };
  }

  // array generation code, clipped out

};
Enter fullscreen mode Exit fullscreen mode

According to the formula for ε ➊ we can calculate the number of bits by taking logarithms; we use the ceiling function for safety; longer fingerprints reduce the false positive probability. For simplicity, we want the length of the array to be a multiple of 3 ➋ so we calculate the block size using the ceiling function.

The returned object has:

  • p is the fingerprint length
  • seed is used for the three hashing functions
  • s is the length of each block
  • b is the generated array; its length is 3*s 

We'll see the rest of the code soon (which includes how to calculate the contents of the b array) but let's look at the fingerprint and hashes now.

➊const fingerprintOf = (value, bits) =>
  hashWithSeed(value, 0) & ((1 << bits) - 1);

➋const h0 = (value, seed, s) =>
  hashWithSeed(value, 3 * seed + 1) % s;

const h1 = (value, seed, s) =>
  s + (hashWithSeed(value, 3 * seed + 2) % s);

const h2 = (value, seed, s) =>
  2 * s + (hashWithSeed(value, 3 * seed + 3) % s);

➌const indicesOf = (value, seed, s) => [
  h0(value, seed, s),
  h1(value, seed, s),
  h2(value, seed, s)
];
Enter fullscreen mode Exit fullscreen mode

To find the fingerprint ➊ we use the same hashWithSeed function we've been using, masking it to keep just a given number of bits; when using the function, we'll pass the p value we calculated. Next ➋ we have the three hash functions; we pass the block size s and we use three different seeds, so the hashes are independent. Finally ➌ the indicesOf function just returns an array with the three calculated hashes; we'll use it for searching.

We're still missing the rest of the XF creation code, but we can look at how to search it with some examples. See Figure 11½–24.

Figure 11½–24: Searching an XOR Filter for the 6 added values
Figure 11½–24: Searching an XOR Filter for the 6 added values

We created the XF with the 6 values on the left: GROUCHO, HARPO, CHICO, ZEPPO, GUMMO, and BROTHERS. At the top of the image, we have the array with 9 values, divided into three blocks. The values for the slots were calculated by the algorithm we'll introduce in the next section. For each value, we have its hexadecimal 8-bit fingerprint (fp) and the three calculated hashes.

How do we search? Take GROUCHO, for example:

  • Calculate its fingerprint (3A)
  • Calculate its three hashes, one into each block
  • Calculate the XOR of the three corresponding slot values, 7C, C5, and 83; the result is 3A 
  • Since the fingerprint and the XOR result match, the search is successful

Let's look at two searches for values not in the original set: ABBOTT, which is reported as not being in the set, and COSTELLO, which produces a false positive. See Figure 11½–25.

Figure 11½–25: A failed search and a false positive
Figure 11½–25: A failed search and a false positive

What happens in these cases?

  • For ABBOTT, its fingerprint is 42, its slot values are 89, A6, and 83 and the XOR of these three values is AC, which doesn't match 42 - we get a 100% negative

  • For COSTELLO, the fingerprint is 46, which happens to match the XOR of the three slots, so we get a false positive.

The search code is as follows.

const find = (filter, value) => {
➊if (filter.s === 0) return false;
➋const [i1, i2, i3] = indicesOf(value, filter.seed, filter.s);
➌return (
    fingerprintOf(value, filter.p) ===
    (filter.b[i1] ^ filter.b[i2] ^ filter.b[i3])
  );
};
Enter fullscreen mode Exit fullscreen mode

First, we check ➊ whether the XF is empty; it doesn't make sense to return a false positive when we know it's empty. Then, we find the three positions to check ➋ and finally we compare the calculated fingerprint with the XOR of the three positions ➌.

Now let's finish by completing the XF creation code.

Creating an XOR Filter

The key detail is how to calculate the B values so the XOR results match the expected fingerprints. Let's start by looking at which slots are involved for each value; see Figure 11½–26.

Figure 11½–26: The "peeling" algorithm decides which slot belongs to which entry
Figure 11½–26: The "peeling" algorithm decides which slot belongs to which entry

We have to assign values to the slots so the XOR works. Let's see:

  • BROTHERS is the only value that needs slot #5; given the values for slots #2 and #8, we can calculate the value for slot #5 so the XOR produces the correct fingerprint. We can "peel" BROTHERS off, and after determining the rest of the slots, we can come back to it and finish the job by calculating slot #5. We will say that BROTHERS owns slot #5 and uses slots #2 and #8. Also, let's forget the BROTHERS row of the table for now; imagine it gone.
  • GROUCHO is a similar case; it's the only value that needs slot #7, so it will own it (and use slots #0 and #3); we'll "peel" it off for later (when we'll be able to set slot #7 so the XOR calculation works out) and ignore that row for now.
  • ZEPPO is interesting; it needs slots #2, #4, and #6, but having "forgotten" the rows for BROTHERS and GROUCHO, we can peel ZEPPO off and let it own slot #2. When the time comes, we'll be able to calculate slot #2 so the XOR produces the correct fingerprint.
  • To sum up the rest, CHICO can be peeled off next, owning slot #0; then HARPO owning slot #3; and finally GUMMO owning slot #4.

What comes next? Initially, all slots are set to 00. Let's walk backward through the set of peeled values.

  • We can let slots #1 and #8 be 00 and set slot #4 to 17 to match the fingerprint to GUMMO. Three slots are now final.
  • For HARPO we already have slot #1 set to 00; we can let slot #6 at 00 and set the owned slot #3 to match C5. We now have five final slot values.
  • Working with CHICO we can set the owned #0 slot so its XOR with slots #4 (17) and #6 (00) produces 6B; the slot is set to 7C, and we have six final slot values.
  • The process is similar for ZEPPO (setting slot #2, taking into account the already set #4 and #6 slots), then GROUCHO (setting its owned slot #7 considering the values for slots #0 and #3), and finally BROTHERS (setting its owned slot #5, the last one).

How is this implemented? Let's analyze the rest of the code for createXorFilter.

const createXorFilter = (items, eps = EPSILON) => {

  // calculation of p and s, shown earlier, snipped out

➊const MAX_SEEDS = 1000;
  for (let seed = 0; seed < MAX_SEEDS; seed++) {
➋  const indices = items.map((item) =>
      indicesOf(item, seed, s)
    );

➌  const itemCounts = new Uint32Array(3 * s);
    const xors = new Uint32Array(3 * s);

➍  indices.forEach((itemIndices, itemIndex) => {
      itemIndices.forEach((index) => {
        itemCounts[index]++;
        xors[index] ^= itemIndex;
      });
    });

➎  const queue = [];
    itemCounts.forEach((count, index) => {
      if (count === 1) {
        queue.push(index);
      }
    });

➏  const peeled = [];
    for (let head = 0; head < queue.length; head++) {
      const index = queue[head];
➐    if (itemCounts[index] === 1) {
        const itemIndex = xors[index];
        peeled.push([itemIndex, index]);
        for (const other of indices[itemIndex]) {
          itemCounts[other]--;
          xors[other] ^= itemIndex;
          if (itemCounts[other] === 1) {
            queue.push(other);
          }
        }
      }
    }

➑  if (peeled.length === items.length) {
      const b = new Uint32Array(3 * s);
      const mask = (1 << p) - 1;
➒    for (let j = peeled.length - 1; j >= 0; j--) {
        const [itemIndex, index] = peeled[j];
        let fp = fingerprintOf(items[itemIndex], p);
        indices[itemIndex].forEach((i) => {
          fp ^= b[i];
        });
➓      b[index] = (fp ^ b[index]) & mask;
      }

      return { p, seed, s, b };
    }
  }

  throw new Error(
    "XOR filter: could not construct a peelable filter"
  );
};
Enter fullscreen mode Exit fullscreen mode

There's one issue ➊ we haven't discussed yet. As we described the "peeling" algorithm, it works as long as you can find columns used by only one remaining yet-unpeeled item… but is this guaranteed? The fact is, if you have bad luck, two different items could hash to the same three slots, and that would stop the algorithm cold. There's a simple solution that will almost certainly work: use a seed to calculate the three hashes, and if the algorithm fails, switch to another seed and try again. Obviously, we cannot keep attempting this, so some limit has to be set; if after MAX_SEEDS different seeds we keep failing, we'll give up.

What do we do in each attempt? The indices array ➋ will just store the three hashes for each item. The itemCounts array ➌ will count how many items use that slot, and the xors array stores the XOR of all the item indices that use the slot; as items get peeled away, their indices are XORed out, so when the count reaches 1, xors will have the index of the item that will own the slot.

We will push indices of the slots with just one user into the queue array ➎ and we'll start the "peeling" algorithm. We'll track the peeled items in the peeled array ➏ and start assigning values. The test at ➐ matters: we queue elements that use a single slot, but when we "remove" its row, we decrement counts in three buckets, so another bucket's count may drop from 1 to 0. When decrementing slot counts, if some other slot ends up associated with just one item, we push it into the queue for future processing.

Final processing ➑ starts by testing whether every item was peeled (and, if not, we loop again with another seed) and computes masks. We go down the peeled list in reverse order ➒ and do calculations so the XOR of the three slots matches the item's fingerprint; the key line is the assignment to b[index] ➓.


Bye for now! In the next article in the series, we will look at Ribbon Filters, which work similarly to XOR Filters but with even less memory overhead.


Top comments (0)