Repository navigation
perf(query): every group by ahead of DuckDB, next to nothing allocated once warm - #57
Merged
Merged
Conversation
Each round line now carries the process's processor time, instructions and cycles over the round, on every thread, read with proc_pid_rusage as a user (macOS; zero elsewhere): a round whose time moves while its cycles do not was slowed by the machine, not by the code. The counters are read outside the timed span, without allocating. VORTICITY_RUNNER_WARM=1 keeps the session's default of 64 mappings instead of mapping the file anew at each round: the file already in memory, the base a comparison against a table loaded beforehand takes. On 2026-10-09 a count and sum by 10^3 keys at fourteen lanes read 14.5 ms with a new mapping a round and 7.1 kept: its 33 000 page faults cost five times more in the kernel at fourteen lanes than at one.
…sh alt A Mac always runs other processes beside the bench, and a few fixed pairs could not tell a change from them. alt now runs pairs whose order alternates (ABBA) until the 95 % interval of the median ratio, a bootstrap of the pairs, is within ±3 % (three pairs at least, eight at most); it frames each scenario with two canaries of the before runner (a scan of one lane and a group by on fourteen), against the session's first, and runs a scenario again once when a canary is 5 % off or swap grew; it holds a scenario back while another process takes more than half a core, and notes those above a fifth; it runs a run again when more than one round in five passes 1.5 times its median. The table prints the interval, the pairs, the ratio of the fastest rounds (a gap of 10 % from the median marks the scenario noisy) and the ratio of the rounds' cycles; runs.tsv keeps every run, canary and control with its time of day. --warm keeps each side's file mapped. Nothing needs sudo. Two identical runners, q4 over 10^7 rows: x0.997 [0.990-1.001] at one lane and x1.006 at fourteen, three pairs each. duckdb.sh --only keeps the rows it names, by the runner's scenario without "group-", so that a change looks at DuckDB on its own rows in a minute.
vortex-queries --micro words hands ShortTextKeys.Assign batches of the canonical views a decoder makes of texts of 5, 8 and 12 bytes, a hundred to a hundred thousand of them drawn at random, as --micro keys does for integers: what a change to the words does, in seconds, with no file, decode or aggregate. Run it under DOTNET_TieredCompilation=0.
Four costs of the first pass over a block of short texts, seen in the disassembly of KeyTable<TextWord>.FindAtHome and in the profile of db-benchmark's q1 (a hundred texts of 5 bytes), where the key took 81 % of the cycles against DuckDB's table: - the seed rapidhash mixes was prepared at every row, a test and four instructions of a constant the native compiler left in the loop: it is prepared once a batch (TextWord.HomePrepared), in KeyTable and WideKeyTable; - TextWord.Equals compared the low words then the high ones with &&, compiled to a branch, mispredicted at every key not in its home slot: both words are compared at once; - a key whose home its neighbour took sent each of its rows to the search, which hashed it again: one row in five, a sixth of q1's cycles. A line of KeyTable holds two slots of a 16-byte key, and the first pass now compares both, the group taken by masks; - FillWords copied each view to a word with two branches and the validity's test a row: a block with no null now copies them with no branch, the bytes past a value zeroed by a mask its length picks (BytesBlock.TryWords), the longest length judged once. --micro words under DOTNET_TieredCompilation=0, best of three: a hundred texts of 5 bytes 8.06 -> 4.70 ns a row (x0.58), a hundred of 12 bytes 7.21 -> 5.14 (x0.71), ten thousand of 8 bytes 6.64 -> 5.89 (x0.89), a hundred thousand of 12 bytes 7.81 -> 7.27 (x0.93).
…les a quarter full Two more costs of the first pass over short texts: - the slot of a word was its rapidhash modulo the prime length by the runtime's fast modulo, two multiplications; it is now the high half of the hash times the length (Lemire's fast range), one, in KeyTable and WideKeyTable. Integers keep the modulo, which lays keys in a row in slots in a row; - six tenths full, a hundred texts laid a tenth of their keys in the chains past a full line, each of their rows sent to the search: a short text's table of 512 slots or fewer (128 keys) now fills to a quarter. Past it, the table grows by the same primes and lands on the length six tenths would have given it. Up to 8 192 slots, a stream of sorted names grew its table once more after its first read, where its reads allocate nothing (AllocationContractTests); and a table cleared to keep some groups, a stream's or a cache's, fills to six tenths from then on. --micro words under DOTNET_TieredCompilation=0, against the previous commit: a hundred texts of 5 bytes 4.70 -> 3.89 ns a row (x0.83), a hundred of 12 bytes 5.14 -> 3.89-3.96 (x0.76), ten thousand of 8 bytes 5.89 -> 5.23-5.29 (x0.89), a hundred thousand of 12 bytes 7.27 -> 6.38-6.42 (x0.88). A view a vector in TryWords, its mask made of the tables' two halves, ran 5 % slower than two words a view, and was not kept; nor was a first pass of WideKeyTable that compared the slot after the home too (ten thousand texts x1.17, a hundred thousand x1.06). The work counters of "count by a name, each once then all again" come down: 70 arrays of 11 901 724 bytes instead of 72 of 11 905 852.
runners.sh alt ran a scenario again whenever a canary that framed it was more than 5 % off its standard. Pairs share whatever the machine does while they run: a scenario whose pairs held the target already says the perturbation did not reach the ratio. Under a video call that kept every canary off, replaying them all doubled a session. A canary off now asks for a replay only when the pairs stopped at the most without holding the target; swap that grew still asks for one.
FixedKeys numbers an integer key by its value when the statistics bound it, through pages of 4 096 numbers. Each row read its page from the array of pages, then the page's start, then its number: a chain of three loads a row, the first two the same for every row of a span of one page, which is every key of a few thousand values (k3, db-benchmark q4 and q5). DirectTwoPasses now takes the page and its start once a chunk when the span has one page, and reads the number from them. --micro keys under DOTNET_TieredCompilation=0, best of five, twice, twenty million keys numbered by their bounds: a hundred values 16.4-16.6 -> 11.8-12.6 ms (x0.73), a thousand 16.5 -> 11.4-11.5 ms (x0.70); a million, many pages, 29.4-29.7 -> 29.8-30.4 ms, unchanged. The micro gains the two cases.
…ts value less the least An integer key the statistics bound to a span is numbered by its value through pages from the value's number to its group, a group given as each value first comes: every row of k3 (a thousand values) read its group from the page, a dependent load, and a third of its cycles went there. A lane's key now numbers its span whole when the span's records fit the private cache (FixedKeys.WholeBytes, 1 MiB with the keys and the numbers): each value of the span is a group from the first row, its number, met or not. The pages tell the values met from the others, a value met for the first time taking its number where it took the next group; once every value is met, the first pass of a chunk is the value less the least, in vectors for keys of four bytes, a value past the bounds left for the lookup as before (FixedKeys.Numbers). The groups no row met are dropped, keys and records kept in order, before anyone else reads the lane's groups: at the end of its lane, or when it turns to the core or writes its table to the scratch (AggregationPartition.DropUnmet). A spill test that left the drop out of EvictAsync failed. Never with a top, the core, a key that streams, a distinct query's or the core's partitions; and only when every slot keeps its state in the records: a keep moves a distinct count's pairs to arrays beside the old, and its peak at one lane went from 29 to 47 MB by a thousand values three in ten unmet. runners.sh alt against the runner of 8d4a53c, warm, on a busy Mac (a video call): group-total-k3 on 20M rows x0.559 at one lane (29.6 -> 16.8 ms) and x0.628 at fourteen (5.0 -> 3.1 ms); group-db-q4 x0.872 and x0.920, its exact float mean left to D3; group-total-k4 x0.733; tenfold x0.992 and k5 x1.026 (0.96-1.11, cycles x0.988), unchanged past the span. The work counters of "count and sum by 10^3 values" come down: 3 arrays of 36 824 bytes instead of 8 of 40 888.
The profile of db-benchmark's q4 at one lane, its key numbered whole, showed NarrowSignedSum<int>.Add apart from the loop that called it: 19 % of the cycles in a function of two statements, a call a row and a value. The native compiler, with no profile to guide it, leaves out of line an add whose conversion (long.CreateTruncating) checks its type in its IL; a total of one statement it inlines. The adds of the four sums that carry a count, the integer means' states, are now marked AggressiveInlining. runners.sh alt against the runner of the previous commit, warm, on a busy Mac: group-db-q4 at one lane x0.897 (46.4 -> 41.6 ms), cycles x0.920.
…pass db-benchmark's q4 takes the means of V1 and V2, two integer columns, and q5 their sums: two slots of the same type, their states in the same record, each folding the window in a pass of its own, its group read and its record reached again. A slot now has a twin, the next slot of its type, both unfiltered, their states in the partition's records (AggregationPartition.TwinOf), and FixedSlot.StepRowsPaired folds both in one pass: a row's group read once, its record reached once, each value read from its column. A batch with nulls on either side, a column coded or constant, or a selection folds them apart as before. PairedSlotsTests checks sums and means of integers against LINQ, nulls in half the batches, a coded column and a filtered twin, at one and four lanes; it fails when the twin's add is left out. runners.sh alt against the runner of the previous commit, warm, on a busy Mac (load 14): group-db-q4 at one lane x0.895 (42.1 -> 37.7 ms), group-db-q5 x0.923 (79.4 -> 73.0 ms). The plan gave the pass up below a tenth on q4: it holds it, just.
…micro floatsum The two tools the grouped exact float sum is changed by, before it is. GroupedFloatSumTests holds a group by's float sums and means to the bits an IndexedSum gives each group's values one at a time, in the order of the rows, at one, four and fourteen lanes, the rows cut into small blocks: groups of values of one range beside groups of tiny, subnormal and huge values, of zeros of both signs, of values that grow along the rows, of NaN and infinities; and a key that opens a group at every row. vortex-queries --micro floatsum folds a column of doubles, batch after batch, into the sums of their groups by the slot a sum or a mean of doubles takes, the groups given, in memory, and prints a checksum of every group's bits: q4's hundred groups, q3's hundred thousand, a hundred groups of ranges of their own, and q10's group a row. Under DOTNET_TieredCompilation=0 today: 2.6, 4.0, 2.6 and 5.0 ns a row.
A row of a grouped exact float sum went through IndexedSum.Add: the group's top read from its state, the limit and the three extractors read by it from their tables, then the value split by seven additions into the bins. The split waited on the group's state, and a row whose group a row still in flight held waited for that chain whole: 2.6 ns a row at a hundred groups (--micro floatsum), 60 % of db-benchmark's q4. IndexedSum.Fold now splits a window's rows at the top of its first row's group, or of its first value when the group holds none yet, the top's limit and extractors held in registers. A row whose group sits at that top, its count under the endurance and its value under the limit, the two read at once (the group's top and count less the top's, unsigned), is split with no wait on the group: only its count and its three bins are. A group that holds no value yet takes its first at the top the value calls for, with no bin to move, as AddSlow does. Any other row, a NaN, an infinity or a value past the limit among them, goes through Add. A window where more than a quarter of the rows did sends the next eight through Add straight (FixedSlot._ownTops): a hundred groups of ranges of their own ran x1.13 with the branch between the two paths taken at random, and x1.03 with the windows left to Add. The top a magnitude calls for is now read off its exponent's bits, or for a subnormal off its highest bit, where Math.ILogB was a call (IndexedSum.Needed, which a test holds to ILogB's for every binade). The parts are those Add gives: GroupedFloatSumTests now adds a column whose groups sit at tops of their own, a third of the rows at a time, and sums it by a key that opens a group a row; with the first value of a group taken at the window's top rather than its own, it fails at every degree. --micro floatsum under DOTNET_TieredCompilation=0, checksums unchanged: a hundred groups 2.61 -> 1.46 ns a row, a hundred thousand 4.01 -> 2.21, a group a row 5.02 -> 2.90, a hundred groups of ranges of their own 2.62 -> 2.75. runners.sh alt against the runner of 8be9f89, warm, at one lane: group-db-q4 x0.668 (36.5 -> 24.4 ms), group-db-q3 x0.855 (127.1 -> 109.0 ms), group-mean-k6 x0.881 (88.6 -> 77.9 ms).
…the count A count rode on a fixed slot's pass when both folded a window into one record, the float sum's among them: its rows then went through IndexedSum.Add a value at a time, never through the split held to one top. group-mean-k6, a count and a float's mean by 10^6 keys, carried its count so through its first batches, 6.6 % of its cycles. A slot of an exact float sum no longer carries it: the count folds first, alone, as it does past the cache, and the sum's own pass splits its values at one top. runners.sh alt against the runner of the previous commit, warm, at one lane: group-mean-k6 x0.973 (80.0 -> 77.8 ms); group-db-q10 x1.006 (0.97-1.03) and group-db-q4 x0.994, unchanged. --micro floatsum now prints what reading every group's sum out costs, as the result's batches read them (FixedSlot.Results): 10.7 ns a group at a hundred thousand groups, 4.3 at a group a row. Building the double from its bits rather than through Math.ScaleB did not change it.
…ide by side At fourteen lanes the core holds a key of ten million values (k7) in sub-tables of hashes: a key hashed into its part, its sub-table, its slot, the tables grown and split, half the cycles for a key whose value is its number. A key the statistics bound to a span no wider than the rows, whose pages its values fill, is now paged (GroupCore.Paged): the span cut into pages of 256 values, a page in the part the hash of its number names (LaneCore.PartOf, the same for every lane), a part's pages side by side in one sub-table of FixedKeys numbered whole (GroupKeys.ForPages): an entry's group is its page's place among the part's, then its place in the page, read off its value with no hash, no probe, no growth and no split (FixedKeys.PagedGroupsOf). A bit a value tells those an entry met: a table of a group a value, 156 KB a part where its bits take 5, cost a miss to memory an entry as the parts went from core to core, 30 % of the cycles. The values no entry met are dropped before anything reads a part's groups: at the end, before a part is read as one, and before its groups go to the scratch (SubTable.DropUnmet, SubTable.CopyEntries), the keep that follows reading the bits in order (FixedKeys.KeepNumbered); a lookup into a part once dropped throws. Never for the core the governor turns to, which holds as little past its groups as it can, nor under a budget that would not hold the span's pages twice over, nor with an emitter. Pages of their own, a sub-table each, ran x1.21: the cut by page cost what the hashes did, and a part held from none to six pages. GroupCoreTests checks every state of a record at one, two and fourteen lanes in pages too; SpillTests.APagedCoreSpillsTheValuesItMetAlone pages two million values, one in two met, under a budget its pending entries pass, and reads them back exact. runners.sh alt against the runner of 500e7c6, warm, at fourteen lanes on a busy Mac: group-total-k7 x0.665 (84.4 -> 56.9 ms, cycles x0.658); k6 x0.978 and range-k6 x0.987, which do not take the core; tenfold x1.009, unique x0.965, drift x1.032 and zipf x1.093, their intervals wide and their cycles x0.994 to x1.032.
A lane whose cache finds too few of its keys folds its rows into a partition of a group a row, then copies them into the core's batches. Its keys, appending, still looked each row up: a comparison with the row before, a call to Lookup, an add to the keys, a sixth of k7's cycles at fourteen lanes, where 82 % of the rows bypass the caches. A batch whose rows are all selected and none null is now taken whole: its values copied after the keys, row i's group the i-th new one. A row whose key is the row before's makes a group of its own too, which the core merges. runners.sh alt against the runner of cfc2731, warm, at fourteen lanes: group-total-k7 x0.898 (55.7 -> 50.5 ms), group-strided-k7 x0.978.
A paged core sends an entry to the part of its page, which every lane computes for every row it copies into its batches: the page's number mixed, two multiplications and three shifts. It is now the page's number offset by the session's seed, times the golden ratio's odd constant, its top byte: pages in order spread over every part, and no layout of keys always lands on one. runners.sh alt against the runner of 8eb56c5, warm, at fourteen lanes: group-total-k7 x0.949 (0.87-1.00), cycles x0.927.
…nothing A lane that deposits past a part's threshold applies the part's stack, a burst: its entries made groups in sub-tables that grew, then split at the end, their groups moved again. Ten million keys over forty million rows (s7) burst twice a part, three entries in four making a group, and split 1 792 times. Past the query's first applications, a few parts' floors, the core now lets its parts wait for the end while an entry applied made a group three times in four or more, and the budget could still grant half its ceiling (GroupCore.Defers): the end applies each part once, split ahead to its depth. A million keys, each forty times, reduce four in five: they burst as before. runners.sh alt against the runner of 0957c94, warm, at fourteen lanes on 40M rows: group-strided-k7 x0.745 (249.8 -> 186.0 ms), its peak 1.58 -> 1.16 GB; group-total-k7, paged, x0.944, its peak 953 -> 785 MB; group-strided-k6 x1.012, its peak unchanged. group-total-unique x0.986, group-pages-uuid x0.991, group-pairs-k100k x1.020 (0.95-1.06, twelve pairs).
A merge of lanes or parts folds each group's IndexedSum into the target's: the count added, the tops compared, the bins shifted and added. Into an empty sum, no value and no infinity, that fold gives the other sum exactly, which nearly every group of db-benchmark's q10, a group nearly a row, met: 9 % of its cycles at fourteen lanes. Merge now copies it. runners.sh alt against the runner of a71cd49, warm, at fourteen lanes: group-db-q10 x0.958 (281.3 -> 269.6 ms).
…atch A lane on a core the plan chose from the start grows its cache within a batch from a shelf that refused anything past the budget: when the core's pages and pending entries held the whole budget first, the lane failed the query rather than let the core spill (APagedCoreSpillsTheValuesItMetAlone, twice under the whole suite's load). Its shelf now takes past the budget, as a lane that may turn to the core does: its next deposit finds no room, and the core spills its largest part before the lane's next batch. The core's spill loop also stops at the query's cancellation: a query that failed in a burst left the part it emptied counted pending, which the loop took again and again, nothing given back, its lane never done (a test run hung on it).
db-benchmark's q10 groups by three short texts and three integers, a group nearly a row. PackedKeys numbered each column in an index of its own, then looked the tuple of the numbers up: seven lookups a row, 64 % of its cycles at one thread; and the numbers differ from one lane to the next, so that the core could not take the key, and fourteen lanes each built a table of 714 000 groups, then merged them by copying. TupleKeys holds such a key, of text and integer columns, as the tuple of its values in 64 bytes (KeyTuple): a text of 12 bytes or less as its word, a longer one as a mark and its number among the run's long texts, an integer as its bytes, the nulls in a byte of flags. One table of tuples, a FixedKeys of them, hashed once a row; and a key of a known width, the same in every lane, which the core takes. The tuples of a block are made 1 024 rows at a time, 64 KB, where a block whole held 4 MB a lane that a lane on the core reserved twice past its budget. The long texts are numbered once for the run, by whichever lane meets one first, in a layout the plan keeps per run (keyed by the run's QueryMemory), and counted under its memory beside the groups (QueryMemory.HoldBeside), kept with the result until it is delivered. Under pressure the governor now turns such keys to the core, which takes past the budget what a stack asks more, as for every key it takes (CorePressureTests): GroupSpillTests checks the peak of the lanes' tables alone, keeps its six-column test on the numbers (Tuples = false) and adds one on the tuples. TupleKeysTests: texts short, long, empty and null, integers of every width, filters, one to fourteen lanes, the core, the order of the components, one numbering a run. The runner's +numbers switch keeps the numbers.
…as its view The output of a key of tuples read each group's tuple whole, 64 bytes copied per value, then appended an integer through KeyStores.AppendFixed and a text by its bytes, copied out of its word and back into a view: 37 % of db-benchmark's q10 at one thread, ten million groups of three texts and three integers, against 21 % for the numbers it replaced. The keys are now read in place (FixedKeys.KeyRef), an integer column appended into its FixedStore as FixedKeys appends its own, and a text of 12 bytes or less written as its view (VarBinStore.AppendInline): its word, the length then the bytes zero-padded, is the canonical view. A long text comes out of the run's list, read without the lock once it is in it. ShortTextKeys writes its words the same way. runners.sh alt against the runner of 8a60779, warm: group-db-q10 x0.771 at one thread (889 -> 687 ms, cycles x0.767), x0.923 at fourteen (noisy, cycles x1.024); group-db-q1 x1.000 and x1.030 (0.96-1.07), group-db-q3 x0.998 and x1.017. Against ab37ead alone, q10 at one thread was x1.028: the tuples paid off only once their output did.
… from the first FixedKeys holds a key wider than a word in slots that hold it until it has 4 096 groups, then in slots of its hash and its group (WideKeyTable). The core's sub-tables never reach that: a few thousand groups each, a tuple of 64 bytes (TupleKeys) took more than a line a slot, and every probe compared whole keys, 22 % of q10's cycles at fourteen lanes. A key wider than 16 bytes now goes to the slots of a hash and a group from its first group. runners.sh alt against the runner of 38572ad's tree, warm: group-db-q10 x0.800 at fourteen lanes (286.0 -> 228.8 ms, cycles x0.781), x1.004 at one.
GroupCore sizes its sub-tables, its lanes' caches and α by a group's bytes, its slot counted as one that holds the key, 5/3 of the key and a number. A key wider than 16 bytes is in slots of its hash and its group since b10b128, 8 bytes four tenths full: a tuple of 64 bytes was counted 113 bytes of slot for 20, and q10's parts split into twice the sub-tables their bytes held, about five entries of a batch each. runners.sh alt against the runner of b10b128, warm: group-db-q10 x0.870 at fourteen lanes (232.9 -> 204.4 ms, cycles x0.893), its sub-tables 8 192 -> 4 096.
Every lane of every query grew its own array of each row's group, 512 KB for the batches of 131 072 rows a lane reads, then dropped it with its tables: at fourteen lanes, 7.3 of the 8.2 MB a count and a sum by 10³ keys allocated, on the large object heap, and the collections that came with it. The partition now rents it from the shared pool, cleared as a new array starts, and gives it back with its groups (Release). The lane's admission counts it, a batch's scratch. runners.sh alt against the runner of 1ac8e36, warm, at fourteen lanes (a busy machine, wide intervals): group-total-k3 on 4M rows x0.662 (2.01 -> 1.17 ms, cycles x0.795), group-db-q4 on 1M x0.686 (cycles x0.834), group-db-q1 on 1M x0.748 (cycles x0.884); unchanged at one lane. k3 at fourteen lanes allocates 1.37 MB a query, against 8.2.
The comparison ran every DuckDB query, then every one of ours: a perturbation of the machine biased one side unseen, and the published q10 at fourteen threads gave DuckDB 257 ms where it took 102 the same day. bench/duckdb.sh now keeps one DuckDB process a file open, driven through a FIFO, its table loaded once, and times a block of DuckDB's and a process of the runner in turn, ABBA, three pairs and then more while the 95 % interval of the median speedup is wider than ±5 % (eight at most); 10 rounds a block, 3 dropped, or 5 and 2 past 100 ms. Each row runs on two bases: our runner with its file kept mapped against DuckDB's table in memory, mapping it anew against DuckDB's vortex reader. DuckDB's time is its JSON profile's latency under 20 ms, to the nanosecond, and its timer's past that: on q10 at fourteen threads the profile cost 2 % (median 107 ms against 105). The CPU of both sides stands beside it. The files are read once before anything is timed; the canaries, controls and replays are bench/runners.sh's; everything is kept under bench/.runs/duckdb-<date>/. --publish builds the page's table from two sessions taken at different hours, a row published when they agree within 10 %. Two sessions of --only db-q4,db-q10 --threads 14 against the runner of 05de5a5's tree, a minute apart: q4 1.73x and 1.73x warm, 2.88x and 2.81x cold; q10 0.52x and 0.50x warm, 0.61x and 0.61x cold; the same answers.
The page timed every DuckDB query, then every one of ours, and set our runner mapping its file anew against DuckDB's table already in memory: its fourteen-thread rows were off by up to 2.5 times (DuckDB's q10 read 257 ms where it took 102 the same day). It is taken again with bench/duckdb.sh's pairs at 41467ea: two sessions one after the other, a third for the two rows they did not agree on within 10 %, each query warm (our file kept mapped against DuckDB's own table) and cold (mapped anew against its Vortex reader), each speedup with its 95 % interval and its pairs. On one thread Vorticity leads every query on both bases, 1.21x to 5.90x over the table and 1.32x to 5.03x over the reader. On fourteen threads it leads every query but db-benchmark's q10 (0.51x and 0.65x) and 10^7 hashed keys against the table (0.96x, its interval reaching 1.00x).
A lane on a hashed key turned to the core once its first 65 536 rows were nearly all new groups, judged between two batches: it folded its whole first batch into its own table, nearly every row a group, the table's arrays doubled some fourteen times, then emptied it into the core and dropped it. On 4M rows of 3.3M hashed values at fourteen lanes that was 400 of the 490 MB a query allocated, and the collections they called stopped every lane: half the samples of the milliseconds few threads ran were the collector's. Before it folds its first batch, a lane now groups the batch's first 8 192 rows in its table, folding no value, and turns with that table nearly empty when a uniform key making as many groups has 500 000 values or more (AggregationPartition.JudgeSample): 8 192 rows tell 10^5 values from 10^6, about 335 repeats against 34. A key of fewer values keeps its table, the batch's rows finding there the groups the sample made, and is judged on its first rows as before. Only keys the core can take are sampled. runners.sh alt against the runner of 41467ea, warm, at fourteen lanes: group-db-q10 x0.892 (199.8 -> 174.7 ms), group-strided-k7 on 40M x0.877, group-strided-k6 on 40M x0.822, group-pages-uuid x0.834, group-total-unique x0.560; the cycles x0.92 to x1.03: the lanes ran more of the time. s7 on 4M allocates 273 MB a query, against 476.
A round's line now ends with gc=<gen0>/<gen1>/<gen2>, the collections it called, and gc_paused_us, the time they held every thread (GC.GetTotalPauseDuration): the stops a lane cannot work through. At fourteen lanes db-benchmark's q10 called 7 to 8 gen0 and 4 to 5 gen1 collections a round, 17 to 25 ms of a 150 to 190 ms round; the strided key of 10^7 values 4 to 5 and 2 to 3, about 10 ms.
What a group by allocates once its process is warm, a whole query from its plan to its last result, on every thread: its lanes run where the pool schedules them and the core's parts apply on any of them, and what they allocate is what stops every lane. A collection of the workstation collector suspends every thread and collects on one; at fourteen lanes the stops were a fifth of s7 and q10 on 4M rows. Ten axes, a path each at one lane and at four: integers numbered by value, short texts, hashed integers (the core turned to on the first rows), integers spread over 2*10^6 (the core by pages), six keys as tuples. Each checks the path it took, and its floor over five warm queries against a ceiling just above it, in AllocationCollection, which runs alone. Today: 0.10 to 0.36 MB for the small integers, 0.5 to 1.2 MB for the texts, 33 to 164 MB for the keys of many groups. The ceilings come down as the zero-allocation work removes each site.
A core's part built its batches from the order of its groups, 0 to n - 1, made new for every part: 256 arrays of 50 to 60 kB a query on 4M rows, just under the large object heap, 16 MB on db-benchmark's q10 and 13 MB on the strided key of 10^7 values at fourteen lanes, a third of what their queries allocated on the small object heap, which is what calls the collections that stop every lane. The order is now lent by the core's shelf, a power of two long, so that the parts of a query take the same few arrays again and the next query those its shelf left to the process's (AggregationEngine.LentOrder), and given back once the part is built; an order whose build failed is let go, a ranking it started may still read it. A merge in parts lends its orders from the process's shelf the same way. An outcome counts the groups it delivers apart from its order's length (Count), and makes no array for chosen rows when there are none. The runner at fourteen lanes, warm, against 3e0d795: the strided key on 4M x0.886 (271 MB a query against 284, 3.6 collections against 5.6, 4.5 ms stopped against 6.0), q10 on 4M x0.954 (693 MB, 5.8 collections against 7.8, 10.4 ms against 12.9); the cycles x0.99, the time gained is the stops. GroupAllocationTests: the core's axes 3 to 4 MB lower.
…k to it A lane's shelf handed out new arrays and left every one its tables gave back to the next collection: each query grew its lanes' tables again, its caches to a batch's keys, the partition it bypasses a cache with to a batch's rows. On the strided key of 10^7 values over 4M rows that was some 226 of the 255 MB a query allocated at fourteen lanes. - A lane's shelf takes its arrays from the process's shelf and gives them back to it, its bytes still reserved and given back under the query's memory as before. - A partition gives its keys' and records' arrays back when it dies (Release, LetGo) and when it turns to the core, the partition its lane bypassed its cache with too. That partition takes its arrays from a shelf of its own, counted by no query as it never was. - The group of each row comes from the process's shelf: the shared array pool kept one array a thread, which the lane of the next query, on another thread, did not find. - The process's shelf, past its quarter of a gigabyte, lets go of the lengths taken or given least recently: full of an earlier query's lengths, it kept them while every query went on taking from it, and the arrays of the queries running then could not come in. A query's shelf hands its arrays to it with no list made first. An array a shelf lends again must be dead where it was given: the tests now poison every array given back (ArrayShelf.PoisonsGiven, set by a module initializer in both test projects), so that a table reading one after giving it fails there. The suite and the conformance tests pass under it. GroupAllocationTests, measured without the tests' shuffle of each result's order: the core's axes 8 to 17 times lower (hashed integers 47.8 -> 3.9 MB, spread integers 29.3 -> 2.8, six keys 113.6 -> 6.7), a lane alone's a quarter to two fifths lower, the rest its table, which is the result.
Past its budget the process's shelf let go of the arrays of the length taken or given least recently, found by reading every pile, under its lock, at every array given back: on the strided key of 10^7 values and on db-benchmark's q10 at fourteen lanes the shelf is full, and each give read every length the query met. The piles are now linked from the one used most recently to the one least recently, moved to the front as an array is taken or given: the oldest is the last. GroupAllocationTests: a ceiling of the core's axes is the floor and 5 % (2 % was under the floor the full suite measured, the process's shelf holding what the suite's other tests left).
Past its budget the process's shelf let go of the arrays used least recently whatever their size: on the strided key of 10^7 values over 4M rows at fourteen lanes, whose arrays the quarter of a gigabyte does not hold, every query lost small arrays to the large ones and allocated them again on the small object heap, 18 MB a query, whose collections stop every lane and copy what lives there; a large array missed costs a page fault. The shelf now keeps the arrays under 85 000 bytes apart, under a quarter of its budget more, each kind in its own order, and lets go of the oldest of the kind it takes in. A pile emptied stays, its next array a push, until the shelf holds 4 096 of them: a pile let go each query was made again at the next, its stack with it. Runners alternated against e379105, warm, at fourteen lanes, the machine busy: the strided key on 4M x0.899 (139 MB a query, 1.2 collections against 5.6 at 3e0d795, 2.2 ms stopped against 6.0), k7 on 4M x0.960 (78 MB, 0.8 collections), q10 on 4M x1.028, whose arrays the shelf holds a third of.
A lane turning to the core emptied its table into a batch of its own on every part, exactly its part's share: given back as the part applies it, where batches the lanes fill again stay held to the end, which is what a lane turned under pressure needs. A lane that turns on its first rows, judged on a sample of 8 192, has a table of a few thousand groups, and made 256 arrays of a few entries each, of lengths no other batch takes again: 3 690 of them a query on the strided key of 10^7 values over 4M rows at fourteen lanes, with their batches. A table no larger than a cache now goes into the lane's open batches, as its cache's flushes do. The runner at fourteen lanes, warm: the strided key on 4M 133 MB a query against 139, 1.0 collection against 1.2, 1.97 ms stopped against 2.24. GroupAllocationTests: hashed integers at four lanes 3.9 -> 3.0 MB.
Each of a core's sub-tables made, as it was made, the first array of its keys, sixteen of them, outside any shelf, and laid its records out again, four objects the same for every sub-table of the core: on db-benchmark's q10 over 4M rows at fourteen lanes, 3 840 sub-tables, 3 885 arrays of a kilobyte of tuples on the small object heap, which live to the end of the query, so that each collection copied them. The keys' array is now made at the first group, from the shelf when the keys have one, and a core lays its records out once, for every sub-table (AggregationPartition.NewSlots takes the layout). The first array a lane's keys take now comes from its shelf, which counts it: WorkCounterTests counts one array more on each query whose keys are fixed, its sixteen keys' bytes with it. The runner at fourteen lanes, warm: q10 on 4M stopped 5.6 ms a query against 9.9, 3.4 collections against 4.6, 615 MB against 631; the strided key 1.5 ms against 2.0.
A slot's, a key's or an applier's scratch grew from nothing at every query and was left to the next collection: the two passes' homes and rows left over, a tuple's and a text's words, the entries an applier of the core cuts by sub-table, its map, its bits. On db-benchmark's q10 over 4M rows at fourteen lanes that was 5.6 MB a query on the small object heap. Scratch.Grow now takes its array from the process's shelf, a power of two long, and gives it the one it replaces; keys give theirs back when they are released, an applier when its worker or its lane is done (CoreApplier.Release, LaneCore.Release with the partition its lane bypassed its cache with), its batches taken in arrays of the shelf too. An array of references given to a shelf is cleared, whatever kept it would keep the objects of the query that gave it. GroupAllocationTests: hashed integers at four lanes 3.1 -> 1.3 MB, spread integers 2.8 -> 1.4, six keys 6.2 -> 4.6.
…ool goes Three sources of objects that lived a whole query on the small object heap, copied by each collection, or that the next query made again: - A core made a batch object for every 150 or so entries a lane deposits, 29 000 on db-benchmark's q10 over 4M rows. The batches a core made are now kept by the process once its slabs go (PartBatch. Linked, Own, Keep), up to 2^17 of them, and the next core takes those first. - A lane that turns to the core with a table no larger than a sample of its first rows empties it into its open batches, as a cache does: with six keys a cache holds fewer groups than a sample, and the table still went into 2 283 arrays of exact lengths, 7.7 MB a query. - Every rent from the shared array pool in the group by now comes from the process's shelf (ArrayShelf.Rent, Return), a power of two long: the pool keeps an array for each thread that gave one back, which the next query, on another thread, did not find. A merge's parts, a key's sort, the slots' reads by part and the arrays QueryArrays counts. The runner at fourteen lanes, warm, against 3e0d795: q10 on 4M 0.8 collections a query against 7.8, 1.2 ms stopped against 12.9, 56 ms against 71; the strided key 0.6 collections against 5.6, 21.6 ms against 29.2; k7 0.6 against 3.6. GroupAllocationTests: six keys at four lanes 4.3 -> 1.6 MB.
A merge borrows its map of numbers from the process's shelf unless its caller keeps it, a partition gives the group of each row back once it folds no more, a key gives its page slabs back and makes its ranges at the first, a result column's values grow through the process's shelf and go back to it, the part builder's column sets are released with their query, and the core's spread map lives on the stack. A core part restarts its directory in one place. The allocation ratchet's floors: a lane alone's small axes three to five times lower (small integers 355 to 76 KB, short texts 434 to 154 KB), the core's axes a sixth to a third (hashed integers at four lanes 1.55 to 0.97 MB, six keys 1.58 to 1.33 MB). Ceilings lowered to them, a margin per regime written in the test.
The steps a group by waits on the store depend on which timer of the counting source fires first; the ceiling was the highest seen, 11, and the whole suite's load pushed a count distinct to 12 in a few runs since 2026-10-08. Swept over eighteen runs, alone and beside a run of the aggregation tests: the group by and the order 9 to 11, the count distinct 6 to 11. The ceiling is now the highest seen and two, still far under the twenty steps a regression that waits a batch more adds.
The table a lane alone holds as the result, or the largest one a merge in series kept, was left to the next collection once its batches were read: every query at one lane grew its whole table again from fresh memory. The outcome now keeps that partition and, once delivered, hands its arrays to the process's shelf, its lane's shelf counting nothing more since the query gave its memory back whole. A whole result's order, the identity its keys made new each time, is lent by the process's shelf and given back with it. The allocation ratchet at one lane: hashed integers 57.7 MB to 100 KB, spread integers 33.1 MB to 106 KB, six keys 105.5 MB to 108 KB, small integers 76 to 35 KB, short texts 154 to 36 KB; at four lanes without the core, small integers 147 to 107 KB and short texts 220 to 123 KB.
…ys only Given back with the shelf's usual eviction, a table larger than the process's budget let go of the arrays its own query had grown through, which the next query takes first; that query then let go of the table's arrays in turn: q10 at one lane made 722 MB a query, against 517 when the table went to the collector. Offered only where there was room, it found the shelf full of what the queries before it left (six keys at one lane, 64 MB a query). A pile now knows when it was last used, and a delivered result's arrays take the place of piles last used before its query began, never of those its own query used. q10 at one lane 496 MB a query, six keys at one lane 108 KB. Alternated runners against c7a5f1b, warm, at one lane: s7 x0.928 (115 to 107 ms), k7 x0.959, q10 x1.006; at fourteen, s7 x0.986, q10 x0.996.
… counted A query's working memory is zero allocation only if kept from one query to the next, and a shelf of a quarter of a gigabyte made most of it new at every query: q10 on 10^7 rows at fourteen lanes holds more than a gigabyte and a half, paid again in page faults and zeroed pages. The process's shelf now counts its large arrays in the process's budget, reserved and measured as the queries' tables are: kept within the ceiling, handed over to a query that counts it, and let go to the collector otherwise. Past its quarter of a gigabyte it keeps what the queries running lately need: the most a query held at once, grown by what it refused or let go for want of room since a query last ended, up to a quarter of the memory the process may use. A query the budget refuses first makes the shelf let its large arrays go, for the collection that follows to take them; two seconds with no query bring the shelf back to its quarter of a gigabyte, the collector asked to run when that let go much. Warm, per query: q10 on 4M rows at fourteen lanes 589 MB to 5.5 MB, at one lane 496 MB to 0.2 MB; s7 134 to 2.3 MB, k7 66 to 4.3 MB. Alternated runners against 14c8690 at fourteen lanes: q10 on 10M x0.877 (150 to 131 ms), 10^7 hashed keys on 40M x0.897, 10^7 keys x0.876, 10^6 hashed keys x0.893, q3 x0.946; q10 on 10M at one lane x0.868 (672 to 584 ms).
A split moves half a sub-table, and each level of splits moves every group again, where a table held whole moves each about twice as it doubles. Sub-tables of half a megabyte split q10's parts on 10^7 rows three times over, a key of 8 bytes once, and made each part's objects again at every split. Bigger arrays were a cost while the process's shelf could not keep them; it now keeps what the running queries need. Out of pressure, a sub-table now splits past 65 536 groups, or past eight megabytes of wide groups; under pressure, the half megabyte as before. Alternated runners against 4d39474 at fourteen lanes, warm: q10 on 10M x0.760 (132 to 100 ms), 10^7 hashed keys on 40M x0.784 (139 to 106 ms), the rest flat (10^6 hashed keys x0.990, 10^7 keys x1.011, q3 x0.984, q2 x0.983). Swept first: 128 000 groups the same, 256 000 x1.10 on the hashed keys, and 40 000, just past q10's 39 000 a part, x1.20, a single split of a sub-table that size costing more than it saves.
An array the process's shelf handed to code that counts nothing went to the budget's rest, and came back from it untaken: every query added its scratch, its lent orders and its rows' groups to the rest again, until the budget refused a reservation, the shelf let everything go, and a query made 240 MB anew every few rounds (q10 on 4M rows at fourteen lanes). Every array given to the shelf was let go to the rest first, a query's when it gave it back, another's when the shelf handed it out or made it new for it: the shelf now always takes it back from there. Sixteen rounds of q10 on 4M rows, warm: 1.7 to 11 MB each from the third, none past.
…le only with no query A part's first sub-table reserved exactly what its entries could hold: lengths no other part nor query asked again, which the process's shelf piled one by one and never found again (830 new piles a query on q10). It now rounds its room up to a quarter of a power of two, four lengths an octave, a quarter wasted at most. The process's shelf went back to its budget after two ticks without a use, which a query between two uses could take: under a debugger, q10's scan went seconds without one, and its shelf, let go mid-query, made 850 MB again a round. A query running now keeps it. Alternated runners against 9e8697c at fourteen lanes, warm: q10 on 10M x0.950 (101 to 95 ms), on 4M x0.960, 10^7 hashed keys on 40M x0.968.
Two sessions at 7445c2a, one after the other, agreeing within 10 % on every row. Vorticity now leads every query on both bases at one and at fourteen threads: db-benchmark's q10 at fourteen threads goes from 0.51x to 1.05x over DuckDB's own table and from 0.65x to 1.26x over its reader, 10^7 hashed keys from 0.96x to 1.76x, the memory of the groups kept from one query to the next and the shared table's parts held whole.
Every section of the page rewritten by its own instrument at 32b3a82: the report's scenarios and decoders, the in-process ratios, taking and writing per encoding, the encodings' trade-offs and the advice, and the four kernel classes. Every ratio-check axis inside its ceiling; Vorticity decodes 51 of 57 files faster than Rust (median 1.27x), takes rows faster on 56 of 57 (1.15x) and writes faster on 56 of 56 (3.76x).
Instruments launches the runner without the caller's environment, so a profile of the warm base mapped its file anew at every round. The variable, when set, now goes to the launched runner through --env.
… past its cache The core found a part's new keys at their home first, then hashed each missed key again to add it, and a third time when its home was taken: three hashes of a 64-byte tuple where one does. The home pass now keeps each hash, and a key it missed is added under it while the table keeps its seed; a table condensed during the batch, or reseeded, hashes again. Alternated runners against 7445c2a at fourteen lanes: q10 on 10M x0.985 (cycles x0.983), 10^7 hashed keys x0.977. A lane's table of hashed keys now reads each slot sixteen rows ahead once it outgrows its lane's share of a cache, 768 KB at several lanes and 8 MB alone, unless the plan sets a distance: a table of 10^5 short texts per lane missed at every probe. Tuples keep their two passes as they were, their 64 bytes still compared once the slot is read (q10 at one lane gave back 1.5 %). Against the commit before, at fourteen lanes: q3 x0.963, q7 x0.947, q2 and q10 flat; at one lane flat.
A lane scattering a key wider than two words folded its words for the key's part, and the core folded them again to find the key's group in its sub-table: four 128-bit products a tuple, twice. An entry of such a key now carries the four bytes of its sub-table hash under no seed, before the key, in the record's padding when there is room (q10's entry keeps its 104 bytes); the lane writes it as it scatters, a spill as it copies, and the core reads it while the sub-table has no seed, folding the words again otherwise. Alternated runners against e89d9a6 at fourteen lanes, warm: q10 on 10M x0.960 (94.5 to 90.7 ms, cycles x0.961), on 4M x0.999 (cycles x0.982).
A key's values went into the result's column one call at a time, each checking the column's room and moving its count: nine cycles a text and six an integer of q10's ten million tuples, in a sixth of its cycles. A part's keys are now written in place, the column's room made once: up to the first null or long text, the rest one at a time as before. Texts as views through the new VarBinStore.InlineViews and AdvanceViews, integers through FixedStore.GetSpan and Advance. Alternated runners against 9b4d1cb, warm: q10 on 10M x0.956 at fourteen lanes (91.2 to 88.0 ms) and x0.948 at one (609 to 577 ms); the keys of one column flat at fourteen (q3 x0.991, 10^7 keys x0.990).
… words A key of short texts copied each view of a block into a word before looking it up, though the views the varbin decoder cuts already are those words: every inline value zero past its bytes, as ViewKernels' cuts and CanonicalSupport.WriteView write it. A canonical record now says whether this library wrote its views (PaddedViews, set by the varbin decoder for every row cut, false for views read as they came), and a block without nulls whose values are all twelve bytes or fewer hands its views to the key as words where they lie, one pass over the lengths judging them. Alternated runners against d72a693, warm: q2 at one lane x0.927 (84 to 78 ms) and at fourteen cycles x0.928 (its median noisy), q1 x0.952 at fourteen and x0.941 at one, q3 x0.975 at fourteen.
The tuples copied a block's text views into words, then the words into the tuples. Views this library cut already are the words: they now go into the tuples from where they lie, the copy kept for any other views. Alternated runners against ddc0a4d, warm: q10 on 10M x0.983 at one lane (575 to 565 ms), flat at fourteen.
Two sessions at 7734c47, one after the other, and a third for the one row they did not agree on. Every row still ahead, the thinnest leads wider: db-benchmark's q10 at fourteen threads 1.05x to 1.18x over DuckDB's own table and 1.26x to 1.39x over its reader, at one thread 1.39x to 1.43x; q3 at fourteen 1.29x to 1.35x, q2 1.35x to 1.44x.
Every section rewritten by its instrument at 6403ae1. Every ratio-check axis inside its ceiling; Vorticity decodes 47 of 57 files faster than Rust (median 1.28x, the files near 1.00x moving from run to run) and takes rows faster on 56 of 57 (median 1.14x).
… speedups against Rust and DuckDB
rducom
force-pushed
the
perf/beat-duckdb
branch
from
October 10, 2026 09:22
c1b36ef to
511497a
Compare
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.
Intent
Put Vorticity's group bys ahead of DuckDB 1.5.2 on every query of the comparison page, at one thread and at fourteen, against DuckDB's own in-memory table as well as its Vortex reader. On main, measured on 2026-10-09, Vorticity trailed DuckDB's table on 4 of the 13 queries at one thread and on 5 at fourteen, db-benchmark's q10 at 0.24x. The page itself could not be trusted: it timed every DuckDB query in one block and every one of ours in another, and its fourteen-thread rows were off by up to 2.5x either way.
The branch does four things, in this order:
bench/duckdb.sh,bench/runners.sh alt).What it brings
DuckDB's time over ours, against its own table, our file kept mapped:
The main column is main's runner (
efc0ec67) on 2026-10-09, each side timed in its own block. The branch column is the page at9df22c4f: two sessions of alternated pairs that agree within 10 % on every row. q10 at fourteen threads is the thinnest lead, 1.18x with its 95 % interval from 1.15x to 1.20x (87 ms against 103). Against DuckDB's Vortex reader the branch leads by 1.58x to 5.65x at one thread and by 1.39x to 4.47x at fourteen, where main trailed it on q10 at 0.31x and on 10⁷ hashed keys at 0.91x.What a warm query allocates, from
88981a59, where the runner started counting collections, to0960f79f:At fourteen lanes q10 called 7.8 collections a query and now calls 0.3, and its median stop went from 12.9 ms to none. Over the same commits, q10 on 10M rows at fourteen lanes went from 168 to 94 ms (×0.563) and 10⁷ hashed keys from 159 to 104 ms (×0.656).
GroupAllocationTestsholds ten axes to ceilings just above today's floors, from 35 KB to 109 KB at one lane and from 127 KB to 1.6 MB at four.The comparison with Rust (benchmarks.md) is measured again too. The scan's and the writer's paths are unchanged, but for a flag the varbin decoder now sets on the views it cuts, and every
--ratio-checkaxis stays inside its ceiling.What changes for callers
Nothing public changes, but three behaviours do:
TupleKeys, 64 bytes), where each column used to be numbered apart. Texts longer than 12 bytes are numbered once for the run and held beside the groups until the result is delivered.Risks and checks
PoisonedShelves, a module initializer), and the suite and the conformance tests pass under it.5557a340fixes it, and sixteen rounds of q10 then stay between 1.7 and 11 MB each.GroupedFloatSumTestsholds every group's bits to thoseIndexedSum.Addgives one value at a time, at one, four and fourteen lanes, with tiny, subnormal and huge values, signed zeros, NaN and infinities. It fails when the split takes a group's first value at the wrong top.SpillTests.APagedCoreSpillsTheValuesItMetAlonereads two million paged values back exact under a tight budget.PaddedViews). Only rows the decoder cut itself are flagged, and views read as they came are copied as before.0e841813fixes two failures this work exposed: a lane on a core that spills failed the query instead of letting the core spill, and a query cancelled during a burst hung the spill loop.GroupRoundTripTests' ceiling of dependent steps goes from 11 to 14 (5ddba40f). Over 18 runs the count distinct took 6 to 11 steps, and 12 under the full suite's load; a regression that waits a batch more adds about 20.TryWords(5 % slower);Vorticity.Tests(7,315, 4 skipped, 189 s) andVorticity.Conformance(923) at the head (511497ac), rebased on main after perf(zstd): x64's hot paths, at or past libzstd's assembly on Zen 4 #55bench/gate.shgreen at the head in 91 s: the ten ratchets,--ffi-check,--ratio-checkWhat changed, by step
b54aa2e3,ce4605d1,11998932,0db15366,88981a59,f464aac3VORTICITY_RUNNER_WARM).runners.sh altruns ABBA pairs until the ratio's 95 % interval holds, framed by canaries.duckdb.shtimes DuckDB and the runner in alternated pairs on two bases.profile.shprofiles the warm base.f589ed44,80b1fad8,2a0aa88a--micro words. A text's group is found with no branch and no second hash, and a block's words are copied without a branch. A slot is found by one multiplication, and small tables are a quarter full.e37132fe,ddc081d5,5c4cd24b,113d71300e06b92f,1f422d46,4a2161e1,081e804e--micro floatsum. A window is split at one top with its extractors held in registers. The sum folds its own pass, and a merge into an empty sum copies it.ab4ef6e6,79068b4d,c97daa5b0c397f65,071a9abbdd5e7e65,e1a72001,dd8c2798,70a027bcTupleKeysholds five to eight columns as a 64-byte tuple the core can take. The tuple's columns are written out where they lie. A key wider than 16 bytes sits in slots of a hash and a group from the first, and the core sizes its sub-tables by that slot.5bfe895d,0e841813d1758ed7,1fa52f98,45c59208,ea927ee5,2e1e4f6a,9f07ddd6,3d3b70e9,53d11f91,09e5b6d6,6d482de2,721a2b4f,cd75bcbb,d1a3f94c,cbe94116,5557a340,0960f79f,5ddba40fGroupAllocationTests. Lanes, scratch, orders, the core's batches and results take their arrays from shelves and give them back. The process's shelf keeps its small arrays apart, finds its oldest pile without a scan, and keeps what the running queries need, counted by the governor. Sub-tables split later. A part's first sub-table is sized at a quarter step.1aff1ddf,da606212,3ea30a52,42687313,00ca71fe2e052079,79599dc2,bfd524c3,9df22c4f,a2986a10,511497acbenchmarks.mdtwice, and the README.Measured at each step (runners alternated against the commit before, warm)
--micro words: a hundred texts of 5 bytes 8.06 → 3.89 ns a row, a hundred thousand of 12 bytes 7.81 → 6.4088981a59: q10 on 10M at fourteen ×0.563 and at one ×0.873; 10⁷ hashed keys at fourteen ×0.6560960f79f: q10 at fourteen ×0.916 (95 → 87 ms), q3 ×0.941, q2 at one lane ×0.927