Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,12 @@ jobs:
# storage profile's, which hold the code to its golden corpus.
run: cargo test -p vitaminc-aead-frame --features serde

- name: bench smoke test (aead-frame storage)
# `cargo test` does not run bench targets, and the storage bench
# checks that every format reads each value back. Run as a test,
# Criterion runs each bench once, untimed, so those checks run.
run: cargo test -p vitaminc-aead-frame --features serde --bench storage

- name: test (equality term feature)
# The workspace run unifies hmac's dev-dependency features, so test
# prf alone without `normalize` as well as with it, and check that
Expand Down
177 changes: 177 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions packages/aead-frame/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,13 @@ vitaminc-encrypt = { path = "../encrypt" }
vitaminc-protected = { path = "../protected" }
chrono = { version = "0.4.44", default-features = false, features = ["std"] }
rust_decimal = { version = "1.41", default-features = false, features = ["std"] }
criterion = { version = "0.8", default-features = false, features = ["cargo_bench_support"] }
# Formats the bench compares the storage profile with.
borsh = { version = "1.8", features = ["derive"] }
rmp-serde = "1.3.1"

# The storage profile is behind `serde`, so the bench is too.
[[bench]]
name = "storage"
harness = false
required-features = ["serde"]
54 changes: 54 additions & 0 deletions packages/aead-frame/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,60 @@ cannot pin gigabytes. Duplicate map keys are refused, as the frame
refuses them. Errors are opaque ([`CodecError`], [`FrameError`]): they
carry no detail about the bytes.

## Performance

`benches/storage.rs` writes and reads five values with the storage profile, with the transport serde format, and, for comparison, with `serde_json`, MessagePack (`rmp_serde`) and Borsh, using Criterion:

```sh
cargo bench -p vitaminc-aead-frame --features serde --bench storage
```

MessagePack is written with field names (`rmp_serde::to_vec_named`), since the storage profile, the transport format and JSON all carry them. Borsh carries no field names and no kinds, only the values in order, so it shows what a format costs that carries nothing else. The storage profile and the transport format wipe their buffers as they grow and when they are dropped. The other three wipe nothing: they write into a plain `Vec<u8>`, so each time the buffer grows the old one is freed with its bytes still in it, and the result is freed unwiped when it is dropped. Wrapping their output in `Zeroizing` would cover only the drop, not the copies left as the buffer grows.

These results are from an Apple M4 (4 performance and 6 efficiency cores, 24 GB) on macOS 26.5 with rustc 1.98.1, on 2026-10-26, using Criterion's default settings. Each time is the median of 100 samples. A write includes dropping the result.

The record is an integer id, a name, an email, a flag, a float, a list of two tags and an optional integer.

Size of the bytes written:

| Value | Storage | Transport | JSON | MessagePack | Borsh |
| --- | --- | --- | --- | --- | --- |
| One record of 7 fields | 164 B | 159 B | 126 B | 98 B | 86 B |
| 1,000 records | 160 KB | 159 KB | 132 KB | 102 KB | 86 KB |
| Map of 1,000 text keys | 22.0 KB | 21.0 KB | 14.9 KB | 11.6 KB | 20.0 KB |
| 64 KiB of text | 65.5 KB | 65.5 KB | 65.5 KB | 65.5 KB | 65.5 KB |
| 64 KiB of bytes | 65.5 KB | 65.5 KB | 234 KB | 65.5 KB | 65.5 KB |

Time to write:

| Value | Storage | Transport | JSON | MessagePack | Borsh |
| --- | --- | --- | --- | --- | --- |
| One record of 7 fields | 986 ns | 1.09 µs | 198 ns | 337 ns | 72 ns |
| 1,000 records | 937 µs | 741 µs | 217 µs | 139 µs | 30.3 µs |
| Map of 1,000 text keys | 115 µs | 80.0 µs | 18.0 µs | 13.6 µs | 6.21 µs |
| 64 KiB of text | 35.5 µs | 33.8 µs | 33.8 µs | 1.93 µs | 2.03 µs |
| 64 KiB of bytes | 33.7 µs | 33.7 µs | 280 µs | 1.87 µs | 1.99 µs |

Time to read:

| Value | Storage | Transport | JSON | MessagePack | Borsh |
| --- | --- | --- | --- | --- | --- |
| One record of 7 fields | 1.35 µs | 1.41 µs | 440 ns | 313 ns | 196 ns |
| 1,000 records | 1.48 ms | 1.47 ms | 489 µs | 467 µs | 230 µs |
| Map of 1,000 text keys | 268 µs | 303 µs | 216 µs | 202 µs | 85.4 µs |
| 64 KiB of text | 8.48 µs | 8.49 µs | 11.6 µs | 4.14 µs | 6.16 µs |
| 64 KiB of bytes | 1.91 µs | 1.99 µs | 881 µs | 1.88 µs | 2.85 µs |

Most results held within a few percent of their median. Two did not: the MessagePack read of 1,000 records ranged from 387 to 567 µs, and the transport write of 1,000 records from 724 to 773 µs.

What the numbers show:

- Writing a large value is limited by the wipe, not by the format. MessagePack and Borsh write 64 KiB in about 2 µs; the storage profile takes 33.7 µs, and wiping the 64 KiB buffer is 31.5 µs of that, because `zeroize` wipes one byte at a time with volatile stores, so the compiler cannot remove the wipe. The transport format has the same cost. JSON writes no wipe, but writes bytes as a list of numbers, 3.6 times as large and 8 times as slow.
- Reading text or bytes costs little more than copying them out: 64 KiB of bytes reads at about 32 GiB/s, as fast as MessagePack.
- On values with many small parts, the storage profile is the slowest of the five. It writes 1,000 records 6.7 times as slowly as MessagePack and 31 times as slowly as Borsh, and reads them 3.2 and 6.4 times as slowly. MessagePack carries the same field names, so the names are not the cost; each element or field costs the storage writer about 40 to 90 ns more. This is the clearest place to look for a speedup.
- The storage profile is also the largest: 1,000 records take 57% more bytes than in MessagePack, and the map 89% more. Every length and count is a fixed four bytes and every value has a kind byte, where MessagePack packs small lengths and integers into one byte. Borsh is the smallest for records, but writes an integer at its full width, so the map of `u64` values is nearly as large as in the storage profile.
- A storage write costs more than a transport write on values with many parts, 26% more for 1,000 records and 43% more for the map, because it reads the blob back to find a repeated key before it returns it. Storage and transport reads are within about 12% of each other.

## Tests

`tests/conformance.rs` runs the shared binding corpus,
Expand Down
Loading
Loading