Skip to content

Commit 0ea8f9b

Browse files
ELaresclaude
andauthored
perf(store): shrink the per-key object - box ValueRepr variants (mem rounds 1-2) (#262)
Box the collection + embstr ValueRepr variants: ValueRepr 72->24, KvObj 112->64, table slot 128->80. vs redis 8.8.0: bytes-per-key 526.7->386.85 (gap 2.41x->1.77x), qps 71.4k->77.9k. Zero behavior change (full suite green); perf-gate verified the memory drop (-17 to -35% per class) with no qps regression. Next lever (single-alloc blob entry) scoped in OPTIMIZATION_LOG. Signed-off-by: Zeke <ezequiel.lares@outlook.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d772216 commit 0ea8f9b

6 files changed

Lines changed: 205 additions & 93 deletions

File tree

crates/ironcache-bench/src/lib.rs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,14 @@
2222
//! all three so bytes-per-key is reported per class:
2323
//!
2424
//! - [`EncodingClass::Int`]: a canonical i64 in decimal (e.g. `"12345"`). Stored
25-
//! with NO value heap allocation (the integer lives inline in the object).
26-
//! Note its per-key footprint equals embstr's: the stored-value enum is sized
27-
//! for its largest inline variant, so int saves a heap allocation but not slot
28-
//! bytes. `OBJECT ENCODING` -> `int`.
25+
//! inline in the per-key object's value enum with NO value heap allocation (the
26+
//! `int` variant is the only string-family variant that does not box its bytes).
27+
//! `OBJECT ENCODING` -> `int`.
2928
//! - [`EncodingClass::EmbStr`]: a short string at or below the embstr threshold
30-
//! (44 bytes, [`ironcache_store::encoding::EMBSTR_THRESHOLD`]), stored inline in
31-
//! the object (SSO). `OBJECT ENCODING` -> `embstr`.
29+
//! (44 bytes, [`ironcache_store::encoding::EMBSTR_THRESHOLD`]), stored in a single
30+
//! boxed value allocation (memory Round 2 shrank the per-key slot by boxing the
31+
//! embstr bytes rather than carrying a fixed inline buffer). `OBJECT ENCODING` ->
32+
//! `embstr`.
3233
//! - [`EncodingClass::Raw`]: a longer string stored out-of-line (a separate heap
3334
//! allocation). `OBJECT ENCODING` -> `raw`.
3435

crates/ironcache-server/src/dispatch.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2372,7 +2372,7 @@ mod tests {
23722372
ttl_present: false,
23732373
snapshot_version: 0,
23742374
};
2375-
obj.value = ValueRepr::Inline(ironcache_store::kvobj::InlineBuf::from_bytes(b"x"));
2375+
obj.value = ValueRepr::Inline(Box::from(&b"x"[..]));
23762376
st.insert_object(0, obj);
23772377

23782378
// GET / STRLEN / GETSET against the non-string -> WRONGTYPE.
@@ -2421,7 +2421,7 @@ mod tests {
24212421
ttl_present: false,
24222422
snapshot_version: 0,
24232423
};
2424-
obj.value = ValueRepr::Inline(ironcache_store::kvobj::InlineBuf::from_bytes(b"x"));
2424+
obj.value = ValueRepr::Inline(Box::from(&b"x"[..]));
24252425
st.insert_object(0, obj);
24262426

24272427
// MGET str missing lst -> [bulk("hi"), Null, Null]. The non-string yields Null,
@@ -2726,7 +2726,7 @@ mod tests {
27262726
ttl_present: false,
27272727
snapshot_version: 0,
27282728
};
2729-
obj.value = ValueRepr::Inline(ironcache_store::kvobj::InlineBuf::from_bytes(b"x"));
2729+
obj.value = ValueRepr::Inline(Box::from(&b"x"[..]));
27302730
st.insert_object(0, obj);
27312731
assert_eq!(
27322732
err_line(run_on(&c, &mut s, &mut st, t, &[b"INCR", b"lst"])),
@@ -2769,7 +2769,7 @@ mod tests {
27692769
ttl_present: false,
27702770
snapshot_version: 0,
27712771
};
2772-
obj.value = ValueRepr::Inline(ironcache_store::kvobj::InlineBuf::from_bytes(b"x"));
2772+
obj.value = ValueRepr::Inline(Box::from(&b"x"[..]));
27732773
st.insert_object(0, obj);
27742774
assert_eq!(
27752775
err_line(run_on(
@@ -4196,7 +4196,7 @@ mod tests {
41964196
ttl_present: false,
41974197
snapshot_version: 0,
41984198
};
4199-
obj.value = ValueRepr::Inline(ironcache_store::kvobj::InlineBuf::from_bytes(b"x"));
4199+
obj.value = ValueRepr::Inline(Box::from(&b"x"[..]));
42004200
st.insert_object(0, obj);
42014201
match run_on_wheel(&c, &mut s, &mut st, &mut wheel, t, &[b"GETEX", b"lst"]) {
42024202
Value::Error(e) => assert_eq!(

crates/ironcache-store/src/kvobj.rs

Lines changed: 46 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
//! command layer. The folded-header metadata (below) is laid out as the FAM
2525
//! version will pack it, so that follow-up is a representation change only.
2626
27-
use crate::encoding::{Classified, EMBSTR_THRESHOLD, classify};
27+
use crate::encoding::{Classified, classify};
2828
use crate::scan_hash;
2929
use bytes::Bytes;
3030
use hashbrown::HashMap;
@@ -41,12 +41,6 @@ use ironcache_storage::{
4141
};
4242
use std::collections::{BTreeSet, VecDeque};
4343

44-
/// The inline-value buffer capacity (embstr). Matches [`EMBSTR_THRESHOLD`]; a
45-
/// value classified as embstr fits here without a separate allocation in the
46-
/// eventual FAM layout. In the safe rep it is a fixed-size inline array plus a
47-
/// length, so an embstr value adds no heap allocation beyond the `KvObj` itself.
48-
pub const INLINE_CAP: usize = EMBSTR_THRESHOLD;
49-
5044
/// The packed per-key header (OBJECT_LAYOUT.md "packed header and metadata bits").
5145
///
5246
/// In the eventual FAM layout these fields are bit-packed into a few bytes (type +
@@ -101,66 +95,53 @@ impl Header {
10195
}
10296
}
10397

104-
/// A small inline string buffer ([`INLINE_CAP`] bytes plus a length), the safe-rep
105-
/// stand-in for the FAM inline-value region. An embstr value lives here with no
106-
/// extra heap allocation.
107-
#[derive(Debug, Clone)]
108-
pub struct InlineBuf {
109-
buf: [u8; INLINE_CAP],
110-
len: u8,
111-
}
112-
113-
impl InlineBuf {
114-
/// Build from bytes that are known to fit ([`INLINE_CAP`]). Panics if too long;
115-
/// callers (the classifier path) only construct this for embstr-sized values.
116-
#[must_use]
117-
pub fn from_bytes(bytes: &[u8]) -> Self {
118-
assert!(
119-
bytes.len() <= INLINE_CAP,
120-
"InlineBuf overflow: {} > {INLINE_CAP}",
121-
bytes.len()
122-
);
123-
let mut buf = [0u8; INLINE_CAP];
124-
buf[..bytes.len()].copy_from_slice(bytes);
125-
InlineBuf {
126-
buf,
127-
len: bytes.len() as u8,
128-
}
129-
}
130-
131-
/// The inline bytes.
132-
#[must_use]
133-
pub fn as_bytes(&self) -> &[u8] {
134-
&self.buf[..self.len as usize]
135-
}
136-
}
137-
13898
/// The value representation inside a [`KvObj`] (ENCODINGS.md #112).
13999
#[derive(Debug, Clone)]
140100
pub enum ValueRepr {
141101
/// An int-encoded value: the raw i64, NO value allocation (the decimal bytes
142102
/// are materialized on read). `OBJECT ENCODING` -> int.
143103
Int(i64),
144-
/// A short string stored inline. `OBJECT ENCODING` -> embstr.
145-
Inline(InlineBuf),
104+
/// A short string (embstr). `OBJECT ENCODING` -> embstr.
105+
///
106+
/// BOXED (memory Round 2): the bytes live behind a `Box<[u8]>` rather than a
107+
/// fixed inline buffer, so this variant is one pointer wide and the largest
108+
/// `ValueRepr` variant shrinks to a `Box<[u8]>`, cutting every per-key `KvObj`
109+
/// and the hashbrown table slot. The embstr-vs-raw distinction is the SAME (it is
110+
/// recorded in [`Header::encoding`], NOT by the variant): a value classified as
111+
/// embstr by [`crate::encoding::EMBSTR_THRESHOLD`] is `Inline`, a longer one is
112+
/// [`ValueRepr::Raw`],
113+
/// and `OBJECT ENCODING` reports `embstr` / `raw` exactly as before. Redis/Valkey
114+
/// also heap-allocate the object body, so this is allocation-parity with redis
115+
/// plus a smaller slot.
116+
Inline(Box<[u8]>),
146117
/// A long string stored out-of-line. `OBJECT ENCODING` -> raw.
147118
Raw(Box<[u8]>),
148119
/// A LIST value (PR-5). `OBJECT ENCODING` -> `listpack` while small, `quicklist`
149120
/// once over the threshold (a pure function of the active repr, #40).
150-
List(ListVal),
121+
///
122+
/// BOXED (memory Round 1): the four collection structs are the large `ValueRepr`
123+
/// variants (`ListVal` 40 / `HashVal` 40 / `SetVal` 48 / `ZSetVal` 64). Holding them
124+
/// behind a `Box` drops `ValueRepr` to the string-variant bound, which shrinks every
125+
/// per-key `KvObj` and the hashbrown table slot. The string/int hot path
126+
/// (`Int`/`Inline`/`Raw`) is UNBOXED so the embstr SSO is untouched; the collections
127+
/// already heap-allocate their contents, so the `Box` is a negligible extra
128+
/// indirection only on collection ops.
129+
List(Box<ListVal>),
151130
/// A HASH value (PR-6). `OBJECT ENCODING` -> `listpack` while small, `hashtable`
152131
/// once over the entry-count OR per-element-byte threshold (a pure function of the
153-
/// active repr, #40).
154-
Hash(HashVal),
132+
/// active repr, #40). BOXED (memory Round 1); see [`ValueRepr::List`].
133+
Hash(Box<HashVal>),
155134
/// A SET value (PR-7). `OBJECT ENCODING` -> `intset` while all-integer and small,
156135
/// `listpack` once a non-integer member is added (and still small), `hashtable` once
157136
/// over the entry-count OR per-member-byte threshold (a pure function of the active
158-
/// repr, #40). The conversion is ONE-WAY (never demotes).
159-
Set(SetVal),
137+
/// repr, #40). The conversion is ONE-WAY (never demotes). BOXED (memory Round 1);
138+
/// see [`ValueRepr::List`].
139+
Set(Box<SetVal>),
160140
/// A ZSET (sorted set) value (PR-8). `OBJECT ENCODING` -> `listpack` while small,
161141
/// `skiplist` once over the entry-count OR per-member-byte threshold (a pure function
162-
/// of the active repr, #40). The conversion is ONE-WAY (never demotes).
163-
ZSet(ZSetVal),
142+
/// of the active repr, #40). The conversion is ONE-WAY (never demotes). BOXED
143+
/// (memory Round 1); see [`ValueRepr::List`].
144+
ZSet(Box<ZSetVal>),
164145
}
165146

166147
impl ValueRepr {
@@ -187,8 +168,9 @@ impl ValueRepr {
187168
pub fn logical_len(&self) -> usize {
188169
match self {
189170
ValueRepr::Int(n) => int_decimal_len(*n),
190-
ValueRepr::Inline(b) => b.as_bytes().len(),
191-
ValueRepr::Raw(b) => b.len(),
171+
// Embstr and raw both hold the value bytes behind a `Box<[u8]>`; the
172+
// embstr-vs-raw distinction lives in `Header.encoding`, not the variant.
173+
ValueRepr::Inline(b) | ValueRepr::Raw(b) => b.len(),
192174
ValueRepr::List(l) => l.element_bytes(),
193175
ValueRepr::Hash(h) => h.element_bytes(),
194176
ValueRepr::Set(s) => s.element_bytes(),
@@ -1714,7 +1696,7 @@ impl KvObj {
17141696
) -> Self {
17151697
let value = match classified {
17161698
Classified::Int(n) => ValueRepr::Int(n),
1717-
Classified::EmbStr => ValueRepr::Inline(InlineBuf::from_bytes(bytes)),
1699+
Classified::EmbStr => ValueRepr::Inline(bytes.to_vec().into_boxed_slice()),
17181700
Classified::Raw => ValueRepr::Raw(bytes.to_vec().into_boxed_slice()),
17191701
};
17201702
let header = Header::new(value.encoding(), expire_at.is_some());
@@ -1800,7 +1782,7 @@ impl KvObj {
18001782
KvObj {
18011783
header: Header::with_type(DataType::List, encoding, expire_at.is_some()),
18021784
key: key.to_vec().into_boxed_slice(),
1803-
value: ValueRepr::List(list),
1785+
value: ValueRepr::List(Box::new(list)),
18041786
expire_at,
18051787
}
18061788
}
@@ -1814,7 +1796,7 @@ impl KvObj {
18141796
KvObj {
18151797
header: Header::with_type(DataType::Hash, encoding, expire_at.is_some()),
18161798
key: key.to_vec().into_boxed_slice(),
1817-
value: ValueRepr::Hash(hash),
1799+
value: ValueRepr::Hash(Box::new(hash)),
18181800
expire_at,
18191801
}
18201802
}
@@ -1828,7 +1810,7 @@ impl KvObj {
18281810
KvObj {
18291811
header: Header::with_type(DataType::Set, encoding, expire_at.is_some()),
18301812
key: key.to_vec().into_boxed_slice(),
1831-
value: ValueRepr::Set(set),
1813+
value: ValueRepr::Set(Box::new(set)),
18321814
expire_at,
18331815
}
18341816
}
@@ -1842,7 +1824,7 @@ impl KvObj {
18421824
KvObj {
18431825
header: Header::with_type(DataType::ZSet, encoding, expire_at.is_some()),
18441826
key: key.to_vec().into_boxed_slice(),
1845-
value: ValueRepr::ZSet(zset),
1827+
value: ValueRepr::ZSet(Box::new(zset)),
18461828
expire_at,
18471829
}
18481830
}
@@ -1861,7 +1843,9 @@ impl KvObj {
18611843
/// yields `None` -> WRONGTYPE).
18621844
pub fn as_list_mut(&mut self) -> Option<&mut ListVal> {
18631845
match &mut self.value {
1864-
ValueRepr::List(l) => Some(l),
1846+
// Deref through the `Box` (memory Round 1) to the `&mut ListVal` the
1847+
// collection trait + in-place RMW path expect.
1848+
ValueRepr::List(l) => Some(&mut **l),
18651849
_ => None,
18661850
}
18671851
}
@@ -1871,7 +1855,7 @@ impl KvObj {
18711855
/// `None` -> WRONGTYPE). The HASH analog of [`Self::as_list_mut`].
18721856
pub fn as_hash_mut(&mut self) -> Option<&mut HashVal> {
18731857
match &mut self.value {
1874-
ValueRepr::Hash(h) => Some(h),
1858+
ValueRepr::Hash(h) => Some(&mut **h),
18751859
_ => None,
18761860
}
18771861
}
@@ -1881,7 +1865,7 @@ impl KvObj {
18811865
/// -> WRONGTYPE). The SET analog of [`Self::as_list_mut`]/[`Self::as_hash_mut`].
18821866
pub fn as_set_mut(&mut self) -> Option<&mut SetVal> {
18831867
match &mut self.value {
1884-
ValueRepr::Set(s) => Some(s),
1868+
ValueRepr::Set(s) => Some(&mut **s),
18851869
_ => None,
18861870
}
18871871
}
@@ -1892,7 +1876,7 @@ impl KvObj {
18921876
/// [`Self::as_set_mut`].
18931877
pub fn as_zset_mut(&mut self) -> Option<&mut ZSetVal> {
18941878
match &mut self.value {
1895-
ValueRepr::ZSet(z) => Some(z),
1879+
ValueRepr::ZSet(z) => Some(&mut **z),
18961880
_ => None,
18971881
}
18981882
}
@@ -1972,7 +1956,7 @@ impl KvObj {
19721956
pub fn set_value_bytes(&mut self, bytes: &[u8]) {
19731957
self.value = match classify(bytes) {
19741958
Classified::Int(n) => ValueRepr::Int(n),
1975-
Classified::EmbStr => ValueRepr::Inline(InlineBuf::from_bytes(bytes)),
1959+
Classified::EmbStr => ValueRepr::Inline(bytes.to_vec().into_boxed_slice()),
19761960
Classified::Raw => ValueRepr::Raw(bytes.to_vec().into_boxed_slice()),
19771961
};
19781962
self.header.encoding = self.value.encoding();

crates/ironcache-store/src/lib.rs

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -576,13 +576,9 @@ impl<E: EvictionHook, A: AccountingHook> ShardStore<E, A> {
576576
kvobj::ValueRepr::Int(n) => {
577577
ValueRef::from_int_bytes(obj.header.data_type, obj.expire_at, int_decimal_bytes(*n))
578578
}
579-
kvobj::ValueRepr::Inline(b) => ValueRef::borrowed(
580-
obj.header.data_type,
581-
obj.header.encoding,
582-
obj.expire_at,
583-
b.as_bytes(),
584-
),
585-
kvobj::ValueRepr::Raw(b) => {
579+
// Embstr and raw both borrow their bytes the same way; the embstr-vs-raw
580+
// distinction is carried by `obj.header.encoding`, not the variant.
581+
kvobj::ValueRepr::Inline(b) | kvobj::ValueRepr::Raw(b) => {
586582
ValueRef::borrowed(obj.header.data_type, obj.header.encoding, obj.expire_at, b)
587583
}
588584
// A LIST/HASH/SET is not byte-readable as a string: the command layer only
@@ -612,13 +608,9 @@ impl<E: EvictionHook, A: AccountingHook> ShardStore<E, A> {
612608
obj.expire_at,
613609
int_decimal_bytes(*n),
614610
),
615-
kvobj::ValueRepr::Inline(b) => OccupiedEntry::borrowed(
616-
obj.header.data_type,
617-
obj.header.encoding,
618-
obj.expire_at,
619-
b.as_bytes(),
620-
),
621-
kvobj::ValueRepr::Raw(b) => {
611+
// Embstr and raw both borrow their bytes the same way; the embstr-vs-raw
612+
// distinction is carried by `obj.header.encoding`, not the variant.
613+
kvobj::ValueRepr::Inline(b) | kvobj::ValueRepr::Raw(b) => {
622614
OccupiedEntry::borrowed(obj.header.data_type, obj.header.encoding, obj.expire_at, b)
623615
}
624616
// A LIST/HASH/SET observed through the READ-ONLY rmw arm (e.g. a numeric RMW
@@ -823,17 +815,20 @@ impl<E: EvictionHook, A: AccountingHook> Store for ShardStore<E, A> {
823815
// would each take and drop a fresh `&mut` and obscure the dispatch) so each
824816
// collection type maps to exactly one arm.
825817
let entry = match &mut obj.value {
818+
// The collection variants are boxed (memory Round 1); deref through the
819+
// `Box` (`&mut **`) to the concrete `&mut *Val`, which then coerces to the
820+
// `&mut dyn *Value` trait object the typed view constructors take.
826821
kvobj::ValueRepr::List(l) => {
827-
RmwEntry::OccupiedMut(OccupiedEntryMut::list(encoding, expire_at, l))
822+
RmwEntry::OccupiedMut(OccupiedEntryMut::list(encoding, expire_at, &mut **l))
828823
}
829824
kvobj::ValueRepr::Hash(h) => {
830-
RmwEntry::OccupiedMut(OccupiedEntryMut::hash(encoding, expire_at, h))
825+
RmwEntry::OccupiedMut(OccupiedEntryMut::hash(encoding, expire_at, &mut **h))
831826
}
832827
kvobj::ValueRepr::Set(s) => {
833-
RmwEntry::OccupiedMut(OccupiedEntryMut::set(encoding, expire_at, s))
828+
RmwEntry::OccupiedMut(OccupiedEntryMut::set(encoding, expire_at, &mut **s))
834829
}
835830
kvobj::ValueRepr::ZSet(z) => {
836-
RmwEntry::OccupiedMut(OccupiedEntryMut::zset(encoding, expire_at, z))
831+
RmwEntry::OccupiedMut(OccupiedEntryMut::zset(encoding, expire_at, &mut **z))
837832
}
838833
kvobj::ValueRepr::Int(_)
839834
| kvobj::ValueRepr::Inline(_)

crates/ironcache-store/tests/keyspace.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use ironcache_storage::{
1313
UnixMillis,
1414
};
1515
use ironcache_store::ShardStore;
16-
use ironcache_store::kvobj::{Header, InlineBuf, KvObj, ValueRepr};
16+
use ironcache_store::kvobj::{Header, KvObj, ValueRepr};
1717
use std::collections::HashSet;
1818

1919
const NOW: UnixMillis = UnixMillis(1_000);
@@ -256,7 +256,7 @@ fn scan_type_filter_selects_by_data_type() {
256256
ttl_present: false,
257257
snapshot_version: 0,
258258
};
259-
lst.value = ValueRepr::Inline(InlineBuf::from_bytes(b"x"));
259+
lst.value = ValueRepr::Inline(Box::from(&b"x"[..]));
260260
s.insert_object(0, lst);
261261

262262
let mut out = Vec::new();

0 commit comments

Comments
 (0)