Parsing Large Collection Literals in Parallel

A plan for compiling a large collection literal on more than one core, in datalit and in datafun alike: split it at the token tree into chunks, parse the chunks as queries of their own on rayon, and do it only where a tuning bench says it pays. Written October 2026, after a prototype for datalit lists. Phases 0 to 2 and 5 are done, phase 3 was measured and set aside, and phase 4 was built, measured and taken out again: datalit is parallel within a collection and datafun is not; see Progress.

Contents

The problem

DATALOVE_PARALLEL spreads the compile across modules, so a program whose work is one big literal gets nothing from it. The store demo is that program: it compiles for about 205ms, almost all of it its 4MB of data, 20,000 orders in one list in orders.dlt, and the parallel compile is slower than the sequential one (225ms; see On real programs).

But a big literal is almost always a collection of independent elements, and that is the shape that splits. What makes this a tuning problem rather than a transformation is that it rarely pays: most collections are a handful of elements, splitting one costs a database clone, a query and a rayon task per chunk, and work on a worker thread costs more than the same work on the main one (see Parallel compilation). So it has to be off below a size that is measured, per kind of collection, rather than guessed.

What the prototype showed

A prototype for a datalit file whose top level is one list (since reverted; the design below is its generalization) took the store demo's compile from 218ms to 154ms on four cores, and from 152ms to 115ms with huge pages, with the report unchanged. Chunking cost the sequential compile a few ms at most. The parse went about 2.3x on four threads rather than 4x, the allocator again.

From a cold sequential profile of the store demo, what the data costs, of a 670ms process:

Step Query Cost
Lexing bcts::lexer::lex about 22ms
Bracket matching bcts::bracer::bracer a few ms
Parsing datalit::parser::parse about 113ms
Typing against the declared type type_check_with_expected about 20ms
Reading values for the IR data_file_value (Reader::read) about 52ms

With the parse spread out, the main thread's compile is mostly the reader (12% of the run's samples) and the lexer (5%).

Scope

Collections only, and only the outermost one that qualifies. A collection is what a bracket of these kinds opens, in either language:

