Skip to content

Frame a block, and decode one, with one copy of the payload - #192

Merged
matrixarkai merged 1 commit into
mainfrom
codec/one-copy-per-block
Oct 24, 2026
Merged

matrixarkai merged 1 commit into
mainfrom
codec/one-copy-per-block

Conversation

@matrixarkai

Copy link
Copy Markdown
Owner

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 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 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 per key, then
cloned again for every position that had asked for that key.

a 1 KiB value allocations bytes
get, promoting from a lower tier 13.00 → 12.00 25,334.0 → 24,310.0
put, reaching the SSD tier (pure-Rust store) 28.11 → 27.11 59,247.8 → 58,223.8
put, reaching the SSD tier (the other 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.

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:

planted agreement test framing test
the shared decode drops the original-length check fails passes
the two length fields are written the other way round passes fails

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

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>
@matrixarkai
matrixarkai merged commit bac09de into main Oct 24, 2026
7 checks passed
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.

2 participants