Repository navigation
Frame a block, and decode one, with one copy of the payload - #192
Merged
Merged
Conversation
Three places on the tiered paths copied the payload for the sake of copying it
again.
Framing a block owned its payload first -- `value.to_vec()` -- and then copied
that buffer into the block. Three of the four arms store the value as it is, so
three of the four were paying two payload-sized allocations and two copies to
produce one buffer that anybody keeps.
A read that finds the entry below memory hands the bytes to two holders: the
tier it refills and the caller. It decoded into a `Vec`, cloned the `Vec` for
the tier, and let the tier build an `Arc` from the clone. Three allocations for
two holders. The batch read did the same, once a key, and then cloned again for
every position that had asked for that key.
A decode now has two forms and the read picks the one it needs. A read that
refills decodes straight into the handle the tier keeps, and copies once for
the caller. A read that cannot refill -- an SSD-only instance never puts
anything back into memory -- decodes into the value it is going to return and
moves it out, so that configuration does not pay for a handle it has no use
for.
allocations bytes
get, promoting from a lower tier 13.00 -> 12.00 25,334.0 -> 24,310.0
put, reaching the SSD tier, Rust 28.11 -> 27.11 59,247.8 -> 58,223.8
put, reaching the SSD tier, with
the other store backend 12.11 -> 11.11 55,938.2 -> 54,914.2
One allocation and one kibibyte each, which is the payload: these are figures
for a 1 KiB value, and the copies removed grow with it.
The batch promoting read had no row at all -- every read row in the report was
a memory hit until the previous change, and that one measured a single read.
It has one now, with a bound per arm, because the batched store read is the
backend's own: 59.80 allocations for eight keys on the pure-Rust store, 73.47
through the other, within 1,200 bytes of each other. Its per-key cost is
dominated by work this change does not touch -- the store read, the admission,
the eviction -- so it is bounded absolutely rather than held to the
bookkeeping-per-key bound the resident batch rows are held to.
Both regime checks now ask for nine reads in ten to have reached a lower tier
rather than allowing a fixed sixteen exceptions. The batch row starts with a
tier full of keys it is about to ask for, so a count of exceptions was a
guess; a share of the reads is what the check is actually about, and a fixture
that stopped demoting reads near zero either way.
Tested: the two decodes are asserted to answer the same for twelve shapes of
block -- raw, compressed, empty, shorter than the header, no magic, no bytes,
a header cut short, a payload cut short, an unknown version, an unknown codec,
a lying original length, a lying payload length -- accepting the same bytes or
rejecting with the same message. The header checks exist twice now, and two
copies of a validation drift.
And the framing is asserted field by field: magic, version, codec, both
lengths, the payload, the total. A block on a device outlives the process that
wrote it, so the framing is a durable format, and the copy was only removable
because the bytes written do not change.
Planted both, one at a time, and each test catches its own defect and not the
other's:
dropping the original-length check from the shared decode
the agreement test FAILS, the framing test passes
writing the two length fields the other way round
the framing test FAILS, the agreement test passes
The second of those is why the framing test checks a compressed block as well.
Planted against the raw block alone it passed: a raw block stores the value as
it is, so its two lengths are equal by construction and swapping them writes
the same bytes. The field that tells the two apart had to be checked on a
block where they differ.
The owning refill is gone with its last caller.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Three places on the tiered paths copied the payload for the sake of copying
it again.
Framing a block owned its payload first —
value.to_vec()— and thencopied that buffer into the block. Three of the four arms store the value as
it is, so three of the four paid two payload-sized allocations and two copies
to produce the one buffer anybody keeps.
A read that finds the entry below memory hands the bytes to two holders:
the tier it refills and the caller. It decoded into a
Vec, cloned theVecfor the tier, and let the tier build an
Arcfrom the clone — threeallocations for two holders. The batch read did the same once per key, then
cloned again for every position that had asked for that key.
get, promoting from a lower tierput, reaching the SSD tier (pure-Rust store)put, reaching the SSD tier (the other backend)One allocation and one kibibyte each, which is the payload — these are figures
for a 1 KiB value and the copies removed grow with it.
Two decode forms, and the read picks
A read that refills decodes straight into the handle the tier keeps and copies
once for the caller. A read that cannot refill — an SSD-only instance never
puts anything back into memory — decodes into the value it is going to return
and moves it out, so that configuration does not pay for a handle it has no
use for. Neither case is worse than it was; two are better.
The batch promoting read had no row at all
Every read row in the report was a memory hit until the previous change, and
that one measured a single read. It has a row now, bounded per arm because the
batched store read is the backend's own: 59.80 allocations for eight keys on
the pure-Rust store, 73.47 through the other, within 1,200 bytes of each
other.
Its per-key cost is dominated by work this change does not touch — the store
read, the admission, the eviction — so it is bounded absolutely rather than
held to the bookkeeping-per-key bound the resident batch rows are held to. A
promoting row is not a bookkeeping row, and holding it to that bound would be
asserting a property it does not have.
Both regime checks now ask for nine reads in ten to have reached a lower tier,
instead of allowing a fixed sixteen exceptions. The batch row starts with a
tier full of keys it is about to ask for, so a count of exceptions was a
guess; a share of the reads is what the check is about, and a fixture that
stopped demoting reads near zero either way.
Tested, and planted
The two decodes are asserted to answer the same for twelve shapes of
block — raw, compressed, empty, shorter than the header, no magic, no bytes, a
header cut short, a payload cut short, an unknown version, an unknown codec, a
lying original length, a lying payload length — accepting the same bytes or
rejecting with the same message. The header checks exist twice now, and two
copies of a validation drift.
The framing is asserted field by field: magic, version, codec, both lengths,
the payload, the total. A block on a device outlives the process that wrote
it, so the framing is a durable format, and the copy was only removable
because the bytes written do not change.
Planted one at a time, and each test catches its own defect and not the
other's:
The second of those is why the framing test checks a compressed block as
well. Planted against the raw block alone it passed: a raw block stores the
value as it is, so its two lengths are equal by construction and swapping them
writes the same bytes. The field that tells the two apart had to be checked on
a block where they differ.
Gates on both feature arms:
cargo fmt --check,clippy --all-targets -D warnings, both suites, the vocabulary check, and both reports with--require-passed.🤖 Generated with Claude Code