Skip to content

Commit 2e2e62d

Browse files
authored
Optimize DAG-CBOR encode by reusing a thread-local buffer and validating CIDs without Multihash construction (#116)
1 parent 7970600 commit 2e2e62d

5 files changed

Lines changed: 80 additions & 15 deletions

File tree

src/car.rs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use cbor4ii::core::dec::Read;
55
use pyo3::prelude::*;
66
use pyo3::types::*;
77

8+
use crate::cid::parse_cid_prefix;
89
use crate::dag_cbor::de::to_pyobject;
910
use crate::error::value_error;
1011
use crate::ffi::recursion::current_recursion_limit;
@@ -68,25 +69,20 @@ pub fn decode_car<'py>(py: Python<'py>, data: &[u8]) -> PyResult<(Py<PyAny>, Bou
6869
}
6970

7071
let cid_bytes_before = buf.buf;
71-
// `&[u8]` is itself an `io::Read`, so we hand it to `Cid::read_bytes`
72-
// directly and recover the consumed length from the slice shrink.
73-
let mut slice: &[u8] = cid_bytes_before;
74-
let cid_result = ::cid::Cid::read_bytes(&mut slice);
75-
let Ok(cid) = cid_result else {
72+
let Some((consumed, codec)) = parse_cid_prefix(cid_bytes_before) else {
7673
return Err(value_error(
7774
"Failed to read CID of block",
78-
cid_result.unwrap_err().to_string(),
75+
"Invalid CID".to_string(),
7976
));
8077
};
8178

82-
if cid.codec() != 0x71 {
79+
if codec != 0x71 {
8380
return Err(value_error(
8481
"Failed to read CAR block",
8582
"Unsupported codec. For now we support only DAG-CBOR (0x71)".to_string(),
8683
));
8784
}
8885

89-
let consumed = cid_bytes_before.len() - slice.len();
9086
buf.advance(consumed);
9187
let cid_raw = &cid_bytes_before[..consumed];
9288

src/cid.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,53 @@ pub(crate) fn looks_like_cid(bytes: &[u8]) -> bool {
2727
bytes.len() == 34 && bytes[0] == 0x12 && bytes[1] == 0x20
2828
}
2929

30+
// Minimal-encoding unsigned varint, as `unsigned_varint::decode::u64`:
31+
// ≤10 bytes, last byte of a multi-byte varint must be non-zero.
32+
#[inline]
33+
fn read_varint(bytes: &[u8], pos: &mut usize) -> Option<u64> {
34+
let mut n: u64 = 0;
35+
for i in 0..10 {
36+
let &b = bytes.get(*pos + i)?;
37+
n |= ((b & 0x7f) as u64) << (i * 7);
38+
if b & 0x80 == 0 {
39+
if b == 0 && i > 0 {
40+
return None;
41+
}
42+
*pos += i + 1;
43+
return Some(n);
44+
}
45+
}
46+
None
47+
}
48+
49+
/// Structural check that `bytes` starts with a valid binary CID; returns
50+
/// `(consumed, codec)`. Accepts exactly what `::cid::Cid::try_from` accepts
51+
/// (trailing bytes are the caller's concern) but skips the `Multihash`
52+
/// construction and its 64-byte digest copy.
53+
#[inline]
54+
pub(crate) fn parse_cid_prefix(bytes: &[u8]) -> Option<(usize, u64)> {
55+
let mut pos = 0;
56+
let version = read_varint(bytes, &mut pos)?;
57+
let codec = read_varint(bytes, &mut pos)?;
58+
59+
// CIDv0: `0x12 0x20` + 32-byte sha2-256 digest; codec is implicitly dag-pb
60+
if (version, codec) == (0x12, 0x20) {
61+
let end = pos + 32;
62+
return (bytes.len() >= end).then_some((end, 0x70));
63+
}
64+
if version != 1 {
65+
return None;
66+
}
67+
68+
read_varint(bytes, &mut pos)?; // multihash code
69+
let hash_size = read_varint(bytes, &mut pos)?;
70+
if hash_size > 64 {
71+
return None;
72+
}
73+
let end = pos + hash_size as usize;
74+
(bytes.len() >= end).then_some((end, codec))
75+
}
76+
3077
pub(crate) fn extract_cid(data: &Bound<PyAny>) -> PyResult<::cid::Cid> {
3178
let cid = if let Ok(s) = data.cast::<PyString>() {
3279
::cid::Cid::try_from(s.to_str()?)

src/dag_cbor/de.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use cbor4ii::core::{
55
};
66
use pyo3::{ffi, prelude::*, types::*, BoundObject};
77

8+
use crate::cid::parse_cid_prefix;
89
use crate::error::value_error;
910
use crate::ffi::dict::new_presized;
1011
use crate::ffi::key_cache::intern;
@@ -158,7 +159,7 @@ where
158159
}
159160

160161
let cid_without_prefix = &cid[1..];
161-
if ::cid::Cid::try_from(cid_without_prefix).is_err() {
162+
if parse_cid_prefix(cid_without_prefix).is_none() {
162163
return Err(anyhow!("Invalid CID"));
163164
}
164165

src/dag_cbor/ser.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use cbor4ii::core::{
66
use pyo3::pybacked::PyBackedStr;
77
use pyo3::{ffi, prelude::*, types::*};
88

9-
use crate::cid::looks_like_cid;
9+
use crate::cid::{looks_like_cid, parse_cid_prefix};
1010
use crate::error::value_error;
1111
use crate::io::VecWriter;
1212

@@ -133,7 +133,7 @@ where
133133
if tp == &raw mut ffi::PyBytes_Type {
134134
let b = obj.cast_unchecked::<PyBytes>();
135135
let bytes = b.as_bytes();
136-
if looks_like_cid(bytes) && ::cid::Cid::try_from(bytes).is_ok() {
136+
if looks_like_cid(bytes) && parse_cid_prefix(bytes).is_some() {
137137
// by providing custom encoding we avoid extra allocation
138138
types::Tag(42, PrefixedCidBytes(bytes)).encode(w)?;
139139
} else {
@@ -187,7 +187,7 @@ where
187187
Ok(())
188188
} else if let Ok(b) = obj.cast::<PyBytes>() {
189189
let bytes = b.as_bytes();
190-
if looks_like_cid(bytes) && ::cid::Cid::try_from(bytes).is_ok() {
190+
if looks_like_cid(bytes) && parse_cid_prefix(bytes).is_some() {
191191
types::Tag(42, PrefixedCidBytes(bytes)).encode(w)?;
192192
} else {
193193
types::Bytes(bytes).encode(w)?;

src/io/writer.rs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,27 @@
1+
use std::cell::Cell;
12
use std::convert::Infallible;
23

34
use cbor4ii::core::enc;
45

5-
// `enc::Write` over a raw `Vec<u8>`: no syscalls behind it, so a `BufWriter`
6-
// wrapper would just add a memcpy per push for no benefit.
6+
// Retaining bigger buffers would pin worst-case memory per thread forever.
7+
const MAX_POOLED_CAPACITY: usize = 1 << 20;
8+
9+
thread_local! {
10+
static POOL: Cell<Vec<u8>> = const { Cell::new(Vec::new()) };
11+
}
12+
13+
// `enc::Write` over a `Vec<u8>` recycled through a thread-local pool: encoding
14+
// many small records reuses one grown allocation instead of re-growing from
15+
// zero on every call. `Cell::take` leaves an empty `Vec` behind, so a
16+
// re-entrant encode just falls back to a fresh buffer.
717
pub(crate) struct VecWriter(Vec<u8>);
818

919
impl VecWriter {
1020
#[inline]
1121
pub(crate) fn new() -> Self {
12-
VecWriter(Vec::new())
22+
let mut buf = POOL.take();
23+
buf.clear();
24+
VecWriter(buf)
1325
}
1426

1527
#[inline]
@@ -18,6 +30,15 @@ impl VecWriter {
1830
}
1931
}
2032

33+
impl Drop for VecWriter {
34+
fn drop(&mut self) {
35+
let buf = std::mem::take(&mut self.0);
36+
if buf.capacity() <= MAX_POOLED_CAPACITY {
37+
POOL.set(buf);
38+
}
39+
}
40+
}
41+
2142
impl enc::Write for VecWriter {
2243
type Error = Infallible;
2344

0 commit comments

Comments
 (0)