Skip to content

Commit d2eaf4d

Browse files
authored
Optimize DAG-CBOR map decode with key-intern cache and KnownHash insert (#106)
1 parent 4ea29bb commit d2eaf4d

2 files changed

Lines changed: 149 additions & 3 deletions

File tree

build.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
fn main() {
2+
pyo3_build_config::use_pyo3_cfgs();
23
if matches!(
34
pyo3_build_config::get().implementation,
45
pyo3_build_config::PythonImplementation::CPython

src/lib.rs

Lines changed: 148 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,16 @@ use cid::{multibase, Cid};
1010
use pyo3::pybacked::PyBackedStr;
1111
use pyo3::{ffi, prelude::*, types::*, BoundObject, Python};
1212

13-
// Private CPython symbol; not provided by pyo3-ffi and CPython-only.
13+
// Private CPython symbols; not provided by pyo3-ffi and CPython-only.
1414
#[cfg(CPython)]
1515
extern "C" {
1616
fn _PyDict_NewPresized(minused: ffi::Py_ssize_t) -> *mut ffi::PyObject;
17+
fn _PyDict_SetItem_KnownHash(
18+
op: *mut ffi::PyObject,
19+
key: *mut ffi::PyObject,
20+
value: *mut ffi::PyObject,
21+
hash: ffi::Py_hash_t,
22+
) -> std::os::raw::c_int;
1723
}
1824

1925
// Empty CPython dicts already have 8 slots, so presizing below that buys
@@ -198,6 +204,124 @@ fn pystring_from_bytes_fast<'py>(py: Python<'py>, bytes: &[u8]) -> PyResult<Boun
198204
PyString::from_bytes(py, bytes)
199205
}
200206

207+
// Direct-mapped intern cache for short map keys. atproto-shape payloads
208+
// reuse a small vocabulary (`$type`, `did`, `cid`, `uri`, `text`, ...) per
209+
// record; caching the constructed `PyUnicode` + its `Py_hash_t` skips both
210+
// the rebuild and the rehash inside `PyDict_SetItem`
211+
#[cfg(all(CPython, not(Py_GIL_DISABLED)))]
212+
mod key_cache {
213+
use super::pystring_from_bytes_fast;
214+
use pyo3::{ffi, prelude::*};
215+
216+
const CAP: usize = 2048;
217+
const MAX_KEY_LEN: usize = 64;
218+
219+
struct Entry {
220+
len: u16,
221+
bytes: [u8; MAX_KEY_LEN],
222+
obj: *mut ffi::PyObject,
223+
hash: ffi::Py_hash_t,
224+
}
225+
226+
impl Entry {
227+
const fn empty() -> Self {
228+
Self {
229+
len: 0,
230+
bytes: [0; MAX_KEY_LEN],
231+
obj: std::ptr::null_mut(),
232+
hash: 0,
233+
}
234+
}
235+
}
236+
237+
static mut SLOTS: [Entry; CAP] = [const { Entry::empty() }; CAP];
238+
239+
#[inline]
240+
fn fx_hash(bytes: &[u8]) -> usize {
241+
const K: u64 = 0x517c_c1b7_2722_0a95;
242+
let mut h: u64 = 0;
243+
for &b in bytes {
244+
h = (h.rotate_left(5) ^ b as u64).wrapping_mul(K);
245+
}
246+
h as usize
247+
}
248+
249+
/// Returns `(strong-ref PyUnicode*, Py_hash_t)`. Caller owns one ref.
250+
/// Caller must hold the GIL (we are always called from a `Python<'_>`).
251+
#[inline]
252+
pub(super) unsafe fn intern_key(
253+
py: Python<'_>,
254+
bytes: &[u8],
255+
) -> PyResult<(*mut ffi::PyObject, ffi::Py_hash_t)> {
256+
if bytes.len() > MAX_KEY_LEN {
257+
return build(py, bytes);
258+
}
259+
260+
let slot_idx = fx_hash(bytes) & (CAP - 1);
261+
// `&raw mut` is the supported path to a `static mut`; the explicit
262+
// re-borrow keeps the field accesses readable. Clippy's `deref_addrof`
263+
// suggestion would re-introduce `static_mut_refs`.
264+
#[allow(clippy::deref_addrof)]
265+
let slot = &mut *(&raw mut SLOTS[slot_idx]);
266+
267+
if slot.len as usize == bytes.len()
268+
&& !slot.obj.is_null()
269+
&& slot.bytes[..bytes.len()] == *bytes
270+
{
271+
ffi::Py_INCREF(slot.obj);
272+
return Ok((slot.obj, slot.hash));
273+
}
274+
275+
let (obj, hash) = build(py, bytes)?;
276+
// Evict the previous occupant before claiming the slot.
277+
if !slot.obj.is_null() {
278+
ffi::Py_DECREF(slot.obj);
279+
}
280+
// One ref for the cache, one for the caller.
281+
ffi::Py_INCREF(obj);
282+
slot.obj = obj;
283+
slot.hash = hash;
284+
slot.len = bytes.len() as u16;
285+
slot.bytes[..bytes.len()].copy_from_slice(bytes);
286+
Ok((obj, hash))
287+
}
288+
289+
#[inline]
290+
unsafe fn build(
291+
py: Python<'_>,
292+
bytes: &[u8],
293+
) -> PyResult<(*mut ffi::PyObject, ffi::Py_hash_t)> {
294+
let s = pystring_from_bytes_fast(py, bytes)?;
295+
let ptr = s.as_ptr();
296+
let hash = ffi::PyObject_Hash(ptr);
297+
if hash == -1 {
298+
return Err(PyErr::fetch(py));
299+
}
300+
Ok((s.into_ptr(), hash))
301+
}
302+
}
303+
304+
// Non-CPython / free-threaded fallback: no cache, just build the string and compute its hash inline
305+
#[cfg(not(all(CPython, not(Py_GIL_DISABLED))))]
306+
mod key_cache {
307+
use super::pystring_from_bytes_fast;
308+
use pyo3::{ffi, prelude::*};
309+
310+
#[inline]
311+
pub(super) unsafe fn intern_key(
312+
py: Python<'_>,
313+
bytes: &[u8],
314+
) -> PyResult<(*mut ffi::PyObject, ffi::Py_hash_t)> {
315+
let s = pystring_from_bytes_fast(py, bytes)?;
316+
let ptr = s.as_ptr();
317+
let hash = ffi::PyObject_Hash(ptr);
318+
if hash == -1 {
319+
return Err(PyErr::fetch(py));
320+
}
321+
Ok((s.into_ptr(), hash))
322+
}
323+
}
324+
201325
fn get_bytes_from_py_any<'py>(obj: &'py Bound<'py, PyAny>) -> PyResult<&'py [u8]> {
202326
if let Ok(b) = obj.cast::<PyBytes>() {
203327
Ok(b.as_bytes())
@@ -308,11 +432,32 @@ where
308432
}
309433
}
310434

311-
let key_py = pystring_from_bytes_fast(py, key)?;
312435
prev_key = Some(key);
313436

437+
let (key_ptr, key_hash) = unsafe { key_cache::intern_key(py, key)? };
438+
let key_bound: Bound<'_, PyAny> = unsafe { Bound::from_owned_ptr(py, key_ptr) };
439+
314440
let value_py = decode_dag_cbor_to_pyobject(py, r, depth + 1)?;
315-
dict.set_item(key_py, value_py)?;
441+
442+
#[cfg(CPython)]
443+
unsafe {
444+
let value_ptr = value_py.into_ptr();
445+
let rc = _PyDict_SetItem_KnownHash(
446+
dict.as_ptr(),
447+
key_bound.as_ptr(),
448+
value_ptr,
449+
key_hash,
450+
);
451+
ffi::Py_DECREF(value_ptr);
452+
if rc != 0 {
453+
return Err(anyhow!(PyErr::fetch(py)));
454+
}
455+
}
456+
#[cfg(not(CPython))]
457+
{
458+
let _ = key_hash;
459+
dict.set_item(&key_bound, value_py)?;
460+
}
316461
}
317462

318463
dict.into_pyobject(py)?.into()

0 commit comments

Comments
 (0)