Expand description
The run of values behind a flat vector, and the seam the buffer manager arrives through.
spec/engine/03-data-plane.md section 3.8. Today every vector owns its payload and every scan
allocates, which is the right place to start and is not where this ends. At layer three the scan
reads a page out of the buffer manager and the vector wants to point into that page rather than
copy out of it, and the copy it avoids is the largest single copy in the system, because it is
every byte of every column every query reads.
The type that supports both is one enum holding either an owned run or a borrowed one, and the part that has to be decided correctly the first time is how the borrow is expressed, because that shows up in every signature that mentions a vector.
§Why the pin is a handle and not a lifetime
The obvious way to express a borrow in Rust is a lifetime parameter, and it is the wrong one
here. A lifetime on Buffer is a lifetime on Data, which is a lifetime on
Vector, which is a lifetime on Chunk, which is a lifetime on
every operator’s state, on every trait object in the pipeline, and on every queue a chunk is put
into for another thread to pick up. The scheduler is exactly that last thing, so the borrow would
have to outlive a hand off between threads that the compiler has no way to see the end of. The
two ways out of that are unsafe code and a copy at the boundary, and the copy at the boundary is
the thing the borrow existed to avoid.
So the pin is a Pin, a reference counted handle the buffer holds, and the page stays alive
because the handle is alive rather than because a region ends. It costs one atomic increment per
vector construction, which is not measurable next to reading the page it is protecting, and the
ownership story stays uniform: a chunk is Send, always, whatever its columns are pointing at.
§Why it landed with one variant
There was no buffer manager, so there was nothing to borrow from, and writing the borrowed
variant then would have been writing an interface against an imaginary caller. What landed was
the enum, with only the owned variant in it, so that adding a variant later is a change inside
this crate rather than a change to every signature in the workspace. The reader side of that
migration was already done by the Deref below: everything outside this crate reads a slice,
and a slice is what every variant hands back. The section after this one is that bet being
collected, and it cost two functions.
The writer side is Buffer::to_mut, which is the one function that has to grow a case. A write
through a borrowed buffer has to copy the page into an owned run first, which is what Cow does
and for the same reason, and having the call site named now means that day is a change to one
function rather than a search for every push.
§The second variant arrived early, and from the other direction
The shared variant is here before the buffer manager is, because the Parquet reader needed the
same thing for a different reason. A string column is built over the page it was decoded from
rather than copying out of it, so the page becomes the column’s arena and goes downstream with
it, and the reader never gets the allocation back. On hits that is ten and a half megabytes a
row group, freed and taken again for every row group, and glibc hands a block that size back to
the kernel when it is freed, so the next one faults in every page of it. Measured on the URL
column of hits-1m-snappy.parquet, running with MALLOC_MMAP_THRESHOLD_ and
MALLOC_TRIM_THRESHOLD_ both raised so that nothing is ever handed back took the query from
113.08 milliseconds to 104.08, and the decode stage moved as well as the decompress one, which
is what says it is page faults rather than anything about the codec.
So the arena is held by an Arc and the reader keeps a handle to it. When every column built
over that page has been dropped the reader is the only holder left, takes the run back out and
decompresses the next page into it. That is a page pool with two entries and no eviction policy,
which is not the buffer manager, but it is the same shape and it is the first caller that will
want one.
It is Arc<Vec<T>> rather than Pin because this caller knows exactly what its page is and
can say so in the type. The Pin variant is still coming and is still opaque, because the
buffer manager’s page is a frame in a pool that a vector has no business knowing the shape of.
Two variants for two situations is the honest answer here: one of them can name its page and the
other cannot.
§A shared buffer is a window, not a whole page
The shared variant carries an offset and a length, so a buffer can be a run inside a page rather
than the whole of one. That is what makes Buffer::slice pointer arithmetic and a reference
count bump instead of an allocation and a copy.
It was a whole page for one release and the cost of that shows up everywhere a column is cut.
Every other form of a vector could already be cut for nothing, because a bit packed body moves an
offset, a dictionary body shares its values, a string body shares its arena and a run length body
keeps the runs it touches. The flat body was the exception, and it was the exception because this
type could not name a piece of itself, so spec/perf/12-the-chunk-and-the-page.md measured 1,803
instructions a chunk of memcpy and 1,869 of malloc and free on a sum over an in memory table,
which together were 27 percent of the chunk. None of that was the query’s work.
The owned variant does not grow a window. A Vec<T> is the thing a writer appends to and an
offset on it would mean every push had to think about where the run starts, for no gain: a
caller that means its run to be cut many times says so once, with Buffer::into_page, and
after that the cuts are free. Cutting an owned run still copies, which is the same answer Cow
gives and is why Buffer::slice is where the decision is made rather than at each call site.
Structs§
- Buffer
- A run of values of one physical type.
Type Aliases§
- Pin
- What keeps a page alive for as long as a buffer points into it.