Sigil Collection Separator
[ list ,
%{ map , between entries
#{ set ,
{| table ; or a newline between rows, after a header row
[| tensor runs of ,, one fewer than the rank; none at rank 1

Structs and tuples are left out: their fields are named or positional and few, and splitting them buys nothing. A qualifying collection nested in one already chosen is parsed inside its chunk, whole. Tables need their header in every chunk, since a row is read against it. Tensors come last: a chunk boundary has to fall on a separator of the highest rank, and a rank-1 tensor has no separator at all, its elements being written side by side.

The parse first, in both languages; what follows it only in datalit. In datalit the typing and the reading split the same way and are where the time goes after the parse (After the parse). In datafun a big literal goes on to typecheck, ownership and lowering, all of which would want the same treatment; that waits for a real program that needs it, since data that big belongs in a .dlt.

The design

Finding the collections

The bracer already says where every bracket ends, so a level of the token tree is walked without looking inside its brackets: Cursor::next_index steps over a whole {...} in one move. So finding the candidates and their chunk boundaries is a pass over the token layout, before any parse, and it is the same pass for both languages. It goes in bcts, next to the bracer:

The plan cannot know the grammar: a [ may be a list, or a type, or an index. The parser decides, by consulting the plan only where it is about to parse a collection literal, by the open bracket's token index. A planned bracket that turns out not to be a literal is parsed as it is today, and the chunks warmed for it are wasted; the thresholds make that rare. The cost of the plan pass itself goes in the bench, since every source pays it.

The policy

ChunkPolicy is a salsa input, one per database, that the pipeline sets:

Being an input, changing it invalidates the plans and nothing else, and the parse queries do not need it passed down to them: parse(source) keeps its signature, and every caller of it -- data_file_type, data_file_value, datalit_spans -- is unchanged. A test or a CI configuration that wants every collection chunked sets a policy with tiny thresholds.

Chunks in datalit

As prototyped. parse_chunk(source, open, chunk) is a tracked query that runs the parser a list already makes for its contents (sub_parser_from_branch) over inner.between(from, to) and returns the chunk's elements and spans. The parser, at a planned collection, assembles the elements out of the chunks' memos in order.

The numbering needs no renumbering. Every ExprFull has a number from a counter shared down the parse, and the number is in its hash, but it is only ever a key: DatalitSpans::lookup finds a span by it and nothing indexes by it. So each chunk numbers its expressions in a range of its own, above the whole parse's, and they never collide. Whether to number every parse that way, so that the same text numbers the same whether or not it was chunked, is an open question.

Chunks in datafun

The same plan, the same thresholds and the same warm-up, but a harder merge, because datafun numbers densely. A body's expressions are an arena (ExprArena.exprs, children before parents) indexed by ExprId, and later phases keep per-expression tables as vectors in the same order (BodyTable). Calls are numbered the same way, and statements have counters of their own.

So a datafun chunk parses into an arena of its own numbered from zero, and the merge relocates it: appends its expressions to the body's arena and adds the offset to every ExprId they hold, to the keys of its spans, and to its call numbers. That wants a map_ids over ExprFunKind that matches every variant exhaustively, so that a new variant cannot be forgotten by it. Relocating is a pass over what the chunk made, which the bench measures against the parse it replaces.

A chunk whose elements use anything numbered per body other than expressions and calls -- a statement counter, which a block or a function literal in an element would -- says so, and the parser parses that collection inline instead. That keeps the merge to two kinds of number, at the cost of chunking less; how often it happens is something to count before doing more.

A chunk parser is built with the parser state a collection's elements are parsed with: the module and function they belong to, which only feed keys the merge rewrites, and the parser's mode. Whether anything else in Parser (branch_context, the script counters) changes how an element parses rather than what it reports is to check in phase 4; if it does, it is part of the chunk's key.

Running the chunks

A chunk is a tracked query, and the parallel part runs them ahead of time on database clones, outside any query, the pattern every phase already uses (see the compiler guide's Parallel Execution). The parse that reaches a planned collection then finds every chunk in its memo. This keeps diagnostics and dependencies where salsa tracks them, keeps parallelism out of queries, as upstream salsa asks, and on wasm, where rayon runs the warm-up inline, it is a sequential parse in pieces -- which the policy turns off anyway.

The warm-up is shared, in the compiler crate, and runs in two places:

After the parse

In datalit, the two passes after the parse are queries over the whole list, and with the parse spread out they are most of what is left:

Lexing does not split at the token tree, since the tree is built from the tokens. Splitting the text at line starts would work only where a piece cannot begin inside a string or a comment, which the lexer cannot yet say. It is 22ms of the store demo and left alone here.

Proving it changes nothing

The existing tests do not exercise any of this: their collections are all below any sensible threshold. So equivalence is a suite of its own, run with a policy that chunks everything:

And the whole suite run once with that policy, as just test-parallel runs it with DATALOVE_PARALLEL, so that a parse a fixture never meant to chunk is compared too. The store demo's just check under it is the end-to-end test.

The cutoff bench

The thresholds are numbers the bench produces, not ones the plan picks. It goes in datalove-bench, beside par_phases, as collection_cutoffs:

What it reports, per kind, stage and thread count: the smallest size at which the best chunked configuration beats the baseline by a margin (5% to start), the chunk size that gives that best, and the overhead of the plan pass on a source with no collection worth chunking. Those are the policy's defaults. The results go in compile-time.md when they are taken, so that retuning after a change to the parser or the allocator is a diff of a table.

Phases

  1. The bench, before any chunking. Generators, the baseline of every kind, stage and size, and the plan pass on its own. This says what a byte of each kind costs to parse, type and read, and whether anything but big datalit lists is worth going on to.
  2. The plan and the policy in bcts, and datalit lists: the chunked parse as prototyped, the policy input, the equivalence suite for lists, and the bench's chunked configurations for them. Set the list thresholds.
  3. The other datalit collections: maps, sets and tables, then tensors.
  4. Datalit's reading and typing in chunks, warmed where they are asked for.
  5. Datafun: map_ids, the relocating merge, the fallback for chunks that number statements, the module-source warm-up, and the equivalence suite for datafun. Only if phase 0 says a datafun literal big enough to matter is something anyone writes.
  6. Tuning and CI: thresholds from the bench for every kind built, and a CI job that runs the suite with everything chunked.

Each phase pays only under DATALOVE_PARALLEL, which is not yet the default; the allocator question (Page faults) decides how much each one is worth.

Progress

Phase 0: collection_cutoffs in datalove-bench, and its baseline in compile-time.md. The parse is 59-98% of every case in both languages, and it costs more per byte as the collection grows, from interning every number.

Phase 1: bct::chunk (the plan, the policy, the thresholds), datalit::parser::parse_chunk and Parser::parse_planned in crates/datalove-datalit/src/parser/chunk.rs, the warm-up for data files (data_chunks and its warm in compile_modules, crates/datalove-datafun-compiler/src/compile.rs), and lists, maps and sets in datalit; crates/datalove-datalit/tests/chunk_tests.rs is the equivalence suite. The defaults are measured, and the store demo compiles in 138ms parallel, from 225ms. Where it came out other than as designed above:

Phase 2: tables and tensors in datalit, with their own cases in chunk_tests. A table's chunks are runs of rows, cut where split::Lines would split them, so a boundary cannot be taken by a mistake the way a comma can, and needs no fallback; a chunk reads the header's column count from the plan's record of where the header is, and numbers its rows from the plan's first. A tensor's chunks are runs of groups at its outermost axis, cut where split::split_commas would; its rank comes from the plan, since a chunk's own commas may not show it. A tensor chunk keeps its delimiter complaints apart (prelude), which a whole parse makes for every group before parsing any, and marks where each group's diagnostics end, so that the complaint about a group of the wrong shape, which only the parse putting the chunks together can make against the first group's, goes where a whole parse would put it. A table's or tensor's delimiter complaints set had_error on the parser that reached it, so a chunk's carries back there. A rank-1 tensor is not cut. The thresholds differ from lists'; see compile-time.md.

Phase 3 is not done, because neither half pays as things are.

Phase 4 was built and taken out. Datafun lists, maps and sets were cut and parsed number for number as a whole parse would (a chunk numbered its expressions and calls from zero and was moved up into its body by a relocate over every ExprFunKind), with an equivalence suite and a warm-up in the dependency walk. It worked, and was not worth keeping:

So data that big goes in a data file, which is what datalit is for: it is parsed in chunks, read into values rather than lowered and evaluated, and split at the token tree without knowing the grammar around it. The design for datafun above is kept as the record of what was tried; the code is not.

Phase 5: the thresholds for everything cut are from the bench and in compile-time.md, and just test-cut, CI's fifth job, runs the suite with every collection cut after every element, in parallel compiles.

Open questions