Skip to content

gh-86199: Avoid an unnecessary dict copy for f(**kwargs) calls - #156384

Open
eendebakpt wants to merge 3 commits into
python:mainfrom
eendebakpt:gh-86199-kwargs-nocopy-v2
Open

gh-86199: Avoid an unnecessary dict copy for f(**kwargs) calls#156384
eendebakpt wants to merge 3 commits into
python:mainfrom
eendebakpt:gh-86199-kwargs-nocopy-v2

Conversation

@eendebakpt

@eendebakpt eendebakpt commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

This PR addresses #86199 (performance regression in f(**kw)) and #86795 (incorrect behavior of PyObject_Call leading to crashes). The work is based on gh-92192 by @MojoVampire, which went stale.

  • f(**d) with a small dict is ~1.4x faster in microbenchmarks.
  • PyObject_Call now honors its documented equivalence to callable(*args, **kwargs): a tp_call callee can no longer mutate the
    caller's kwargs dict.
  • Unpacking a shared kwargs dict is now safe against concurrent resizing on the free-threaded build.

It took some iterations before ending up at the current PR. The main optimization is due to the compiler pushing the ** operand as-is instead of BUILD_MAP/DICT_MERGE. CALL_FUNCTION_EX converts a non-exact mapping to an exact dict itself, reusing the DICT_MERGE machinery so error messages are unchanged. PyObject_Call copies the dict only on the tp_call path, where the callee would otherwise receive the caller's dict directly. For the vectorcall protocoll the _PyStack_UnpackDict takes a critical section while copying the dict's items out (retrying on a concurrent resize).

Since Python 3.9 a call with a lone ** unpacking compiled to
BUILD_MAP 0 + DICT_MERGE 1 + CALL_FUNCTION_EX, copying the kwargs dict on
every call.  For vectorcall callees (all Python functions and most
builtins) that copy is wasted work: the dict is immediately unpacked onto
a flat argument vector.

The compiler now pushes the ** operand as-is.  CALL_FUNCTION_EX converts a
non-exact mapping to a dict itself (reusing the DICT_MERGE machinery, so
error messages are unchanged), and PyObject_Call copies the dict only on
the tp_call path, where the callee would otherwise receive the caller's
dict directly -- so a callee still can never mutate the caller's kwargs,
and the documented PyObject_Call/callable(*args, **kwargs) equivalence now
holds for the C API too (pythongh-86795).

_PyStack_UnpackDict takes a critical section while copying the dict's
items out, retrying if the dict was resized in between, which makes
unpacking a shared dict safe on free-threaded builds.

f(**d) with a small dict is ~1.4x faster; calls without ** unpacking are
unaffected.

Co-authored-by: Josh Rosenberg <1178095+MojoVampire@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
eendebakpt and others added 2 commits September 9, 2026 23:58
… body

Taking the address of a local (&dupkey) inside _MAKE_CALLARGS_A_TUPLE
prevents MSVC from emitting the required tail call in the tail-calling
interpreter build (error C4737). Move the mapping-to-dict conversion into
a new helper, _PyEval_KwargsToDict(), so the op body has no address-taken
locals.

Also update test_dict_merge: f(**d) no longer emits DICT_MERGE, so use
f(**d, **e) there and add a test that f(**d) traces through
_MAKE_CALLARGS_A_TUPLE without _DICT_MERGE.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ild)

With _PyDict_MergeUniq now having two callers, MSVC no longer inlines it
into DICT_MERGE, so the address-taken local there blocks the required
tail call (C4737). Route both DICT_MERGE and _PyEval_KwargsToDict through
a new _PyEval_MergeKwargs() helper so no instruction body takes the
address of a local.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.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.

1 participant