Expand description
Set algebra, and the choice between probing and merging.
SINTER, SUNION, SDIFF, SINTERCARD and the *STORE forms. This is the
family aki lost worst on, at 0.75x for SINTER and 0.30x to 0.55x for the
*STORE forms, and 08 section 4 sets the gate at ten times for all of them.
§Two ways to do it
Probe. Take the smallest set, and for each of its members ask every other
set whether it has it. Work is |smallest| * (k - 1) questions in the worst
case, and far fewer in practice because a member that is missing from the
second set is never asked about the third. Every question is a random access
into a different table.
Accumulate. Walk every member of every set once, into one table that
counts how many sets each member appeared in, and then read the answer off
the counts. Work is sum(|set|) insertions, all of them into the same table.
K11 pre-registers a crossover at k around 7: below that probe, above it merge. It does not reproduce, and it is worth being exact about why, because the reason is not that the number is a little out.
§The crossover is not at seven and there is not one
benches/setops.rs runs both plans over the same sets at k from 2 to 16, with
sets of two hundred thousand and nine tenths of every set shared, which is the
shape that gives probe the least help. Probe wins at every k. The gap narrows
as k grows, from 2.95 times at k equals 2 to 1.24 times at k equals 16, and it
narrows towards parity rather than towards a crossing.
The arithmetic says the same thing once the cost of an operation is measured
instead of assumed. Probe does n * (k - 1) table operations. Accumulate does
n * (k + 1), being one seeding insert and one count raise per member plus the
read back. Those are 2.7 and 3.4 million at k equals 16, a ratio of 1.26
against a measured 1.24. Probe does less work at every k and the ratio tends to
one from above, so these two never cross.
The pre-registered number assumed a probe question is much dearer than an
accumulate touch, because a question is a random access into a table this
operation has not otherwise touched and 08 section 4 floors that at about 40
ns on a DRAM miss. Both come out at about 25 ns here. An accumulate touch is
not the cheap sequential thing the model had in mind: it hashes the member and
makes its own random access, into the counting table. Two random accesses that
cost the same cannot trade off against each other, however many of them there
are. This is L6’s 70 ns positional probe again, which measured 13.
§The third plan, which does change it
08 section 4 describes a merge that is neither of the two above: sorted
arrays walked in lockstep, where a touch is a pointer step and a comparison
with no hash anywhere. That genuinely is much cheaper than a probe question,
and against it a crossover can exist. It was written down as needing the
partitioned band and was therefore out of reach.
It is in reach now, from the other direction. An all integer set is an
Intset, which is exactly a sorted array, and since #148 it stays one
however big it gets rather than turning into a table at five hundred and
twelve members. So whenever every operand is an intset there is something to
merge, and that is most of what SINTERSTORE is called with: identifier
sets, bitmap style tag sets, anything a numeric primary key went into.
Plan::Merge is that, over Walk, and it is why plan_for is a
chooser with something to choose. The intersection is a leapfrog driven from
the smallest set: take the value that set is on, pull the others up to it
with Walk::seek, and if they all land on it then it is in all of them.
The seek is what makes the asymmetric case cheap, because a set of ten
against a set of a million touches ten members of the big one and skips the
rest.
The counting plan stays reachable through inter_with and the benchmark
keeps measuring it, because it is the control the merge has to beat.
§What the merge is worth, measured
benches/setops.rs builds the same four shapes as integer sets and runs
every plan over them. Milliseconds per intersection, minimum per iteration,
two hundred thousand members a set:
k=2 k=4 k=8 k=16
dense merge 3.95 5.15 11.06 23.91
probe 6.93 11.93 22.63 41.95
count 16.32 25.91 45.03 84.90
sparse merge 0.04 0.06 0.12 0.26
probe 6.17 6.21 6.38 6.54
striped merge 4.70 5.07 5.13 5.27
probe 7.42 6.43 7.63 7.80
skewed merge 0.002 0.003 0.007 0.015
probe 0.004 0.007 0.013 0.023And the other three commands, where the merge’s opposite number is the table for the union and the probe for the other two:
k=2 k=4 k=8 k=16
union merge 1.47 4.35 14.76 54.49
table 13.30 31.21 69.84 149.82
diff merge 4.40 5.16 8.60 15.98
probe 6.94 10.02 16.65 29.07
store merge 7.17 10.36 16.59 29.79
probe 13.88 23.95 36.69 63.24The merge wins every row at every k. The narrowest is 1.27 times and the widest is 141, which is a spread wide enough to be worth explaining rather than averaging.
§Where the spread comes from, and the shape that nearly broke it
sparse and striped hold the same sets with the same one percent overlap
and differ only in where each set’s unshared members sit. In sparse they
are in a range of their own, so a cursor that lands in another set’s range
steps over the whole range in one binary search. In striped they are
interleaved one for one, so there is nothing to skip and a step is worth a
single member. That is 141 times against 1.6, on data that is identical by
every summary statistic an optimiser could look at. Real data lies between
the two and the number to quote is the striped one.
Getting that row right took two goes and it is the reason the shape is in the
benchmark. The first merge was symmetric: no set in charge, the largest value
any cursor held as the target, every cursor visited in turn. On striped it
was nine times slower than the probe at k of 16, and it deserved to be. A
symmetric leapfrog costs a step per member of the union of every operand,
because proving that nothing matches means looking at everything, and the
union is k times the smallest set. The probe reads the smallest set once
and fails on its first question, so it is flat in k, which is exactly what
sparse_probe and striped_probe do at about 6 to 8 ms across the range.
Driving the leapfrog from the smallest set fixes it, because it puts the
merge on the probe’s own bound: a step per member of the smallest operand,
plus one per overshoot, over a step that is cheaper than a hash and a random
access. striped_merge is 4.70 ms at k of 2 and 5.27 at k of 16, which is
the same flatness the probe has with a smaller constant.
So the honest claim is not that the merge is a different order of cost. It is that the merge is never worse than the probe by more than its constant and is sometimes better by two orders, and that the plan is free to take because the representation already sorted the data.
The one row with a slope worth watching is the union, which finds the
smallest value by looking at every cursor and is therefore quadratic in k
where the table is linear. It wins by 9.1 times at k of 2 and 2.75 at k of 16,
and extrapolating the two slopes they would cross somewhere past k of 50. A
heap would make it log k at the cost of a comparison per push, and there is
no point paying that until a SUNION with fifty keys turns up.
§Ordering
A probe or a count returns members in the order the first relevant set holds them, which is insertion order for a listpack or a table and ascending for an intset. A merge returns them ascending. Redis makes no ordering promise for any of these, and picking the order the data is already in means the walk is sequential and there is nothing to sort.
For the intersection and the difference the two agree, because the plan only changes when every operand is an intset and the set being walked is then ascending either way. For the union they do not: the table walks the sets in turn and the merge interleaves them. That is the one place a plan is visible from outside, and it is visible only to a client that was relying on something Redis never promised.
§The three representations
The operand is a Set, which is one of three things, and not the element
table it used to be. The walked set gives up members through the same
Set::iter everything else uses, and the questioned sets answer through
Set::has, which is Set::contains with the parse and the hash lifted
out into a Needle so they happen once per member rather than once per
question.
What that buys is that the algebra never has to know what it is holding. It
also means the members cross between representations correctly, which is not
automatic: an intset member is a number that has no digits anywhere, and a
table stores that same member as its digits, so SINTER ints table only
finds anything because the needle carries both forms.
§Presizing
The *STORE forms hand the destination a size before they start filling it,
taken from the smallest input, which is Y18’s rule and an upper bound on any
intersection. 05 section 3.1 wants that to be one arena bump. Until the
arena is under this, the destination’s own hint is the same promise with a
different allocator behind it.
Structs§
- Scratch
- The tables a set operation fills in on its way to an answer.
Enums§
- Plan
- How to answer a set operation.
Functions§
- collect
- The
*STOREforms: run the operation and build the result as a set. - diff
- The members of the first set that no later set has.
- diff_
with - The same, with the plan named rather than assumed. See
union_with. - inter
- The members every set has, in the order the smallest set holds them.
- inter_
with - The same, with the plan named rather than assumed.
- union
- Every member of any of the sets, each once.
- union_
with - The same, with the plan named rather than assumed.