Repository navigation
Add internal/slots: fixed-size aligned record slots - #4
Merged
Merged
Conversation
page.Append can store bytes but cannot find item n again, so reaching the 200th means decoding the 199 before it. Fixed-size slots make any slot reachable by one multiplication: about 4-5ns whichever slot, against ~990ns for the packed 200th. Align is applied to the stride rather than to each position, because an aligned start plus an aligned step size makes every later position aligned for free, which is what keeps Offset pure arithmetic. Layout's fields are unexported and NewLayout is the only constructor, so validation happens once at construction rather than on every access. Padding is zeroed on write so deleted fragments cannot survive in the gaps, and PadPerSlot/Tail/Waste expose the cost rather than hiding it. Review of this change found four defects, all fixed with regression tests: Put could panic computing a slice bound past the end of the page, Get could silently read 64 bytes out of an 8-byte slot, NewLayout accepted a negative record size that wrapped through uint16 to 65531, and Align returned nonsense for non-positive input. The first two came from exported fields allowing an inconsistent Layout; unexporting them turned the bug class into a compile error.
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.
Summary
Adds
internal/slots: fixed-size, aligned record slots inside a page, so anyslot is reachable by arithmetic rather than by scanning.
That line is the feature. Everything else in the package exists to make it safe
and to account for what it costs.
Background
page.Appendcopies bytes into a page and advancesFreeStart. It has no wayto find item n again: records are unknown lengths at unknown positions, so
reaching the 200th means decoding the 199 before it.
Measured in the same 4096-byte page:
Ranges rather than single figures deliberately — see the methodology note under
Testing.
Design
Alignment is applied to the stride, not to each position
Alignis called exactly twice, and neither call is per-slot:Aligning the step size once makes every subsequent position aligned for free:
Aligning positions individually would not work:
Align(base + i*recordSize, 8)can round two different slots to the same place, or make a slot overlap its
neighbour. An aligned start plus an aligned stride is what lets
Offsetstaypure arithmetic.
Why align at all
Memory is fetched in words, not bytes, so an 8-byte value starting at offset 6
straddles two words and costs two fetches instead of one. The same shape appears
one scale up: a record straddling two 4096-byte pages costs two page reads.
Go's compiler already does this to every struct, which makes it easy to show:
Same fields, 8 bytes saved by ordering largest-first.
What it costs, exposed rather than hidden
PadPerSlot(),Tail()andWaste()are public so a caller can see the billbefore choosing a record size. A 1-byte record padded to 8 throws away 87% of
the page, which is the honest limit of the design.
Layout is valid by construction
Layout's fields are unexported andNewLayoutis the only constructor. Thisis not stylistic — see the defects below. Validation happens once, at
construction; the per-access path checks only what can still vary: the slot
index, and whether the slot ends inside this page.
An earlier draft called a
Validate()method on everyGet/Put. That checkedconstants in the hottest path in the package and cost 2.3x, which defeated the
purpose of the step. Making a bad
Layoutimpossible to build is both faster andstronger than detecting one repeatedly.
Padding is zeroed on write
Putzeroes the bytes between the record and the end of its slot. Without it,padding retains whatever the page held before — fragments of deleted records,
readable by anything scanning raw bytes, and enough to make two logically
identical pages differ byte for byte.
TestPutZeroesThePaddingfills a slotwith
0xFFfirst and checks it comes back clean.Defects found and fixed during review
Four, all in this change, found by probing rather than by re-reading.
A panic writing past the page. An inconsistent
LayoutmadePutcompute aslice bound of
[:8024]on a 4096-byte page. A panic takes the process down,which is the worst available outcome for a storage engine.
A silent over-read.
GetwithRecordSize=64andSlotSize=8returned 64bytes out of an 8-byte slot — reading into neighbouring slots — and reported
success.
Both had the same cause: exported fields let callers build an inconsistent
Layout, andPut/Gettrusted it. Unexporting the fields turned this from aruntime concern into a compile error; two of the probe tests no longer build,
which is the intended outcome.
NewLayout(-5, 8)succeeded, producingRecordSize = 65531— a negativeintwrapping throughuint16. That Layout then reproduced the panic above by adifferent route. Now rejected explicitly.
Align(-5, 8)returned 8 andPadding(-5, 8)returned 13. Non-positiveinput now passes through unchanged, so
NewLayout's existing<= 0checkcatches it.
Testing
53 tests across the module.
go vet,gofmt -landgo test -race ./...clean.
New in this change:
TestLastSlotEndsInsideThePage— for every record size from 1 to 512, checksthe last slot ends inside the page and that
Tail()andWaste()are nevernegative
TestZeroLayoutErrorsRatherThanPanicking,TestShortPageErrorsRatherThanPanickingTestNegativeOrZeroRecordSizeIsRejected,TestAlignIgnoresNonPositiveInputFuzzPutGetNeverPanics— 15s clean; guards the panic aboveBenchmark methodology
Three runs of each benchmark:
The fixed-slot figures vary by about 1.7 ns run to run, so first and 200th
overlap. That overlap is the result — access cost does not depend on the
index. Quoting two significant digits from this benchmark would be misleading,
so the README and demo quote ranges.
The hardening costs roughly 2 ns per access. Against 990 ns that is irrelevant,
and real access will go through a buffer pool with locking that dwarfs it. Not
optimised further: there is no workload to optimise for yet, and the measurement
is not precise enough to verify an improvement.
Not in scope
varying length need a slot directory — offsets stored in the page rather than
computed — which belongs with the heap file. Measured on three sample rows,
fixed slots left 53% of the space empty, which is what justifies that work.
Putoverwrites; there is no free list andno tombstone.
belongs to the tuple layer.
line. Worth revisiting once something is hot enough to measure.