An implementation of the Adaptive Quotient Filter (AQF) in Rust.
© David J. Lee, 2021.
The logic is very close to that of the C implementation, with a few notable differences:
- The filter now grows when it runs out of space for remainders, instead of starting off with a static amount of extra space. We do this by putting the filter's blocks in a vector instead of an array, which could impact total space utilization but affords us greater flexibility and generality.
find_runend
is renamed asrank_select
, as this better represents what we are doing: this function does not find the runend of a particular slot; it is merely a useful shorthand for a blocked operation that reused multiple times (select(Q.runends, rank(Q.occupieds, x))
), notably in insertions, queries, and thefind_first_unused
operation.rank_select(x)
makes use of a custom Enum calledRankSelectResult
, which consists of three cases (Empty
,Full(loc)
, andOverflow
). This allows the function to more clearly signal its result:Empty
indicates that the result ofselect(Q.runends, rank(Q.occupieds, x))
precedesx
; i.e., the home slot for the quotientx
is open.Full(loc)
indicates that the home slot forx
is taken, andloc
is the last slot that is taken by a run for a quotient smaller thanx
.Overflow
indicates that the search passed the last block in the filter.
- Elsewhere in the code, we replace haphazard flag values and returns by reference
with proper return values, sometimes using
Option
s. Generally,Some(y)
indicates a valid resulty
andNone
indicates an invalid result (e.g., running out of space or going off the end of an array). - Loop logic is modified somewhat; Rust lacks
do-while
loops, so we make do with infinite loops that break when a condition is met. - Casts between integer types are made explicit.
- Boolean to integer casts are implemented using conditional expressions or explicit casts instead of implicit casts.
- Integer overflows are fixed. Whereas C allows them, Rust does not -- these overflows cause compiler errors. So the Rust AQF prevents these overflows from happening with additional checks. This is especially important for incrementing direct/indirect offsets.
- Functions are more rigorously tested. See the
tests
module for details.
In an RSQF, each block stores an offset, which lets us avoid searching the entire filter when looking for a run; instead, we can start at the block containing the quotient of the word we are interested in and use offsets to account for runs from earlier blocks.
The offset O_i
of the absolute index i
in filter Q
is defined as
O_i = max(select(Q.runends, rank(Q.occupieds, i)) - i, 0)
This measures the distance from i
to the k
-th runend in Q
, where k
is the number of runs with quotients in the interval [0, i]
. Therefore, when i
is occupied, O_i
will be the distance from i
to its runend; when i
is not occupied, O_i
will be the distance from i
to the runend of the last quotient before i
. In the second case, the distance may be negative (if the run before i
ends before i
); instead of storing this distance, we store O_i = 0
, using max(x, 0)
to clamp.
Because we only store offsets for the first slot in each block, for a block B
,
- if
B[0]
is occupied, thenB.offset
will be the distance fromB.start
to the runend forB[0]
; - if
B[0]
is unoccupied, thenB.offset
will be either0
or the distance fromB.start
to the runend for the last run from a block beforeB
.
To compute the offset of a slot j
where i = j - (j mod 64)
, i.e., i
is the position of the start of the block that j
belongs to, Pandey et al. suggest the following procedure:
d = rank(Q.occupieds[i+1, j], j-i-1)
O_j = select(Q.runends[i+O_i+1, end], d)
d
represents the number of occupied quotients in the interval [i+1, j]
; that is, between i
and j
and excluding i
. O_j
is computed as the d
-th runend in Q
after i+0_i
.
Insertions will modify offsets in different ways depending on whether an inserted element's home slot is empty and whether the inserted elements extend existing runs or create new ones.
At insert time, we only increment those offsets that are already non-negative. A negative offset will either remain negative or become zero, but because zero offsets and negative offsets are represented the same way (as zeros), we need not track this transition explicitly.
If a block
B
's offset points to the runend forB[0]
, then we callB.offset
owned. If, instead,B
's offset points to the runend of the last runend in a block beforeB
, then we say thatB.offset
is unowned.We can distinguish owned from unowned offsets by checking whether
B[0]
is occupied. If it is occupied, thenB.offset
is owned; otherwise, it is unowned.
Let x
be the element we are inserting, with q = quot(x)
.
If the home slot q
is empty, then there is no runend at q
, so there can't be a non-negative offset pointing to it (because non-negative offsets must point to runends). Therefore, we don't need to increment offsets in this case.
If the home slot q
is taken, then we need to lookup the appropriate location to insert into. First, we find the runend r = rank_select(q)
.
If q
is occupied, then r
marks the end of q
's run. Inserting x
means extending this run with rem(x)
. We first make room for rem(x)
by finding u
, the first unused slot after r
, and shifting forward (by 1) the remainders and runends in the interval [r+1, u-1]
, updating offsets with targets in this interval. Now that slot r+1
is open, we insert rem(x)
at r+1
: we shift the runend bit at r
to r+1
and increment any non-negative offsets formerly pointing to r
.
If q
is unoccupied, then r
marks the end of the last run before q
. Here, inserting x
means adding a new run. As in the previous case, we make room for rem(x)
at r+1
by shifting runends and remainders and updating offsets. Next, we handle offsets previously pointing to r
in the following manner:
- If the offset is owned, then don't increment it. Call the offset
o
and letB
be the block it belongs to.o
being owned means thatr
is the index of the end of the run starting atB.start
. Adding a new run atr+1
should not affectB
's offset. - If the offset is unowned, then:
- If
q < B.start
forB
the block containingo
, then increment. This condition must be met forB.offset
to be affected byq
's runend. - If
q = B.start
, then increment. Adding a new run forB[0]
should setB.offset
tor+1
. Because it was previously pointing tor
, this means that incrementingB.offset
will set it to the correct value. - If
q > B.start
, don't increment. A run for a quotient afterB
should not affectB.offset
.
- If
Given elements A
and B
that have the same quotient q
and remainder r
, a non-adaptive quotient filter need only store one copy of the pair (q, r)
.
In the AQF, we store both A
and B
in the remote representation, so that the fingerprints for both can adapt independent of each other. This leaves us with a choice of what to store in the local filter:
-
"One-to-many": We store one copy of the pair
(q, r)
in the filter, which maps toA
andB
in the remote representation. When there is a collision at(q, r)
for a queryC
, we adapt either one or all of the elements associated with it, which requires that we perform an insertion to handle the case where the extensions forA
andB
(relative toC
) diverge. -
"Many-to-many": We store two copies of the pair
(q, r)
in the filter. This means that we need not perform additional inserts when a query collides with(q, r)
.
We go with the latter approach because it doesn't require additional insertions at adapt time.
There are a few key points in the code where we need to choose policies to deal with fingerprint collisions consistently.
At query time, we need policies to determine the following:
-
Whether a query fingerprint matches a stored fingerprint. Our current policy is to find the first stored fingerprint whose quotient, remainder, and extension match that of the query fingerprint. This means that if stored fingerprints
f(a)
andf(b)
share the same quotient and remainder, butf(a)
has an extension andf(b)
doesn't, such thatf(a) = q:r:e
andf(b) = q:r:_
, then when querying witha
, we may findq:r:_
instead ofq:r:e
. -
Which remote elements are associated with a local element. Once we have found a stored fingerprint
f(s)
that matches a query fingerprintf(q)
of query elementq
, we need to determine whetherq
is in the filter (i.e., whether the result is a true or false positive) to determine whether we need to adapt. This requires retrievingS(f(s))
, the set of stored elements associated withf(s)
, and seeing whether anys
inS(f(s))
matchesq
. Therefore, we need to defineS
, which maps from a stored fingerprint to its associated stored elements.
At adapt time, we need to determine the following:
- Which remote element to update. If
q
does not match any element inS(f(s))
, then we need to adapt. But if there are multiple elements inS(f(s))
, which of these elements should we extend the fingerprints of?- One: extend the fingerprint of a single element plucked arbitrarily from
S(f(s))
.- Pros: fast, rebuilds less often
- Cons: under-adapts
- All: extend the fingerprints for all
s
inS(f(s))
- Pros: fixes collisions more thoroughly
- Cons: slow; over-adapts; only distinguishes false matches from query, but not with each other
- One: extend the fingerprint of a single element plucked arbitrarily from
- Which remote elements to rebuild. If we run out of space in a block's encoding, we clear all the extensions of local fingerprints in the block. This necessitates that we also clear the extensions of the remote elements, which in turn requires that we have a mapping from local fingerprints to remote elements.
In the C implementation, we don't shift selectors during insertions. This isn't an issue for our current test suite, because we do all inserts before doing any queries. This means that we don't have any nonzero selectors at insert time, so inserts need not worry about shifting selectors. But to make the filter more general, we need to shift selectors/extensions during insertions (do speed this up, we can check whether extensions are 0 and skip shifting them if this is the case).
When encoding the extensions associated with a block fails, we zero out the extensions. This requires that we update the remote representation as well, because the remote representation maps from (quot, rem, ext) triples to (word, hash) pairs.
When clearing a block, it is trivial to find its stored remainders and their extensions, as these are contained in the block. Finding the associated quotients is nontrivial, as the quotients contained in the block (in the form of the occupieds
bitarray) don't necessarily correspond to the stored remainders.
So we need a way to figure out the quotient for each of the remainders stored in the block B
in a filter Q
. Let i
be the index of the block, so that its first remainder B.rems[0]
is at position i*64
in the filter. We refer to this position as B.start
.
We already have a function that we can use to get from quotients to their corresponding runends; this is rank_select(x)
, a shorthand for rank(select(Q.runends, rank(Q.occupieds, x))
. Note that this function only gives the corresponding runend if x
is occupied; if it is not, then the result of rank_select(x)
is the runend for the last occupied quotient before position x
.
Because our filter is split into blocks, we store offsets in each block to let us run rank_select
without traversing the whole filter. For the same reason, we can't just run a mirrored version of rank_select
that maps runends to their quotients, because this would require that we store offsets that go in the opposite direction.
Instead, we have to make do with rank_select
; that is, we need to figure out the quotients associated with the remainders in a block given
- a function that maps from quotients to runends,
- the block's index, and
- block metadata (each block's
occupieds
,runends
, andoffset
).
There are two approaches I've thought about:
- Determine the quotient of the first run that intersects with the block. (This requires scanning backwards through quotients until we find the last one whose runend >=
B.start
.) Then, walk backwards through the remainders in each run, but step forward to the next occupied quotient when done going through a given run. Repeat until we hit the last quotient whose run intersects the block. (This is roughly what we do in the C code.) - Determine the quotient of the last run that intersects the block. (This requires scanning backwards through quotients until we find the first one whose run doesn't overshoot the end of the block.) Then, walk backwards through the remainders in each run, and when done with a run, step backward to the previous occupied quotient. Repeat until we hit the first quotient whose run intersects the block.
I've chosen to go with the latter approach because it gives us better locality and means that we don't have to do two passes.
- Find the quotient (
q
) and runend (end
) of the last run that intersects the block. - Step through each remainder in the run, clearing its extension in the remote representation if the remainder is in the block of interest. Stop when we reach a runend, the start of the block, or
q
. - Use rank and select to jump to the previous occupied quotient and runend. If the new runend is before the start of the block, stop. Otherwise, repeat Step 2 with the new quotient and runend.
- Clear all extensions in the block. (This is faster than clearing each extension one by one.)
In pseudocode, where Q
is the filter, B
is the block of interest, and block_i
is the index of the block:
let B = Q.blocks[block_i]
let (q, i) = last_intersecting_run(block_i)
// skip backward until we hit the block
while i >= block_start + 64:
i -= 1
if Q.runend(i):
q = prev_quot(q)
// q is a quotient that is in block B or any earlier block.
// therefore, if it is not intersecting B, that means that it
// must have been pushed over by another run;
// so, if there's no runend between the end of B and q's runend,
// then the run for q overlaps with B.
loop block:
loop run:
update remote (q, Q.rem[i], Q.ext[i]) -> (q, Q.rem[i], none)
if i == B.start:
break block
else if i == q or Q.runend(i-1):
break run
else
i -= 1
(q, i) = prev_pair(q, i)
clear B.extensions
Determines the quotient and runend for the last run that intersects the block_i
-th block.
Start searching for the last intersecting run's quotient at B
's last quotient. If B
has no quotients, then start at B.start := block_i * 64
or quit depending on whether B
's offset is zero.
- If the offset is 0 and the block has no quotients, then the block is guaranteed to be empty, so we quit early.
- If the offset is positive and the block has no quotients, then the last intersecting run belongs to a quotient from a prior block.
If B
has occupied quotients, then we start at the last occupied quotient. (We can do this by using either leading_zeros
or bitselect(B.occs, popcnt(B.occs)-1)
.)
After determining the location from which to start the search, jump backwards through previous (quotient, runend) pairs using rank and select to find the first pair whose runend < B.start + 63
, and backtrack to the previously encountered (quotient, runend) pair. This is the pair we want.
// (1) Get start position of search
let n = popcnt(B.occs)
if n == 0:
if B.offset == 0 and B[0] is not a runend:
return None
else:
let q = prev_q(B.start)
else:
let q = highest_set_bit(B.occs) // get last occupied quotient
// ^ defined below, same as select(B.occs, count-1) in this context
// (2) Search backwards to find last intersecting run
let end = rank_select(q)
if end <= B.start + 63:
return Some(q, end)
else:
loop:
let last_q = q, last_end = end // store current (q, end)
if prev_pair(q, end) gives None: // prev_pair uses rank-select and
// returns None if no valid result found
return last_q, last_end
else:
q, end = prev_pair(q, end)
if end < B.start + 63:
return last_q, last_end
Uses rank and select to get the previous (quot, runend) pair given (q
, end
). As an optimization, exits early (returns None) if the previous pair's runend is before bound
.
This can be implemented straightforwardly by defining the helpers prev_end(end, bound)
and prev_q(q)
.
let block_i = end/64
let b = Q.blocks[block_i]
let n = if end%64 == 0 then 0, else rank(b.runends, end%64 - 1)
if n == 0:
for i in [block_i-1, bound_i]:
b = Q.blocks[i]
if b.runends > 0:
return i*64 + highest_set_bit(b.runends)
return None
else:
return block_i * 64 + select(b.runends, n-1)
Gets the last occupied quotient (q'
) before q
, returning Some(q')
if one exists and None
if q
is the first occupied quotient in the filter.
let block_i = q/64
let b = Q.blocks[block_i]
let n = if q%64 == 0 then 0, else rank(b.occupieds, q%64 - 1)
if n == 0:
for i in [block_i-1, 0]:
let b = Q.blocks[i]
if b.occupieds > 0:
return i*64 + highest_set_bit(b.occupieds)
return None
else:
return i*64 + select(b.occupieds, n-1)
Gets the index of the MSB set to 1.
fn highest_set_bit(bits: u64) -> usize {
assert_ne!(bits, 0);
63 - bits.leading_zeros()
}