[BUG]: `py::kw_only()` roughly halves runtime performance
Nobody has claimed this yet.
- Dominant language
- C++
- Stars
- 18k
- Forks
- 2.3k
- Avg merge
- 5d 17h
- Merged PRs (30d)
- 10
Description
Required prerequisites
- Make sure you've read the documentation. Your issue may be addressed there.
- Search the issue tracker and Discussions to verify that this hasn't already been reported. +1 or comment there if it has.
- Consider asking first in the Gitter chat room or in a Discussion.
What version (or hash if on master) of pybind11 are you using?
2.11.1
Problem description
When wrapping a C++ function with and without py::kw_only(), the py::kw_only() takes twice as long as the version which does not require kwargs.
Results:
python3 -m pytest benchmarks.py
====================================================== test session starts =======================================================
platform darwin -- Python 3.11.3, pytest-7.4.0, pluggy-1.2.0
plugins: benchmark-4.0.0
------------------------------------------------------------------------------------------ benchmark: 4 tests ------------------------------------------------------------------------------------------
Name (time in ns) Min Max Mean StdDev Median IQR Outliers OPS (Mops/s) Rounds Iterations
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_add 166.9998 (1.0) 17,542.0000 (7.60) 263.2149 (1.0) 115.9062 (2.54) 250.0001 (1.0) 41.0000 (41.00) 32;103 3.7992 (1.0) 45370 1
test_add_kwargs 341.6500 (2.05) 2,308.3000 (1.0) 351.8520 (1.34) 45.5627 (1.0) 350.0000 (1.40) 2.1000 (2.10) 458;6953 2.8421 (0.75) 134844 20
test_construct 416.9999 (2.50) 29,084.0001 (12.60) 553.1024 (2.10) 426.7325 (9.37) 542.0000 (2.17) 1.0000 (1.0) 204;37711 1.8080 (0.48) 97963 1
test_construct_kwargs 666.0000 (3.99) 32,792.0000 (14.21) 794.2052 (3.02) 237.9895 (5.22) 792.0000 (3.17) 42.0000 (42.00) 32;2770 1.2591 (0.33) 108602 1
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Legend:
Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
OPS: Operations Per Second, computed as 1 / Mean
======================================================= 4 passed in 2.68s =====================================================
Obviously this might be an inevitable problem with the python interpreter, but I figured I would submit the bug report just in case someone can see some low-hanging fruit in pybind11.
Reproducible example code
#include <pybind11/pybind11.h>
using namespace pybind11::literals;
namespace py = pybind11;
int add(int i, int j) {
return i + j;
}
int add_kwargs(int i, int j) {
return i + j;
}
class Foo {
public:
Foo(long i, long j) : i_{i}, j_{j} {}
private:
long i_;
long j_;
};
class Bar {
public:
Bar(long i, long j) : i_{i}, j_{j} {}
private:
long i_;
long j_;
};
PYBIND11_MODULE(example, m) {
m.def("add", &add, "A function that adds two numbers");
m.def("add_kwargs", &add_kwargs, py::kw_only(), "i"_a, "j"_a);
py::class_<Foo>(m, "Foo")
.def(py::init<long, long>());
py::class_<Bar>(m, "Bar")
.def(py::init<long, long>(), py::kw_only(), "i"_a, "j"_a);
}
Python:
import pytest
from example import add, add_kwargs, Foo, Bar
@pytest.mark.benchmark
def test_add(benchmark):
benchmark(add, 0, 2)
@pytest.mark.benchmark
def test_add_kwargs(benchmark):
benchmark(add_kwargs, i=0, j=2)
@pytest.mark.benchmark
def test_construct(benchmark):
benchmark(Foo, 3, 7)
@pytest.mark.benchmark
def test_construct_kwargs(benchmark):
benchmark(Bar, i=3, j=7)
N.B.: I implemented the same code in nanobind and got ~80-100ns of overhead:
----------------------------------------------------------------------------------------- benchmark: 4 tests -----------------------------------------------------------------------------------------
Name (time in ns) Min Max Mean StdDev Median IQR Outliers OPS (Mops/s) Rounds Iterations
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_add 56.8966 (1.0) 217.0966 (1.0) 57.5045 (1.0) 3.3493 (1.0) 57.3276 (1.0) 0.1448 (1.0) 382;5531 17.3899 (1.0) 59999 290
test_construct 83.0000 (1.46) 9,334.0000 (42.99) 179.4872 (3.12) 38.6705 (11.55) 167.0001 (2.91) 41.0000 (283.10) 2343;173 5.5714 (0.32) 95239 1
test_add_kwargs 134.5800 (2.37) 621.2500 (2.86) 136.9582 (2.38) 8.5765 (2.56) 136.6600 (2.38) 0.4200 (2.90) 316;7509 7.3015 (0.42) 72291 100
test_construct_kwargs 182.6923 (3.21) 1,841.3461 (8.48) 190.2872 (3.31) 15.0874 (4.50) 189.1154 (3.30) 1.6154 (11.15) 245;17747 5.2552 (0.30) 195122 26
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
And finally, I benchmarked the overhead of just doing the python interpreter, with and without kwargs:
import pytest
from example import add, add_kwargs, Foo, Bar
def py_add(i, j):
return i + j
@pytest.mark.benchmark
def test_py_add(benchmark):
benchmark(py_add, 3, 7)
@pytest.mark.benchmark
def test_py_add_kwarg(benchmark):
benchmark(py_add, i=3, j=7)
Results: Adds ~66ns/call
====================================================== test session starts =======================================================
platform darwin -- Python 3.11.3, pytest-7.4.0, pluggy-1.2.0
benchmark: 4.0.0 (defaults: timer=time.perf_counter disable_gc=False min_rounds=5 min_time=0.000005 max_time=1.0 calibration_precision=10 warmup=False warmup_iterations=100000)
plugins: benchmark-4.0.0
-------------------------------------------------------------------------------------- benchmark: 2 tests -------------------------------------------------------------------------------------
Name (time in ns) Min Max Mean StdDev Median IQR Outliers OPS (Mops/s) Rounds Iterations
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_py_add 74.4000 (1.0) 483.3286 (1.0) 75.7920 (1.0) 6.2766 (1.0) 75.3000 (1.0) 0.3000 (1.0) 366;11493 13.1940 (1.0) 93024 140
test_py_add_kwarg 140.8300 (1.89) 561.6700 (1.16) 143.1593 (1.89) 9.6644 (1.54) 142.5000 (1.89) 0.8300 (2.77) 338;1611 6.9852 (0.53) 69363 100
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Legend:
Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
OPS: Operations Per Second, computed as 1 / Mean
Is this a regression? Put the last known working version here if it is.
Not a regression
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by running the benchmark command python3 -m pytest benchmarks.py and compare the positional and keyword-only cases in the supplied C++ and Python examples. Trace the py::kw_only() call path and measure it against the plain binding and the Python-only comparison; done means identifying and addressing a pybind11-specific source of overhead without regressing the benchmark results.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp, python
- Domain
- backend-api-design, performance
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Stale
- Clarity
- Needs clarification
- Newbie friendliness
- 25/100