Skip to content

Commit b5684bc

Browse files
timsaucerclaude
andcommitted
perf(codec): cache cloudpickle module handle in a OnceLock
The six encode/decode helpers re-resolved `cloudpickle` on every call. `py.import` is backed by `sys.modules` so each lookup is cheap, but a plan with many Python UDFs pays the dict-walk + bind cost per UDF for no reason. Cache the module once in a `OnceLock<Py<PyAny>>` and hand out a fresh `Bound` against the caller's GIL token. Race-safe: two threads can both miss and import, but CPython already deduplicates the import via `sys.modules` and the losing `set` still leaves the winning value in the slot, so both threads return the same module. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d722bdc commit b5684bc

1 file changed

Lines changed: 34 additions & 7 deletions

File tree

crates/core/src/codec.rs

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@
7575
//! `inner`; the encoder/decoder hooks for each kind are added as the
7676
//! corresponding Python-side type becomes serializable.
7777
78-
use std::sync::Arc;
78+
use std::sync::{Arc, OnceLock};
7979

8080
use arrow::datatypes::{Field, Schema, SchemaRef};
8181
use arrow::ipc::reader::StreamReader;
@@ -470,7 +470,7 @@ pub(crate) fn try_decode_python_scalar_udf(buf: &[u8]) -> Result<Option<Arc<Scal
470470
/// `from_parts` immediately collapses incoming `Field`s back to
471471
/// `DataType`s for the reconstructed `Signature`.
472472
fn encode_python_scalar_udf(py: Python<'_>, udf: &PythonFunctionScalarUDF) -> PyResult<Vec<u8>> {
473-
let cloudpickle = py.import("cloudpickle")?;
473+
let cloudpickle = cloudpickle(py)?;
474474

475475
let signature = udf.signature();
476476
let input_dtypes: Vec<arrow::datatypes::DataType> = match &signature.type_signature {
@@ -513,7 +513,7 @@ fn encode_python_scalar_udf(py: Python<'_>, udf: &PythonFunctionScalarUDF) -> Py
513513

514514
/// Inverse of [`encode_python_scalar_udf`].
515515
fn decode_python_scalar_udf(py: Python<'_>, payload: &[u8]) -> PyResult<PythonFunctionScalarUDF> {
516-
let cloudpickle = py.import("cloudpickle")?;
516+
let cloudpickle = cloudpickle(py)?;
517517

518518
let tuple = cloudpickle
519519
.call_method1("loads", (PyBytes::new(py, payload),))?
@@ -589,6 +589,33 @@ fn volatility_wire_str(v: Volatility) -> &'static str {
589589
}
590590
}
591591

592+
/// Cached handle to the `cloudpickle` module.
593+
///
594+
/// Six encode/decode helpers below would otherwise re-resolve the
595+
/// module on every call. `py.import` is backed by `sys.modules` and
596+
/// therefore cheap, but each call still walks a dict and re-binds the
597+
/// result; a plan with many Python UDFs pays that cost per UDF. The
598+
/// `OnceLock` collapses it to a single import per process while the
599+
/// `Py<PyAny>` lets us hand out a fresh `Bound` against the current
600+
/// GIL token without holding one in the static slot.
601+
fn cloudpickle<'py>(py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
602+
static CLOUDPICKLE: OnceLock<Py<PyAny>> = OnceLock::new();
603+
if let Some(cached) = CLOUDPICKLE.get() {
604+
return Ok(cached.bind(py).clone());
605+
}
606+
// Race: two threads can both miss and import. CPython's
607+
// `sys.modules` makes the second import essentially free, and
608+
// `set` losing the race still leaves the winning value in the
609+
// slot — both threads end up returning the same module.
610+
let module = py.import("cloudpickle")?;
611+
let _ = CLOUDPICKLE.set(module.clone().unbind().into_any());
612+
Ok(CLOUDPICKLE
613+
.get()
614+
.expect("cloudpickle slot populated above")
615+
.bind(py)
616+
.clone())
617+
}
618+
592619
// =============================================================================
593620
// Shared Python window UDF encode / decode helpers
594621
//
@@ -629,7 +656,7 @@ pub(crate) fn try_decode_python_window_udf(buf: &[u8]) -> Result<Option<Arc<Wind
629656
}
630657

631658
fn encode_python_window_udf(py: Python<'_>, udf: &PythonFunctionWindowUDF) -> PyResult<Vec<u8>> {
632-
let cloudpickle = py.import("cloudpickle")?;
659+
let cloudpickle = cloudpickle(py)?;
633660

634661
let signature = WindowUDFImpl::signature(udf);
635662
let input_dtypes: Vec<arrow::datatypes::DataType> = match &signature.type_signature {
@@ -671,7 +698,7 @@ fn encode_python_window_udf(py: Python<'_>, udf: &PythonFunctionWindowUDF) -> Py
671698
}
672699

673700
fn decode_python_window_udf(py: Python<'_>, payload: &[u8]) -> PyResult<PythonFunctionWindowUDF> {
674-
let cloudpickle = py.import("cloudpickle")?;
701+
let cloudpickle = cloudpickle(py)?;
675702

676703
let tuple = cloudpickle
677704
.call_method1("loads", (PyBytes::new(py, payload),))?
@@ -757,7 +784,7 @@ pub(crate) fn try_decode_python_agg_udf(buf: &[u8]) -> Result<Option<Arc<Aggrega
757784
}
758785

759786
fn encode_python_agg_udf(py: Python<'_>, udf: &PythonFunctionAggregateUDF) -> PyResult<Vec<u8>> {
760-
let cloudpickle = py.import("cloudpickle")?;
787+
let cloudpickle = cloudpickle(py)?;
761788

762789
let signature = AggregateUDFImpl::signature(udf);
763790
let input_dtypes: Vec<arrow::datatypes::DataType> = match &signature.type_signature {
@@ -807,7 +834,7 @@ fn encode_python_agg_udf(py: Python<'_>, udf: &PythonFunctionAggregateUDF) -> Py
807834
}
808835

809836
fn decode_python_agg_udf(py: Python<'_>, payload: &[u8]) -> PyResult<PythonFunctionAggregateUDF> {
810-
let cloudpickle = py.import("cloudpickle")?;
837+
let cloudpickle = cloudpickle(py)?;
811838

812839
let tuple = cloudpickle
813840
.call_method1("loads", (PyBytes::new(py, payload),))?

0 commit comments

Comments
 (0)