Repository navigation
Conversation
SPARQL reads a stored list as its owner edge `s p head` and its cells (#2159). A pattern `?s <p> ?o` on a predicate holding lists, read with no input, was a member-hiding scan followed by a read of every list of the predicate: the scan walked the whole predicate when its rows were list members, then `read_lists_in` read it again as flakes and emitted every list triple in one batch. A LIMIT stopped neither. Such a pattern's list triples are exactly the owner edges, so the scan now emits them itself (`BinaryScanOperator::listing_owners`): where it skips a member it emits the list's owner edge, once per list, naming the head cell through the request's list view. The planner builds that scan (`DatasetOperator::scan_listing_owners`) in place of the hiding scan and the list augmentation when the pattern has no input, and a WHERE that opens on a list-view triple no longer gets an empty seed, so it has none. Patterns on `rdf:first`/`rdf:rest`, a variable predicate, and list-view triples joined to an input keep the existing path.
The one-pass scan named each list's head cell through its owner's terms, resolving every owner's subject through the dictionary and hashing the owner's strings into the request's list table: about 9k instructions and seven allocations a list, most of a full read's cost. The list table now also names a list by `(graph, s_id, p_id)`. A scan over persisted ids (no novelty) names the lists it meets that way and keeps the subject encoded, as it binds stored rows; a cell is resolved to its owner's terms only when it is decoded. Once a plan holds such a scan, every list named by its terms that the index holds is entered under its ids too, so a list has one name however a request meets it. A scan over novelty resolves subjects as before.
Every case is asked twice: as written, where its first list-view triple
has no input and is read in one pass, and after `VALUES ?z { 1 }
FILTER(?z = 1)`, which gives every list-view triple an input and so the
tee and the read of each list. The plans confirm each reading took its
path. The ledger mixes lists with stored values, holds two lists on one
subject, a list in a named graph and a predicate only novelty holds,
in three states (novelty only, indexed, indexed with novelty), read over
its default graph, a named graph and their union, with fast paths on and
off, plus rows by hand. A LIMIT 5 over 16k and over 48k lists costs the
same fuel, under half the full read's.
…h fills A cursor batch is a whole leaflet, and the scan converted all of it before returning, naming every list owner the leaflet held even when a LIMIT needed five rows that stored values had already given. Over a predicate mixing stored values with lists, that put a LIMIT 5 at 1.19x main. A scan listing owners now stops converting at the first owner edge past its batch budget, keeps the rest of the leaflet, and resumes there on the next batch. The check sits in the member branch only, so a scan of anything else converts as before.
…ir labels With no subject, a read of the lists walked the view's list predicates in the order of a `HashSet`, whose hasher differs per request, so lists on two predicates could swap their cell labels between two runs of one query. The predicates are now read sorted.
Cell labels in the one-pass reading: one label per cell within a result (an owner edge joined to its cells, and a UNION holding a head twice), the same labels on every run, and labels apart from stored blank nodes and BNODE()'s, over novelty and the index. Where a scan meets lists in subject order, the labels equal the reading with an input's exactly; grouping by the object compares up to renaming. A predicate mixing stored values with lists across batches answers as with an input, and a LIMIT over it reads well under the full read's fuel.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
#2159's list view (an owner edge
s p headplus therdf:first/rdf:restcells, with the members no longer values of their predicate) is the right model for SPARQL, and nothing here changes what it answers. The cost was in how one common shape gets read: a SPARQL?s <p> ?oon a predicate that holds lists, with no input, was planned as a member-hiding scan followed by a read of every list on the predicate. For a predicate whose rows are mostly list members that's two full reads, and aLIMITstopped neither of them:next_batchwalks the whole predicate and returns nothing (binary_scan.rs, thehide_members && o_i != u32::MAXskip);read_lists_inreads the predicate again throughrange_with_overlayas flakes, groups the members by owner, andlist_rowsemits every list triple in one batch.So
SELECT ?s ?t WHERE { ?s ex:tags ?t } LIMIT 5cost exactly what the unlimited query did, and both grew with the ledger: about 22× a list-free ledger at 300k subjects for theLIMIT, and 9.4× for the full read.What changed
One pass. For that shape the list triples are exactly the owner edges, so the scan emits them itself. Where the hiding scan skips a member,
BinaryScanOperator::listing_ownersinstead emits that list's owner edge, once per list, naming the head cell through the request'sListView. The planner builds that scan (DatasetOperator::scan_listing_owners) in place of the hiding scan plusListAugmentOperatorwhen the pattern has no input (list_view::owner_edges_answer:?s <p> ?o, two distinct variables,<p>notrdf:first/rdf:rest, no datatype constraint). Everything the scan's row loop does differently sits inside the existing member-skip branch, which is only taken in leaflets that hold list rows, so a scan of anything else runs the same code it did.Stopping at the batch. A cursor batch is a whole leaflet, and the scan converts all of it before returning, so a scan listing owners would name every owner the leaflet held even when a
LIMITneeded five rows. It now stops at the first owner edge past its batch budget, keeps the rest of the leaflet, and resumes there on the next batch (again only in the member branch).Naming lists by id. Resolving every owner's subject through the dictionary and hashing its strings into the list table was most of the remaining cost (about 9k instructions and seven allocations per list). The list table now also names a list by
(graph, s_id, p_id), and a scan over persisted ids (no novelty) names the lists it meets that way, keeping the subject encoded the way it binds stored rows; a cell is resolved back to its owner's terms only if something decodes it (e.g. a following?l rdf:first ?x). Once a plan holds such a scan, a list named by its terms elsewhere in the request is entered under its ids too, so a list gets one name however it's met. With novelty present the scan resolves subjects as before.Planner precondition.
needs_empty_seednow admitsPattern::ListViewalongsideTriple/Values/Bind. Without that, a WHERE that opens on a list-view triple got anEmptyOperatorseed, so its first pattern was planned as a join and never took the one-pass path. This also gives?s ?p ?oon a list-bearing ledger its first scan back.List predicates in one order. Not introduced here, but the label tests turned it up: with no subject,
read_lists_inwalked the view's list predicates in aHashSet's order, whose hasher differs per request, so lists on two predicates could swap labels between two runs of the same query (?c rdf:first ?x, say). It now reads them sorted.Not changed: patterns on
rdf:first/rdf:rest, a variable predicate, and list-view triples joined to an input keep #2159's tee-and-augment path.Answers
The new tests ask every case twice: as written, where the first list-view triple has no input and is read in one pass, and after
VALUES ?z { 1 } FILTER(?z = 1), which gives every list-view triple an input and so routes it through the existing tee and list read in the same binary. The plans are checked too, so a case can't pass by comparing a plan with itself. The ledger mixes lists with stored values on one predicate, has two lists on one subject, a list in a named graph, a stored blank node and a predicate only novelty holds; it's read in three states (novelty only, indexed, indexed with novelty), over the default graph, a named graph and their union, inGRAPH ?g, with fast paths on and off. A second ledger mixes 6k stored values with 2k lists on one predicate, so the scan stops and resumes leaflets mid-way. The W3C suite (testsuite-sparql) passes unchanged.Cell labels are numbered in the order a request reads its lists, so a
GROUP BY ?o(which reads the predicate in object order) may number them differently from main; it's the same RDF up to a consistent renaming of the cells. Everywhere the scan reads in subject order, the tests hold the labels to exactly the ones the list read gives. Within one result a cell has one label wherever it appears (the owner edge's object, and the cell's ownrdf:first/rdf:restrows), the same query gives the same labels on every run, and the labels stay in the list view's own_:l…space, apart from stored blank nodes (_:fdb-…) andBNODE()'s.Numbers
Instructions per query (release,
proc_pid_rusage), main42bb9646cagainst this branch, on a ledger where every tenth subject owns a two-itemex:tagslist and a two-itemex:mixedlist (the others hold one plainex:mixedvalue), beside a list-free ledger holding the same values as plain multi-values. Medians, three interleaved passes at 3k/30k, one at 300k.?s ex:tags ?t LIMIT 5?s ex:tags ?t… ORDER BY ?s LIMIT 5?s ex:mixed ?m?s ex:mixed ?m LIMIT 5?s ?p ?oAllocations drop from about 12 per list to about one (the cell's name), and a
LIMITover a list predicate now costs one batch whatever the ledger's size. Shapes that don't touch lists (non-list predicates, joins,COUNT(*), GROUP BY, plain?s ?p ?o, and JSON-LD on both ledgers) stay within −1.7% to +0.4% of main, with identical allocations.Tests
it_sparql_list_single_pass(own binary, since it flips the fast-path switch):one_pass_answers_as_the_list_view_with_an_input— the differential and plan checks above (labels compared exactly where the read is in subject order), including one list met twice in a request (?s ex:items ?o . ?s2 ex:items ?o, where the second pattern reads the lists by their terms and the first names them by id, so the head cells only join if both give a list one name);cell_labels_are_one_name_per_cell_and_stable— an owner edge joined to its first cell, a UNION holding each head twice, six repeat runs, and labels apart from stored blank nodes andBNODE();a_large_mixed_predicate_answers_as_with_an_input— the mixed ledger across batches, as with an input, labels included, andLIMIT 5well under the full read's fuel;one_pass_answers_by_hand— owner edges beside stored values, by hand;a_limit_over_lists_reads_one_batch—LIMIT 5fuel over 16k and 48k lists is the same, and under half the full read's.On the branch, each of these changes is caught when reverted: dropping the id alias in
ListView::cellloses rows in the one-list-met-twice case; droppingListViewfromneeds_empty_seedfails the plan and cost checks; dropping the batch stop puts the mixedLIMIT 5at the full read's fuel; and reading the predicates inHashSetorder changes labels between runs.Since this reshapes how the list view gets read, I'd especially value a close look at the id-keyed list table (
ListView::scanned_head/name_lists_by_idand the alias incell) and the batch stop inbatch_to_bindings, and on whether there's a shape I'm missing where a scan's owner edge and the old list read could disagree. Happy to talk through any of it.Fwiw,
it_transact_update_indexed::a_multi_op_update_ends_where_its_last_op_leaves_a_datetime_held_in_noveltyfailed twice under heavy load with a WAL append ENOENT increate_ledger(2 of 5 full runs here, 0 of 5 on main), then not at all in 20 more full-suite rounds of each run side by side, so it's not attributed to this change or to main.