pybind / pybind/pybind11

[BUG]: `py::kw_only()` roughly halves runtime performance

Open
#4,773 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

triage
Dominant language
C++
Stars
18k
Forks
2.3k
Avg merge
5d 17h
Merged PRs (30d)
10

Description

Required prerequisites
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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.