diff --git a/Cargo.toml b/Cargo.toml index d044cea1..c9221c52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,8 @@ exclude = ["website/*"] [workspace.dependencies] allocator-api2 = "0.2" anyhow = "1" -bincode = "1" +borsh = { version = "1", features = ["derive"] } +postcard = "1" bitflags = "2" bytes = "1" bytesize = { package = "foyer-bytesize", version = "2" } diff --git a/examples/Cargo.toml b/examples/Cargo.toml index 8fbba2ea..1634ee51 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -15,6 +15,7 @@ publish = false [features] serde = ["foyer/serde"] +borsh = ["foyer/borsh"] jaeger = ["fastrace-jaeger"] ot = [ "fastrace-opentelemetry", diff --git a/foyer-common/Cargo.toml b/foyer-common/Cargo.toml index feecc273..98f4ae30 100644 --- a/foyer-common/Cargo.toml +++ b/foyer-common/Cargo.toml @@ -18,19 +18,21 @@ development = ["criterion", "serde_bytes"] [features] default = ["tokio/runtime-tokio"] -serde = ["dep:serde", "dep:bincode"] +serde = ["dep:serde", "dep:postcard"] +borsh = ["dep:borsh"] strict_assertions = [] tracing = ["dep:fastrace"] [dependencies] anyhow = { workspace = true } -bincode = { workspace = true, optional = true } +borsh = { workspace = true, optional = true, features = ["derive"] } bytes = { workspace = true } cfg-if = { workspace = true } fastrace = { workspace = true, optional = true } mixtrics = { workspace = true } parking_lot = { workspace = true } pin-project = { workspace = true } +postcard = { workspace = true, optional = true, features = ["use-std"] } serde = { workspace = true, optional = true } tokio = { workspace = true, features = ["rt"] } twox-hash = { workspace = true, features = ["xxhash64"] } diff --git a/foyer-common/benches/bench_serde/borsh.rs b/foyer-common/benches/bench_serde/borsh.rs new file mode 100644 index 00000000..f958f712 --- /dev/null +++ b/foyer-common/benches/bench_serde/borsh.rs @@ -0,0 +1,59 @@ +// Copyright 2026 foyer Project Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use borsh::{BorshDeserialize, BorshSerialize}; +use criterion::Criterion; + +use crate::{Entry, run_encode_decode_bench}; + +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +struct BorshEntry { + id: u64, + label: String, + payload: Vec, +} + +impl From for BorshEntry { + fn from(e: Entry) -> Self { + Self { + id: e.id, + label: e.label, + payload: e.payload, + } + } +} + +impl From for Entry { + fn from(e: BorshEntry) -> Self { + Self { + id: e.id, + label: e.label, + payload: e.payload, + } + } +} + +fn borsh_encode(entry: &Entry, buf: &mut Vec) { + let be: BorshEntry = entry.clone().into(); + borsh::to_writer(buf, &be).unwrap(); +} + +fn borsh_decode(bytes: &[u8]) -> Entry { + let be: BorshEntry = borsh::from_reader(&mut &bytes[..]).unwrap(); + be.into() +} + +pub fn bench(c: &mut Criterion) { + run_encode_decode_bench(c, "borsh", borsh_encode, borsh_decode, Entry::create); +} diff --git a/foyer-common/benches/bench_serde/main.rs b/foyer-common/benches/bench_serde/main.rs index 98831b7c..af5020da 100644 --- a/foyer-common/benches/bench_serde/main.rs +++ b/foyer-common/benches/bench_serde/main.rs @@ -16,45 +16,130 @@ use std::time::Instant; -use criterion::Bencher; -use foyer_common::code::StorageValue; +use criterion::{Bencher, Criterion, criterion_group, criterion_main}; +#[cfg(feature = "borsh")] +mod borsh; +mod manual; #[cfg(feature = "serde")] -mod serde; -#[cfg(feature = "serde")] -criterion::criterion_group!(benches, serde::bench_encode, serde::bench_decode); -#[cfg(feature = "serde")] -criterion::criterion_main!(benches); +mod postcard; -#[cfg(not(feature = "serde"))] -mod no_serde; -#[cfg(not(feature = "serde"))] -criterion::criterion_group!(benches, no_serde::bench_encode, no_serde::bench_decode); -#[cfg(not(feature = "serde"))] -criterion::criterion_main!(benches); +/// A representative cache entry: an id, a label, and a payload. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Entry { + pub id: u64, + pub label: String, + pub payload: Vec, +} + +impl Entry { + pub fn create(payload_size: usize, label_len: usize) -> Self { + let mut payload = vec![0; payload_size]; + rand::fill(&mut payload[..]); + let label = "x".repeat(label_len); + Self { + id: rand::random(), + label, + payload, + } + } +} const K: usize = 1 << 10; const M: usize = 1 << 20; -fn encode(b: &mut Bencher, v: V, size: usize) { +/// Sizes to benchmark: (label, payload_size, label_len). +const SIZES: &[(&str, usize, usize)] = &[("64KiB", 64 * K, 8), ("4MiB", 4 * M, 8), ("64MiB", 64 * M, 8)]; + +/// Helper: measure encode throughput for a given value. +fn bench_encode(b: &mut Bencher, encode: fn(&V, &mut Vec), v: V, cap: usize) +where + V: Clone, +{ b.iter_custom(|iters| { - let mut buf = vec![0; size * 2]; + let mut buf = Vec::with_capacity(cap); let start = Instant::now(); for _ in 0..iters { - v.encode(&mut &mut buf[..]).unwrap(); + buf.clear(); + encode(&v, &mut buf); } start.elapsed() }); } -fn decode(b: &mut Bencher, v: V, size: usize) { +/// Helper: measure decode throughput for a given encoded buffer. +fn bench_decode(b: &mut Bencher, encode: fn(&V, &mut Vec), decode: fn(&[u8]) -> V, v: V, cap: usize) { + let mut buf = Vec::with_capacity(cap); + encode(&v, &mut buf); b.iter_custom(|iters| { - let mut buf = vec![0; size * 2]; - v.encode(&mut &mut buf[..]).unwrap(); let start = Instant::now(); for _ in 0..iters { - V::decode(&mut &buf[..]).unwrap(); + std::hint::black_box(decode(&buf)); } start.elapsed() }); } + +/// Helper: get the encoded size for a given value. +fn encoded_size(encode: fn(&V, &mut Vec), v: &V) -> usize { + let mut buf = Vec::new(); + encode(v, &mut buf); + buf.len() +} + +/// Run encode + decode benchmarks for a given backend, and print encoded sizes. +pub fn run_encode_decode_bench( + c: &mut Criterion, + group: &str, + encode: fn(&V, &mut Vec), + decode: fn(&[u8]) -> V, + create: fn(usize, usize) -> V, +) { + { + let mut grp = c.benchmark_group(format!("{group}/encode")); + for (label, size, label_len) in SIZES { + let entry = create(*size, *label_len); + let cap = size + label_len + 4096; + grp.bench_function(*label, |b| { + bench_encode(b, encode, entry.clone(), cap); + }); + } + grp.finish(); + } + + { + let mut grp = c.benchmark_group(format!("{group}/decode")); + for (label, size, label_len) in SIZES { + let entry = create(*size, *label_len); + let cap = size + label_len + 4096; + grp.bench_function(*label, |b| { + bench_decode(b, encode, decode, entry.clone(), cap); + }); + } + grp.finish(); + } + + // Print encoded sizes (appears in benchmark output). + println!("--- {group} encoded sizes ---"); + for (label, size, label_len) in SIZES { + let entry = create(*size, *label_len); + let enc_size = encoded_size(encode, &entry); + let raw_size = std::mem::size_of::() + *label_len + *size; + println!( + "[{group}] {label}: encoded={enc_size}B raw={raw_size}B ratio={:.2}%", + (enc_size as f64 / raw_size as f64) * 100.0 + ); + } +} + +fn bench_all(c: &mut Criterion) { + #[cfg(feature = "borsh")] + borsh::bench(c); + #[cfg(feature = "serde")] + postcard::bench(c); + manual::bench(c); +} + +criterion_group!(benches, bench_all); +criterion_main!(benches); diff --git a/foyer-common/benches/bench_serde/manual.rs b/foyer-common/benches/bench_serde/manual.rs new file mode 100644 index 00000000..698e00c5 --- /dev/null +++ b/foyer-common/benches/bench_serde/manual.rs @@ -0,0 +1,50 @@ +// Copyright 2026 foyer Project Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use criterion::Criterion; + +use crate::{Entry, run_encode_decode_bench}; + +/// Manual little-endian encoding: u64 (8 bytes LE) + label_len (u64 LE) + label bytes + payload_len (u64 LE) + payload +/// bytes. +fn manual_encode(entry: &Entry, buf: &mut Vec) { + buf.extend_from_slice(&entry.id.to_le_bytes()); + buf.extend_from_slice(&(entry.label.len() as u64).to_le_bytes()); + buf.extend_from_slice(entry.label.as_bytes()); + buf.extend_from_slice(&(entry.payload.len() as u64).to_le_bytes()); + buf.extend_from_slice(&entry.payload); +} + +fn manual_decode(bytes: &[u8]) -> Entry { + let (id_bytes, rest) = bytes.split_at(8); + let id = u64::from_le_bytes(id_bytes.try_into().unwrap()); + + let (label_len_bytes, rest) = rest.split_at(8); + let label_len = u64::from_le_bytes(label_len_bytes.try_into().unwrap()) as usize; + + let (label_bytes, rest) = rest.split_at(label_len); + let label = String::from_utf8(label_bytes.to_vec()).unwrap(); + + let (payload_len_bytes, rest) = rest.split_at(8); + let payload_len = u64::from_le_bytes(payload_len_bytes.try_into().unwrap()) as usize; + + let (payload_bytes, _) = rest.split_at(payload_len); + let payload = payload_bytes.to_vec(); + + Entry { id, label, payload } +} + +pub fn bench(c: &mut Criterion) { + run_encode_decode_bench(c, "manual", manual_encode, manual_decode, Entry::create); +} diff --git a/foyer-common/benches/bench_serde/postcard.rs b/foyer-common/benches/bench_serde/postcard.rs new file mode 100644 index 00000000..c6eb8b16 --- /dev/null +++ b/foyer-common/benches/bench_serde/postcard.rs @@ -0,0 +1,30 @@ +// Copyright 2026 foyer Project Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use criterion::Criterion; + +use crate::{Entry, run_encode_decode_bench}; + +fn postcard_encode(entry: &Entry, buf: &mut Vec) { + postcard::to_io(entry, buf).unwrap(); +} + +fn postcard_decode(bytes: &[u8]) -> Entry { + let (entry, _remaining): (Entry, &[u8]) = postcard::take_from_bytes(bytes).unwrap(); + entry +} + +pub fn bench(c: &mut Criterion) { + run_encode_decode_bench(c, "postcard", postcard_encode, postcard_decode, Entry::create); +} diff --git a/foyer-common/src/code.rs b/foyer-common/src/code.rs index 2a399b58..51a118c2 100644 --- a/foyer-common/src/code.rs +++ b/foyer-common/src/code.rs @@ -57,18 +57,18 @@ pub trait Code { /// /// NOTE: /// - /// When implementing [`Code`], if [`std::io::Error`] or `bincode::Error` occurs during encoding, - /// please use [`Error::io_error`] or `Error::bincode_error` to convert it into [`Error`], - /// instead of manually creating an [`Error`].. + /// When implementing [`Code`], if [`std::io::Error`] or a serialization error occurs during encoding, + /// please use [`Error::io_error`] or `Error::postcard_error` to convert it into [`Error`], + /// instead of manually creating an [`Error`]. fn encode(&self, writer: &mut impl std::io::Write) -> Result<()>; /// Decode the object from a reader. /// /// NOTE: /// - /// When implementing [`Code`], if [`std::io::Error`] or `bincode::Error` occurs during decoding, - /// please use [`Error::io_error`] or `Error::bincode_error` to convert it into [`Error`], - /// instead of manually creating an [`Error`].. + /// When implementing [`Code`], if [`std::io::Error`] or a serialization error occurs during decoding, + /// please use [`Error::io_error`] or `Error::postcard_error` to convert it into [`Error`], + /// instead of manually creating an [`Error`]. fn decode(reader: &mut impl std::io::Read) -> Result where Self: Sized; @@ -79,28 +79,58 @@ pub trait Code { fn estimated_size(&self) -> usize; } -#[cfg(feature = "serde")] +/// Blanket implementation of [`Code`] for types implementing [`borsh::BorshSerialize`] and +/// [`borsh::BorshDeserialize`]. +/// +/// This takes priority over the `serde`-based blanket implementation when both +/// the `borsh` and `serde` features are enabled. +#[cfg(feature = "borsh")] +impl Code for T +where + T: borsh::BorshSerialize + borsh::BorshDeserialize, +{ + fn encode(&self, writer: &mut impl std::io::Write) -> Result<()> { + borsh::to_writer(writer, self).map_err(Error::io_error) + } + + fn decode(reader: &mut impl std::io::Read) -> Result { + borsh::from_reader(reader).map_err(Error::io_error) + } + + fn estimated_size(&self) -> usize { + borsh::object_length(self).unwrap_or(0) + } +} + +/// Blanket implementation of [`Code`] for types implementing [`serde::Serialize`] and +/// [`serde::de::DeserializeOwned`] via the [`postcard`] serialization format. +/// +/// This blanket impl is only active when the `borsh` feature is NOT enabled. +/// When `borsh` is enabled, its blanket impl takes priority to avoid trait coherence conflicts. +#[cfg(all(feature = "serde", not(feature = "borsh")))] impl Code for T where T: serde::Serialize + serde::de::DeserializeOwned, { fn encode(&self, writer: &mut impl std::io::Write) -> Result<()> { - bincode::serialize_into(writer, self).map_err(Error::bincode_error) + postcard::to_io(self, writer).map(|_| ()).map_err(Error::postcard_error) } fn decode(reader: &mut impl std::io::Read) -> Result { - bincode::deserialize_from(reader).map_err(Error::bincode_error) + let mut buf = Vec::new(); + reader.read_to_end(&mut buf).map_err(Error::io_error)?; + postcard::from_bytes(&buf).map_err(Error::postcard_error) } fn estimated_size(&self) -> usize { - bincode::serialized_size(self).unwrap() as usize + postcard::experimental::serialized_size(self).unwrap_or(0) } } macro_rules! impl_serde_for_numeric_types { ($($t:ty),*) => { $( - #[cfg(not(feature = "serde"))] + #[cfg(not(any(feature = "serde", feature = "borsh")))] impl Code for $t { fn encode(&self, writer: &mut impl std::io::Write) -> Result<()> { writer.write_all(&self.to_le_bytes()).map_err(Error::io_error) @@ -128,7 +158,7 @@ macro_rules! for_all_numeric_types { for_all_numeric_types! { impl_serde_for_numeric_types } -#[cfg(not(feature = "serde"))] +#[cfg(not(any(feature = "serde", feature = "borsh")))] impl Code for bool { fn encode(&self, writer: &mut impl std::io::Write) -> Result<()> { writer @@ -154,7 +184,7 @@ impl Code for bool { } } -#[cfg(not(feature = "serde"))] +#[cfg(not(any(feature = "serde", feature = "borsh")))] impl Code for Vec { fn encode(&self, writer: &mut impl std::io::Write) -> Result<()> { self.len().encode(writer)?; @@ -180,7 +210,7 @@ impl Code for Vec { } } -#[cfg(not(feature = "serde"))] +#[cfg(not(any(feature = "serde", feature = "borsh")))] impl Code for String { fn encode(&self, writer: &mut impl std::io::Write) -> Result<()> { self.len().encode(writer)?; @@ -205,7 +235,7 @@ impl Code for String { } } -#[cfg(not(feature = "serde"))] +#[cfg(not(any(feature = "serde", feature = "borsh")))] impl Code for bytes::Bytes { fn encode(&self, writer: &mut impl std::io::Write) -> Result<()> { self.len().encode(writer)?; @@ -233,20 +263,65 @@ impl Code for bytes::Bytes { mod tests { use super::*; - #[cfg(feature = "serde")] - mod serde { + /// Tests for the `borsh` blanket impl. + #[cfg(feature = "borsh")] + mod borsh { use super::*; #[test] - fn test_encode_overflow() { + fn test_borsh_encode_decode_numeric() { + // borsh uses io::Error, which maps WriteZero -> BufferSizeLimit let mut buf = [0u8; 4]; let e = 1u64.encode(&mut buf.as_mut()).unwrap_err(); - assert_eq!(e.kind(), crate::error::ErrorKind::BufferSizeLimit); + // A 4-byte buffer is too small for a u64; expect an IO-derived error. + assert!( + matches!( + e.kind(), + crate::error::ErrorKind::BufferSizeLimit | crate::error::ErrorKind::Io + ), + "unexpected error kind: {:?}", + e.kind() + ); + } + + #[test] + fn test_borsh_roundtrip() { + let original = 42u64; + let mut buf = vec![0xffu8; original.estimated_size()]; + original.encode(&mut buf.as_mut_slice()).unwrap(); + let decoded = u64::decode(&mut buf.as_slice()).unwrap(); + assert_eq!(original, decoded); + } + } + + /// Tests for the `postcard` (serde) blanket impl. + #[cfg(all(feature = "serde", not(feature = "borsh")))] + mod serde { + use super::*; + + #[test] + fn test_postcard_encode_overflow() { + // postcard uses variable-length encoding; pick a value large enough + // that it definitely won't fit in a tiny buffer. + let mut buf = [0u8; 1]; + let e = u64::MAX.encode(&mut buf.as_mut()).unwrap_err(); + // postcard wraps IO errors from the writer as an External error. + assert!(matches!(e.kind(), crate::error::ErrorKind::External)); + } + + #[test] + fn test_postcard_roundtrip() { + let original = 42u64; + let mut buf = vec![0xffu8; original.estimated_size()]; + original.encode(&mut buf.as_mut_slice()).unwrap(); + let decoded = u64::decode(&mut buf.as_slice()).unwrap(); + assert_eq!(original, decoded); } } - #[cfg(not(feature = "serde"))] - mod non_serde { + /// Tests for manual [`Code`] implementations (neither `serde` nor `borsh`). + #[cfg(not(any(feature = "serde", feature = "borsh")))] + mod manual { use super::*; #[test] diff --git a/foyer-common/src/error.rs b/foyer-common/src/error.rs index 650007b1..44209fbe 100644 --- a/foyer-common/src/error.rs +++ b/foyer-common/src/error.rs @@ -354,14 +354,10 @@ impl Error { } } - /// Helper for creating an error from [`bincode::Error`]. - #[cfg(feature = "serde")] - pub fn bincode_error(source: bincode::Error) -> Self { - match *source { - bincode::ErrorKind::SizeLimit => Error::new(ErrorKind::BufferSizeLimit, "coding error").with_source(source), - bincode::ErrorKind::Io(e) => Self::io_error(e), - _ => Error::new(ErrorKind::External, "coding error").with_source(source), - } + /// Helper for creating an error from [`postcard::Error`]. + #[cfg(all(feature = "serde", not(feature = "borsh")))] + pub fn postcard_error(source: postcard::Error) -> Self { + Error::new(ErrorKind::External, "coding error").with_source(source) } /// Helper for creating a [`ErrorKind::NoSpace`] error with context. @@ -379,13 +375,12 @@ impl From for Error { } } -#[cfg(feature = "serde")] -impl From for Error { - fn from(e: bincode::Error) -> Self { - Self::bincode_error(e) +#[cfg(all(feature = "serde", not(feature = "borsh")))] +impl From for Error { + fn from(e: postcard::Error) -> Self { + Self::postcard_error(e) } } - #[cfg(test)] mod tests { diff --git a/foyer/Cargo.toml b/foyer/Cargo.toml index 078fcb1b..af6609be 100644 --- a/foyer/Cargo.toml +++ b/foyer/Cargo.toml @@ -24,6 +24,7 @@ development = ["jiff"] default = ["runtime-tokio"] clap = ["foyer-storage/clap"] serde = ["foyer-common/serde", "foyer-storage/serde"] +borsh = ["foyer-common/borsh"] nightly = ["foyer-storage/nightly", "foyer-memory/nightly"] tracing = [ "dep:fastrace",