Plan the ProductZipper's factor order from the trie, behind an experimental conjunct_order feature - #152
Plan the ProductZipper's factor order from the trie, behind an experimental conjunct_order feature#152MesTTo wants to merge 2 commits into
Conversation
|
Right, a few different features are gated by more runtime information in the trie. This is not a feature we want to add until that is merged in (already being worked on), and it can also seriously regress on hand-coded order in its heuristics form, which is not acceptable for a non-gated solution. |
…mental conjunct_order feature
`Space::query_multi` hands the conjunction's arguments to the ProductZipper descent in the
order they were written, and that descent order IS the join order of a nested-loop join. A
body written star-first pays the star: `bench clique` writes its three-, four- and five-clique
bodies with every edge out of $x0 first and defers all the cross edges until every vertex is
bound. This is the shape you diagnosed by hand on chaining's bfc-xp -- 220M transitions for
20K unifications, fixed by switching the join order -- with nothing choosing the order.
The gain is asymptotic, not a factor, and `mork bench clique_scaling` is here so that is
checkable rather than asserted. It holds a random graph's average degree at 10, grows it, and
prints `transitions` per size; run it once on a default build and once with the feature:
four-clique |E| = 300 800 1300 2100 3400
written 111,162 316,778 537,529 892,230 1,492,176
planned 10,486 23,473 35,776 56,136 87,746
ratio 10.6x 13.5x 15.0x 15.9x 17.0x
five-clique |E| = 300 800 1300 2100 3400
written 911,195 2,894,603 5,024,101 8,653,338 15,064,427
planned 15,687 28,618 41,984 62,158 93,783
ratio 58.1x 101.1x 119.7x 139.2x 160.6x
Least squares over those five sizes puts the four-clique body's written order at |E|^1.07 and
the planned order at |E|^0.88; five-clique, |E|^1.15 against |E|^0.74. The ratio widens with
the input rather than settling. Answer counts are identical at every point; this is search the
descent no longer does.
REVIEW SHAPE. The routing is behind an experimental `conjunct_order` feature, default off, the
same shape as the leapfrog dispatch. Without the feature neither this module nor `leapfrog`
compiles, `query_multi_dispatch` calls `query_multi` exactly as it does today, and `cargo test`
collects the same zero tests it collects on main. Against 06cdcf3 the default build measures
within 0.015% on every one of the twelve benchmarks and 0.0005% on the suite total, with every
output identical, which is the whole of its claim.
One thing in the default build's path did have to change to keep that true. `query_multi_raw`
forwards to the two-source-list form by handing it the same slice twice, and the compiler cannot
prove two `&[ExprEnv]` alias, so it kept both pointers live across the descent's hot closure:
`bench transitive`, which is nothing but that closure, measured +0.033% on a build where neither
this module nor `leapfrog` is compiled at all. `query_multi_raw_sourced` takes the identity as a
const parameter instead, in the same shape as `MAY_REORDER`, so the `true` instantiation folds
the branch and the second pointer stops existing. That is the -0.005% row below.
THE MODEL. System R (Selinger et al., SIGMOD 1979), and it needs both halves of that paper's
model rather than only the famous one.
The cardinality half estimates how many partial matches reach each step, under uniformity and
independence: a variable's second and later occurrences are equalities that divide out one
domain apiece. The ACCESS PATH half estimates what a step then costs, and that is where a
byte-trie parts company with a relational store. A PathMap indexes prefixes only, so a factor
is entered by seeking its leading columns and scanning the rest: a variable bound in a leading
column makes the step selective, while the same variable bound in a later column only rejects
rows the descent has already walked. `leapfrog` meets the same fact from the other side --
`is_inverted` detects a factor whose bound variable is not in leading position, and pays to
re-index it into a private map.
Charging only the cardinality half is not a small error, and it is not a conservative one. On a
3,400-edge graph of average degree 10 the four-clique body's written order walks 1,538,745
transitions; cardinality alone prefers an order walking 4,346,487, which is 2.8x WORSE than
doing nothing, and the error grows with |V| because the conditional domain it reads is a degree
where the marginal is a vertex count. With the access-path term the same body walks 87,537.
The access path has a second edge here that a relational store does not have: A COLUMN CAN HOLD
A VARIABLE. A stored variable unifies with every value, so a query variable read out of such a
column may itself be a variable, and the factor entered next by seeking on it has no key to
seek and scans. This is the degradation first-argument indexing has in a WAM, where a clause
with a variable head argument belongs in every bucket and the index stops discriminating,
carried into the retrieval-as-join setting of Riazanov and Voronkov, "Efficient Instance
Retrieval with Standard and Relational Path Indexing", CADE-19, LNAI 2741, pages 380-396, 2003.
Leaving it out is not a bounded error either, which is why it is priced rather than ignored. Add
one `(root $r)` beside three ground rows of the same table and the four-conjunct body above is
planned into an order walking 27x the written order's transitions at 300 edges -- and 56x, 116x,
235x, 463x as the graph grows, so the loss widens with the input exactly as the win does.
`a_relation_holding_variables_does_not_mislead_the_planner` measures both sizes and fails on a
model that leaves this out; `differential/corpus/programs/plan_variable_column.mm2` pins the
answers.
The search is the same paper's subset dynamic program. Each step's cost depends only on the
subset before it and the factor appended, so it stays an exact minimisation over the model
rather than a heuristic over it, and it gives way to a greedy order on the same cost function
past twelve conjuncts.
WHERE THE NUMBERS COME FROM. There are no materialised relations and no collected histograms
here, so every count is enumerated off the live byte-trie when a body is first planned. That is
the part you said a satisfying solution needed and did not have. It is admitted in stages, each
refusing to spend where the answer is already known:
- a body whose reordering cannot change its asymptotics is refused from the body alone, with
no trie access at all: GYO reduction says it is alpha-acyclic, and no prefix of the written
order is disconnected from the factor that follows it;
- a measured body whose written order walks fewer rows than searching over its orders costs
keeps that order, on a floor that scales with the 2^n the search would visit;
- the wide reading, which unions a column's domain over several values of the column before
it, is bought only when the cheap reading leaves the decision inside a band where a better
measurement could still move it;
- the margin a plan must beat is derived from how much of the reading was cut short rather
than fixed, so an exactly-counted body is charged no scepticism;
- a run of refusals makes the planner skip a doubling run of bodies, which one acceptance
clears. `bench counter_machine` fires 1,813 transforms over 1,368 distinct bodies and
refuses every one;
- decisions are remembered in a fixed-size set-associative decaying table keyed by a bounded
prefix of the body, and a refusal is cached as an answer. Collisions are accepted: every
entry is a permutation and the factor count is checked, so a collision can cost a worse
order and never a wrong one.
Two ceilings bound one measurement. `COLUMN_CAP` bounds how many values a column is read for;
`COLUMN_BYTE_CAP` bounds how many BYTES that walk covers, which is what it actually spends,
since a cursor step walks the value's own bytes. `bench exponential_fringe` builds exponentially
nested terms whose widest column walked 129,896 bytes under the value ceiling alone.
APPLYING A PLAN re-encodes the factors into the planned order with their variables renumbered
to it, and descends those while unifying against the ORIGINAL factors, so bindings still come
out keyed in the pattern's own namespace and the template instantiation downstream is untouched.
That is what `query_multi_raw_with_unification_sources` is for. Nothing here re-derives a tag
byte or re-implements unification; the body decomposition, the trie cursor and the unifier are
`leapfrog`'s and `mork_expr`'s, and join order is orthogonal to all three.
Reordering changes which answer arrives first, so it is offered only through
`query_multi_planned`, which `query_multi_dispatch` takes -- the space-to-space transform's
entry, and already the point where the `leapfrog` feature substitutes a different join with a
different order. The sink and source transforms keep `query_multi`.
`bench clique_scaling` is the one addition outside the feature. It is a benchmark, not a code
path: `bench all` does not run it, and it exists so the exponents above can be reproduced
rather than taken on trust. Adding it hoisted `clique_query` and the graph construction out of
`bench_clique_no_unify`, which both now share; `bench clique`'s output is unchanged.
…taken on The numbers in the module header come from the tree its own commit records, which cannot name itself: writing the object ID into a tracked file changes the tree and so changes the ID. This is that commit, carrying nothing but the three references. Each placeholder sits alone on its line so this substitution cannot rewrap and shift a line number, which is the one thing a comment can change in the compiled output -- `assert!` and `unwrap` bake `file:line` in as static strings, and an earlier edit to this header moved the binary's hash exactly that way. Checked rather than assumed: the binary built from 5775c55 and the binary built from this commit are byte-identical, so the gate recorded against the parent is a gate against this tree too.
77db85c to
091f589
Compare
|
Rechecked this end to end before you got to it and found something I would rather hand you than What was wrongA column in a term store can hold a VARIABLE, and a relational cost model has no term for that. Put one
Not a constant, which by your own filter makes it something to fix rather than note. Answers were This is first-argument indexing degrading in a WAM, where a clause with a variable head argument The fix, and where it lives
Both consequences are applied where the stats are BUILT, not in the search: the seek path ends at What it also unlocked, which I did not expectThe change was aimed at removing a regression. It also made the planner FIRE where it had been
The residual, stated plainly
I tried charging that coarseness through the machinery already there -- counting a tainted column Two corrections to the evidence, not the codeThe asymptotic claim is now reproducible. It rested on a sweep that lived only in my notes, The first gate's small numbers were void and are re-measured. I built base in one worktree Redoing it that way surfaced one more thing worth having: Gate
Default build against 06cdcf3: max |delta| 0.015%, suite total -0.0005%, every output Only
The head of the branch builds BYTE-IDENTICAL binaries to the ones those numbers came from, in
|
|
Our comments crossed -- I pushed an update and posted the long version just after you wrote this, You are right that the heuristics form can seriously regress on hand-coded order, and it was worse Answers were correct the entire time, which is why nothing in the differential corpus caught it -- On the scheduling, understood and no argument from me. For what it is worth this one is gated: I will leave it sitting here rather than push on it. If the extra runtime information in the trie |
Plan the ProductZipper's factor order from the trie, behind an experimental conjunct_order feature
Space::query_multihands the conjunction's arguments to the ProductZipper descent in theorder they were written, and that descent order IS the join order of a nested-loop join. A
body written star-first pays the star:
bench cliquewrites its three-, four- and five-cliquebodies with every edge out of $x0 first and defers all the cross edges until every vertex is
bound. This is the shape you diagnosed by hand on chaining's bfc-xp -- 220M transitions for
20K unifications, fixed by switching the join order -- with nothing choosing the order.
The gain is asymptotic, not a factor, and
mork bench clique_scalingis here so that ischeckable rather than asserted. It holds a random graph's average degree at 10, grows it, and
prints
transitionsper size; run it once on a default build and once with the feature:four-clique |E| = 300 800 1300 2100 3400
written 111,162 316,778 537,529 892,230 1,492,176
planned 10,486 23,473 35,776 56,136 87,746
ratio 10.6x 13.5x 15.0x 15.9x 17.0x
five-clique |E| = 300 800 1300 2100 3400
written 911,195 2,894,603 5,024,101 8,653,338 15,064,427
planned 15,687 28,618 41,984 62,158 93,783
ratio 58.1x 101.1x 119.7x 139.2x 160.6x
Least squares over those five sizes puts the four-clique body's written order at |E|^1.07 and
the planned order at |E|^0.88; five-clique, |E|^1.15 against |E|^0.74. The ratio widens with
the input rather than settling. Answer counts are identical at every point; this is search the
descent no longer does.
REVIEW SHAPE. The routing is behind an experimental
conjunct_orderfeature, default off, thesame shape as the leapfrog dispatch. Without the feature neither this module nor
leapfrogcompiles,
query_multi_dispatchcallsquery_multiexactly as it does today, andcargo testcollects the same zero tests it collects on main. Against 06cdcf3 the default build measures
within 0.015% on every one of the twelve benchmarks and 0.0005% on the suite total, with every
output identical, which is the whole of its claim.
One thing in the default build's path did have to change to keep that true.
query_multi_rawforwards to the two-source-list form by handing it the same slice twice, and the compiler cannot
prove two
&[ExprEnv]alias, so it kept both pointers live across the descent's hot closure:bench transitive, which is nothing but that closure, measured +0.033% on a build where neitherthis module nor
leapfrogis compiled at all.query_multi_raw_sourcedtakes the identity as aconst parameter instead, in the same shape as
MAY_REORDER, so thetrueinstantiation foldsthe branch and the second pointer stops existing. That is the -0.005% row below.
THE MODEL. System R (Selinger et al., SIGMOD 1979), and it needs both halves of that paper's
model rather than only the famous one.
The cardinality half estimates how many partial matches reach each step, under uniformity and
independence: a variable's second and later occurrences are equalities that divide out one
domain apiece. The ACCESS PATH half estimates what a step then costs, and that is where a
byte-trie parts company with a relational store. A PathMap indexes prefixes only, so a factor
is entered by seeking its leading columns and scanning the rest: a variable bound in a leading
column makes the step selective, while the same variable bound in a later column only rejects
rows the descent has already walked.
leapfrogmeets the same fact from the other side --is_inverteddetects a factor whose bound variable is not in leading position, and pays tore-index it into a private map.
Charging only the cardinality half is not a small error, and it is not a conservative one. On a
3,400-edge graph of average degree 10 the four-clique body's written order walks 1,538,745
transitions; cardinality alone prefers an order walking 4,346,487, which is 2.8x WORSE than
doing nothing, and the error grows with |V| because the conditional domain it reads is a degree
where the marginal is a vertex count. With the access-path term the same body walks 87,537.
The access path has a second edge here that a relational store does not have: A COLUMN CAN HOLD
A VARIABLE. A stored variable unifies with every value, so a query variable read out of such a
column may itself be a variable, and the factor entered next by seeking on it has no key to
seek and scans. This is the degradation first-argument indexing has in a WAM, where a clause
with a variable head argument belongs in every bucket and the index stops discriminating,
carried into the retrieval-as-join setting of Riazanov and Voronkov, "Efficient Instance
Retrieval with Standard and Relational Path Indexing", CADE-19, LNAI 2741, pages 380-396, 2003.
Leaving it out is not a bounded error either, which is why it is priced rather than ignored. Add
one
(root $r)beside three ground rows of the same table and the four-conjunct body above isplanned into an order walking 27x the written order's transitions at 300 edges -- and 56x, 116x,
235x, 463x as the graph grows, so the loss widens with the input exactly as the win does.
a_relation_holding_variables_does_not_mislead_the_plannermeasures both sizes and fails on amodel that leaves this out;
differential/corpus/programs/plan_variable_column.mm2pins theanswers.
The search is the same paper's subset dynamic program. Each step's cost depends only on the
subset before it and the factor appended, so it stays an exact minimisation over the model
rather than a heuristic over it, and it gives way to a greedy order on the same cost function
past twelve conjuncts.
WHERE THE NUMBERS COME FROM. There are no materialised relations and no collected histograms
here, so every count is enumerated off the live byte-trie when a body is first planned. That is
the part you said a satisfying solution needed and did not have. It is admitted in stages, each
refusing to spend where the answer is already known:
no trie access at all: GYO reduction says it is alpha-acyclic, and no prefix of the written
order is disconnected from the factor that follows it;
keeps that order, on a floor that scales with the 2^n the search would visit;
it, is bought only when the cheap reading leaves the decision inside a band where a better
measurement could still move it;
than fixed, so an exactly-counted body is charged no scepticism;
clears.
bench counter_machinefires 1,813 transforms over 1,368 distinct bodies andrefuses every one;
prefix of the body, and a refusal is cached as an answer. Collisions are accepted: every
entry is a permutation and the factor count is checked, so a collision can cost a worse
order and never a wrong one.
Two ceilings bound one measurement.
COLUMN_CAPbounds how many values a column is read for;COLUMN_BYTE_CAPbounds how many BYTES that walk covers, which is what it actually spends,since a cursor step walks the value's own bytes.
bench exponential_fringebuilds exponentiallynested terms whose widest column walked 129,896 bytes under the value ceiling alone.
APPLYING A PLAN re-encodes the factors into the planned order with their variables renumbered
to it, and descends those while unifying against the ORIGINAL factors, so bindings still come
out keyed in the pattern's own namespace and the template instantiation downstream is untouched.
That is what
query_multi_raw_with_unification_sourcesis for. Nothing here re-derives a tagbyte or re-implements unification; the body decomposition, the trie cursor and the unifier are
leapfrog's andmork_expr's, and join order is orthogonal to all three.Reordering changes which answer arrives first, so it is offered only through
query_multi_planned, whichquery_multi_dispatchtakes -- the space-to-space transform'sentry, and already the point where the
leapfrogfeature substitutes a different join with adifferent order. The sink and source transforms keep
query_multi.bench clique_scalingis the one addition outside the feature. It is a benchmark, not a codepath:
bench alldoes not run it, and it exists so the exponents above can be reproducedrather than taken on trust. Adding it hoisted
clique_queryand the graph construction out ofbench_clique_no_unify, which both now share;bench clique's output is unchanged.