Skip to content

Store non-full pages in a BTreeSet, not a Vec#5071

Open
gefjon wants to merge 2 commits into
masterfrom
phoebe/table-non-full-pages-heap
Open

Store non-full pages in a BTreeSet, not a Vec#5071
gefjon wants to merge 2 commits into
masterfrom
phoebe/table-non-full-pages-heap

Conversation

@gefjon
Copy link
Copy Markdown
Contributor

@gefjon gefjon commented May 19, 2026

Description of Changes

Reviving a previous patch I wrote during our (internal) TPCC experimentation. This has become important because, in addition to its performance implications, it makes row insertion locations deterministic regardless of datastore restarts, which previously they were not.

Previously, restarting the datastore would re-order the non_full_pages list (i.e. sort it by increasing PageIndex, where normally it was not sorted), meaning that which page a new row would be inserted into depended on when the datastore was last restarted.

With this patch, that is not the case: the non_full_pages are always kept in a deterministic order, so which page a new row goes into is also deterministic.

Original commit message follows:

And sort them by number of available var-len granules. This prevents an accidentally quadratic behavior where, for a table where the average row contains many var-len granules, after inserting a large number of rows, there would be a large number of pages in non_full_pages each of which had enough space for at least one fixed-len row part, but insufficient space for an actual row in practice due to insufficient var-len granules. Each insertion would then do a linear scan over non_full_pages before either inserting into the last page or allocating a new page which went to the end.

Now, non-full pages are stored in a BTreeSet sorted by the number of free var-len granules, and the search for a useable page is done with a BTreeSet::range iterator for only the pages with enough granules. I think there may still be an off-by-one-ish bug here, where a page may have enough bytes in the gap that it could either store the fixed-len part or the var-len granules, but not both, but this fix hopefully will suffice for now.

API and ABI breaking changes

N/a

Expected complexity level and risk

2? Table code is a bit fiddly, and this path is performance-sensitive when inserting rows.

Testing

  • Passes table crate tests.
  • Was included in our internal TPCC experimentation, where it significantly improved performance (due to that benchmark exercising the accidentally-quadradic behavior this patch is designed to protect).
  • Joshua ran the keynote-2 benchmarks with this patch and did not observe a decrease in throughput.

Reviving a previous patch I wrote during our (internal) TPCC experimentation.
This has become important because, in addition to its performance implications,
it makes row insertion locations deterministic regardless of datastore restarts,
which previously they were not.

Previously, restarting the datastore would re-order the `non_full_pages` list
(i.e. sort it by increasing `PageIndex`, where normally it was not sorted),
meaning that which page a new row would be inserted into
depended on when the datastore was last restarted.

With this patch, that is not the case:
the `non_full_pages` are always kept in a deterministic order,
so which page a new row goes into is also deterministic.

Original commit message follows:

And sort them by number of available var-len granules.
This prevents an accidentally quadratic behavior where,
for a table where the average row contains many var-len granules,
after inserting a large number of rows, there would be a large number of pages in `non_full_pages`
each of which had enough space for at least one fixed-len row part,
but insufficient space for an actual row in practice due to insufficient var-len granules.
Each insertion would then do a linear scan over `non_full_pages`
before either inserting into the last page or allocating a new page which went to the end.

Now, non-full pages are stored in a `BTreeSet` sorted by the number of free var-len granules,
and the search for a useable page is done with a `BTreeSet::range` iterator for only the pages with enough granules.
I think there may still be an off-by-one-ish bug here,
where a page may have enough bytes in the gap that it could either store the fixed-len part
or the var-len granules, but not both,
but this fix hopefully will suffice for now.
@gefjon gefjon requested review from joshua-spacetime and kim May 19, 2026 15:49
Copy link
Copy Markdown
Contributor

@joshua-spacetime joshua-spacetime left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested this on the keynote-2 benchmark, and I didn't observe a decrease in overall throughput fwiw.

Comment thread crates/table/src/pages.rs
During review, Joshua noticed that
I had completely forgotten to update `non_full_pages` in the delete path. D'oh!
Fixing this, it made the most sense to do a minor refactor to how `non_full_pages` was updated:
rather than conditionalizing inclusion there on `page.is_full(fixed_row_size)`,
we now check `page.available_var_len_granules() != 0`.
This means we have to pass around `fixed_row_size` less, and reduces dedundancy.
It did, however, mean rewriting and/or replacing a few methods on `Pages`
more than the original patch had included.
@gefjon gefjon requested a review from joshua-spacetime May 19, 2026 22:37
Comment thread crates/table/src/pages.rs
Comment on lines +343 to +344
if available_granules != 0 {
self.non_full_pages.insert((available_granules, page_index));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about deletes that don't include any var_len_granules? If I'm reading this correctly, it won't record the page as non_full, right?


impl<T: MemoryUsage> MemoryUsage for std::collections::BTreeSet<T> {
fn heap_usage(&self) -> usize {
// NB: this is best-effort, since we don't have a `capacity()` method on `BTreeMap`.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this how B-trees work? Like, there isn't any excess, preallocated capacity 🤔

Comment thread crates/table/src/pages.rs
// but we'd have to do some amount of reasoning to demonstrate it was correct
// based on the definition of `Page::clear`,
// and why bother?
.map(|idx| (self.pages[idx].available_var_len_granules(), PageIndex(idx as u64)))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For my own education: why is it that the number of granules isn't known statically at this point (we just cleared all pages)? Does the size of a granule depend on the table schema or something?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants