Skip to content

pyarrow: Cache the imported classes to avoid importing them each time#9439

Merged
alamb merged 1 commit intoapache:mainfrom
Tpt:tpt/pyarrow-import
Mar 19, 2026
Merged

pyarrow: Cache the imported classes to avoid importing them each time#9439
alamb merged 1 commit intoapache:mainfrom
Tpt:tpt/pyarrow-import

Conversation

@Tpt
Copy link
Copy Markdown
Contributor

@Tpt Tpt commented Feb 19, 2026

Which issue does this PR close?

Rationale for this change

Speed up conversion by only importing pyarrow once.

What changes are included in this PR?

  • Use PyOnceLock::import to import the types.
  • Remove some not useful .extract::<PyBackedStr>()? (the Display implementation already does something similar)

Are these changes tested?

Covered by existing tests. It would be nice to add benchmark but it might require to:

  • either add a dependency to a python benchmark runner
  • write some hacky code to import pyarrow from criterion tests (likely by running pip/uv from the Rust benchmark code)

Are there any user-facing changes?

No

Copy link
Copy Markdown
Contributor

@alamb alamb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to me -- thank you @Tpt

@kylebarron / @adriangb as you have more experience with py03 could you also give this a brief look? Thank you

Comment thread arrow-pyarrow/src/lib.rs

fn array_class(py: Python<'_>) -> PyResult<&Bound<'_, PyType>> {
static TYPE: PyOnceLock<Py<PyType>> = PyOnceLock::new();
TYPE.import(py, "pyarrow", "Array")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://docs.rs/pyo3/0.28.2/pyo3/sync/struct.PyOnceLock.html#method.import

Looks like this is exactly the pattern it was designed for

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex also found similar uses in other well respected crates

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting that those crates cache the imports. I thought that Python cached the import anyways on the C side, so it was unnecessary to do it on the pyo3 side

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought that Python cached the import anyways on the C side, so it was unnecessary to do it on the pyo3 side

Yes, cpython does not reinitialize the module each time (I guess this what you mean by "cache the import", sorry for the dumb answer if it's not the case). However, doing py.import(my_module)?.getattr(my_class)? requires at least two map lookups, one to fetch the module object from its path and one to fetch the class from the module. The cache allows to skip these lookups and directly use the type object. If the GIL is enabled there is no synchronization cost to do that (PyOnceLock use the GIL as lock).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see; so it's just saving two lookups into the CPython HashMap? I guess that's not nothing, but it's not the slow module reinitialization I was worried it was.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be curious about a benchmark, but not required to merge

Copy link
Copy Markdown
Contributor Author

@Tpt Tpt Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! I just ran a benchmark by curiosity. Here is the result:

import_direct           time:   [272.33 ns 274.23 ns 276.52 ns]
Found 4 outliers among 100 measurements (4.00%)
  1 (1.00%) high mild
  3 (3.00%) high severe

import_intern           time:   [206.61 ns 207.60 ns 208.84 ns]
Found 8 outliers among 100 measurements (8.00%)
  6 (6.00%) high mild
  2 (2.00%) high severe

import_static           time:   [1.3524 ns 1.3578 ns 1.3648 ns]
Found 17 outliers among 100 measurements (17.00%)
  4 (4.00%) high mild
  13 (13.00%) high severe

the three benchmarks import uuid.UUID.

  • import_direct uses Python::import()?.getattr()
  • import_intern uses Python::import(intern!())?.getattr(intern!()) to avoid always allocating the strings "uuid" and "UUID"
  • import_static uses PyOnceLock::import

Code:

Details ```rust use std::hint::black_box;

use codspeed_criterion_compat::{criterion_group, criterion_main, Bencher, Criterion};

use pyo3::prelude::*;

use pyo3::intern;
use pyo3::sync::PyOnceLock;
use pyo3::types::PyType;

fn import_direct(b: &mut Bencher<'_>) {
Python::attach(|py| {
b.iter(|| black_box(black_box(&py.import("uuid").unwrap()).getattr("UUID")).unwrap());
});
}

fn import_intern(b: &mut Bencher<'_>) {
Python::attach(|py| {
b.iter(|| {
black_box(
black_box(&py.import(intern!(py, "uuid")).unwrap()).getattr(intern!(py, "UUID")),
)
.unwrap()
});
});
}

fn import_static(b: &mut Bencher<'_>) {
Python::attach(|py| {
static TYPE: PyOnceLock<Py> = PyOnceLock::new();
b.iter(|| {
black_box(TYPE.import(py, "uuid", "UUID")).unwrap();
});
});
}

fn criterion_benchmark(c: &mut Criterion) {
c.bench_function("import_direct", import_direct);
c.bench_function("import_intern", import_intern);
c.bench_function("import_static", import_static);
}

criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);

</details>

@alamb alamb merged commit 14f1eb9 into apache:main Mar 19, 2026
13 checks passed
@alamb
Copy link
Copy Markdown
Contributor

alamb commented Mar 19, 2026

Thanks again @Tpt and @kylebarron

AdamGS added a commit to vortex-data/vortex that referenced this pull request Mar 27, 2026
## Summary

Was reading the arrow-rs changelog and I ran into
[this](apache/arrow-rs#9439) PR, seems like
`PyOnceLock` was built for this and they have some very promising
benchmarks
[there](https://github.com/apache/arrow-rs/pull/9439/changes#r2955818325).

I've also wrapped all static strings that are passed into `pyo3` with
the `intern` macro, which prevents allocating a new `PyString` on every
call.

---------

Signed-off-by: Adam Gutglick <adam@spiraldb.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: avoid importing pyarrow classes ever time

3 participants