From 947e149a93f55dfb4f4199dd7ef56d193e8ba38d Mon Sep 17 00:00:00 2001 From: Cody Maloney Date: Sat, 21 Feb 2026 20:26:38 -0800 Subject: [PATCH 1/4] gh-87613: Argument Cliic @vectorcall decorator Add `@vectorcall` as a decorator to Argument Clinic (AC) which generates a new [Vectorcall Protocol](https://docs.python.org/3/c-api/call.html#the-vectorcall-protocol) argument parsing C function named `{}_vectorcall`. This is only supported for `__new__` and `__init__` currently to simplify implementation. The generated code has similar or better performance to existing hand-written cases for `list`, `float`, `str`, `tuple`, `enumerate`, `reversed`, and `int`. Using the decorator added vectorcall to `bytearray` and construction got 1.09x faster. For more details see the comments in gh-87613. The `@vectorcall` decorator has two options: - **zero_arg={C_FUNC}**: Some types, like `int`, can be called with zero arguments and return an immortal object in that case. Adding a shortcut is needed to match existing hand-written performance; provides an over 10% performance change for those cases. - **exact_only**: If the type is not an exact match delegate to the existing non-vectorcall implementation. NEeded for `str` to get matching performance while ensuring correct behavior. Implementation details: - Adds support for the new decorator with arguments in the AC DSL Parser - Move keyword argument parsing generation from inline to a function so both vectorcall, `vc_`, and existing can share code generation. - Adds an `emit` helper to simplify code a bit from existing AC cases Co-Authored-By: Claude Opus 4.6 --- Lib/test/test_clinic.py | 157 +++++++++ Tools/clinic/libclinic/app.py | 1 + Tools/clinic/libclinic/clanguage.py | 20 +- Tools/clinic/libclinic/dsl_parser.py | 30 +- Tools/clinic/libclinic/function.py | 3 + Tools/clinic/libclinic/parse_args.py | 475 +++++++++++++++++++++++---- 6 files changed, 620 insertions(+), 66 deletions(-) diff --git a/Lib/test/test_clinic.py b/Lib/test/test_clinic.py index e71f9fc181bb43..def49c970e5467 100644 --- a/Lib/test/test_clinic.py +++ b/Lib/test/test_clinic.py @@ -626,6 +626,7 @@ def test_directive_output_invalid_command(self): - 'impl_prototype' - 'parser_prototype' - 'parser_definition' + - 'vectorcall_definition' - 'cpp_endif' - 'methoddef_ifndef' - 'impl_definition' @@ -2496,6 +2497,162 @@ def test_duplicate_coexist(self): """ self.expect_failure(block, err, lineno=2) + def test_duplicate_vectorcall(self): + err = "Called @vectorcall twice" + block = """ + module m + class Foo "FooObject *" "" + @vectorcall + @vectorcall + Foo.__init__ + """ + self.expect_failure(block, err, lineno=3) + + def test_vectorcall_on_regular_method(self): + err = "@vectorcall can only be used with __init__ and __new__ methods" + block = """ + module m + class Foo "FooObject *" "" + @vectorcall + Foo.some_method + """ + self.expect_failure(block, err, lineno=3) + + def test_vectorcall_on_module_function(self): + err = "@vectorcall can only be used with __init__ and __new__ methods" + block = """ + module m + @vectorcall + m.fn + """ + self.expect_failure(block, err, lineno=2) + + def test_vectorcall_on_init(self): + block = """ + module m + class Foo "FooObject *" "Foo_Type" + @vectorcall + Foo.__init__ + iterable: object = NULL + / + """ + func = self.parse_function(block, signatures_in_block=3, + function_index=2) + self.assertTrue(func.vectorcall) + self.assertFalse(func.vectorcall_exact_only) + + def test_vectorcall_on_new(self): + block = """ + module m + class Foo "FooObject *" "Foo_Type" + @classmethod + @vectorcall + Foo.__new__ + x: object = NULL + / + """ + func = self.parse_function(block, signatures_in_block=3, + function_index=2) + self.assertTrue(func.vectorcall) + self.assertFalse(func.vectorcall_exact_only) + + def test_vectorcall_exact_only(self): + block = """ + module m + class Foo "FooObject *" "Foo_Type" + @vectorcall exact_only + Foo.__init__ + iterable: object = NULL + / + """ + func = self.parse_function(block, signatures_in_block=3, + function_index=2) + self.assertTrue(func.vectorcall) + self.assertTrue(func.vectorcall_exact_only) + + def test_vectorcall_init_with_kwargs(self): + block = """ + module m + class Foo "FooObject *" "Foo_Type" + @vectorcall + Foo.__init__ + source: object = NULL + encoding: str = NULL + errors: str = NULL + """ + func = self.parse_function(block, signatures_in_block=3, + function_index=2) + self.assertTrue(func.vectorcall) + + def test_vectorcall_new_with_kwargs(self): + block = """ + module m + class Foo "FooObject *" "Foo_Type" + @classmethod + @vectorcall + Foo.__new__ + source: object = NULL + * + encoding: str = NULL + errors: str = NULL + """ + func = self.parse_function(block, signatures_in_block=3, + function_index=2) + self.assertTrue(func.vectorcall) + + def test_vectorcall_init_no_args(self): + block = """ + module m + class Foo "FooObject *" "Foo_Type" + @vectorcall + Foo.__init__ + """ + func = self.parse_function(block, signatures_in_block=3, + function_index=2) + self.assertTrue(func.vectorcall) + + def test_vectorcall_zero_arg(self): + block = """ + module m + class Foo "FooObject *" "Foo_Type" + @classmethod + @vectorcall zero_arg=_PyFoo_GetEmpty() + Foo.__new__ + x: object = NULL + / + """ + func = self.parse_function(block, signatures_in_block=3, + function_index=2) + self.assertTrue(func.vectorcall) + self.assertFalse(func.vectorcall_exact_only) + self.assertEqual(func.vectorcall_zero_arg, '_PyFoo_GetEmpty()') + + def test_vectorcall_zero_arg_with_exact(self): + block = """ + module m + class Foo "FooObject *" "Foo_Type" + @classmethod + @vectorcall exact_only zero_arg=get_cached() + Foo.__new__ + x: object = NULL + / + """ + func = self.parse_function(block, signatures_in_block=3, + function_index=2) + self.assertTrue(func.vectorcall) + self.assertTrue(func.vectorcall_exact_only) + self.assertEqual(func.vectorcall_zero_arg, 'get_cached()') + + def test_vectorcall_invalid_kwarg(self): + err = "unknown argument" + block = """ + module m + class Foo "FooObject *" "" + @vectorcall bogus=True + Foo.__init__ + """ + self.expect_failure(block, err, lineno=2) + def test_unused_param(self): block = self.parse(""" module foo diff --git a/Tools/clinic/libclinic/app.py b/Tools/clinic/libclinic/app.py index 9e8cec5320f877..c8ca4cb452a0e9 100644 --- a/Tools/clinic/libclinic/app.py +++ b/Tools/clinic/libclinic/app.py @@ -121,6 +121,7 @@ def __init__( 'impl_prototype': d('file'), 'parser_prototype': d('suppress'), 'parser_definition': d('file'), + 'vectorcall_definition': d('file'), 'cpp_endif': d('file'), 'methoddef_ifndef': d('file', 1), 'impl_definition': d('block'), diff --git a/Tools/clinic/libclinic/clanguage.py b/Tools/clinic/libclinic/clanguage.py index 9e7fa7a7f58f95..8b59b5367ddb23 100644 --- a/Tools/clinic/libclinic/clanguage.py +++ b/Tools/clinic/libclinic/clanguage.py @@ -14,7 +14,7 @@ from libclinic.function import ( Module, Class, Function, Parameter, permute_optional_groups, - GETTER, SETTER, METHOD_INIT) + GETTER, SETTER, METHOD_INIT, METHOD_NEW) from libclinic.converters import self_converter from libclinic.parse_args import ParseArgsCodeGen if TYPE_CHECKING: @@ -478,6 +478,24 @@ def render_function( template_dict['parser_parameters'] = ", ".join(data.impl_parameters[1:]) template_dict['impl_arguments'] = ", ".join(data.impl_arguments) + # Vectorcall impl arguments: replace self/type with the appropriate + # expression for the vectorcall calling convention. + if f.vectorcall and f.cls: + if f.kind is METHOD_INIT: + # For __init__: self is a locally-allocated PyObject* + vc_first = f"({f.cls.typedef})self" + elif f.kind is METHOD_NEW: + # For __new__: type is PyObject* in vectorcall, need cast + vc_first = "_PyType_CAST(type)" + else: + raise AssertionError( + f"Unhandled function kind for vectorcall: {f.kind!r}" + ) + vc_impl_args = [vc_first] + data.impl_arguments[1:] + template_dict['vc_impl_arguments'] = ", ".join(vc_impl_args) + else: + template_dict['vc_impl_arguments'] = "" + template_dict['return_conversion'] = libclinic.format_escape("".join(data.return_conversion).rstrip()) template_dict['post_parsing'] = libclinic.format_escape("".join(data.post_parsing).rstrip()) template_dict['cleanup'] = libclinic.format_escape("".join(data.cleanup)) diff --git a/Tools/clinic/libclinic/dsl_parser.py b/Tools/clinic/libclinic/dsl_parser.py index 0d83baeba9e508..26f129c0538422 100644 --- a/Tools/clinic/libclinic/dsl_parser.py +++ b/Tools/clinic/libclinic/dsl_parser.py @@ -302,6 +302,9 @@ def reset(self) -> None: self.critical_section = False self.target_critical_section = [] self.disable_fastcall = False + self.vectorcall = False + self.vectorcall_exact_only = False + self.vectorcall_zero_arg = '' self.permit_long_summary = False self.permit_long_docstring_body = False @@ -466,6 +469,24 @@ def at_staticmethod(self) -> None: fail("Can't set @staticmethod, function is not a normal callable") self.kind = STATIC_METHOD + def at_vectorcall(self, *args: str) -> None: + if self.vectorcall: + fail("Called @vectorcall twice!") + self.vectorcall = True + for arg in args: + if '=' in arg: + key, value = arg.split('=', 1) + else: + key, value = arg, '' + if key == 'exact_only': + self.vectorcall_exact_only = True + elif key == 'zero_arg': + if not value: + fail("@vectorcall zero_arg requires a value") + self.vectorcall_zero_arg = value + else: + fail(f"@vectorcall: unknown argument {key!r}") + def at_coexist(self) -> None: if self.coexist: fail("Called @coexist twice!") @@ -599,6 +620,10 @@ def normalize_function_kind(self, fullname: str) -> None: elif name == '__init__': self.kind = METHOD_INIT + # Validate @vectorcall usage. + if self.vectorcall and not self.kind.new_or_init: + fail("@vectorcall can only be used with __init__ and __new__ methods currently") + def resolve_return_converter( self, full_name: str, forced_converter: str ) -> CReturnConverter: @@ -723,7 +748,10 @@ def state_modulename_name(self, line: str) -> None: critical_section=self.critical_section, disable_fastcall=self.disable_fastcall, target_critical_section=self.target_critical_section, - forced_text_signature=self.forced_text_signature + forced_text_signature=self.forced_text_signature, + vectorcall=self.vectorcall, + vectorcall_exact_only=self.vectorcall_exact_only, + vectorcall_zero_arg=self.vectorcall_zero_arg, ) self.add_function(func) diff --git a/Tools/clinic/libclinic/function.py b/Tools/clinic/libclinic/function.py index f981f0bcaf89f0..303d2e0704fddb 100644 --- a/Tools/clinic/libclinic/function.py +++ b/Tools/clinic/libclinic/function.py @@ -111,6 +111,9 @@ class Function: critical_section: bool = False disable_fastcall: bool = False target_critical_section: list[str] = dc.field(default_factory=list) + vectorcall: bool = False + vectorcall_exact_only: bool = False + vectorcall_zero_arg: str = '' def __post_init__(self) -> None: self.parent = self.cls or self.module diff --git a/Tools/clinic/libclinic/parse_args.py b/Tools/clinic/libclinic/parse_args.py index bca87ecd75100c..b1193abb154cc6 100644 --- a/Tools/clinic/libclinic/parse_args.py +++ b/Tools/clinic/libclinic/parse_args.py @@ -5,7 +5,7 @@ from libclinic import fail, warn from libclinic.function import ( Function, Parameter, - GETTER, SETTER, METHOD_NEW) + GETTER, SETTER, METHOD_NEW, METHOD_INIT) from libclinic.converter import CConverter from libclinic.converters import ( defining_class_converter, object_converter, self_converter) @@ -650,7 +650,6 @@ def parse_var_keyword(self) -> None: self.parser_body(*parser_code) def parse_general(self, clang: CLanguage) -> None: - parsearg: str | None deprecated_positionals: dict[int, Parameter] = {} deprecated_keywords: dict[int, Parameter] = {} for i, p in enumerate(self.parameters): @@ -725,69 +724,18 @@ def parse_general(self, clang: CLanguage) -> None: fastcall=self.fastcall) parser_code.append(code) - add_label: str | None = None - for i, p in enumerate(self.parameters): - if isinstance(p.converter, defining_class_converter): - raise ValueError("defining_class should be the first " - "parameter (after clang)") - displayname = p.get_displayname(i+1) - parsearg = p.converter.parse_arg(argname_fmt % i, displayname, limited_capi=self.limited_capi) - if parsearg is None: - parser_code = [] - use_parser_code = False - break - if add_label and (i == self.pos_only or i == self.max_pos): - parser_code.append("%s:" % add_label) - add_label = None - if not p.is_optional(): - parser_code.append(libclinic.normalize_snippet(parsearg, indent=4)) - elif i < self.pos_only: - add_label = 'skip_optional_posonly' - parser_code.append(libclinic.normalize_snippet(""" - if (nargs < %d) {{ - goto %s; - }} - """ % (i + 1, add_label), indent=4)) - if has_optional_kw: - parser_code.append(libclinic.normalize_snippet(""" - noptargs--; - """, indent=4)) - parser_code.append(libclinic.normalize_snippet(parsearg, indent=4)) - else: - if i < self.max_pos: - label = 'skip_optional_pos' - first_opt = max(self.min_pos, self.pos_only) - else: - label = 'skip_optional_kwonly' - first_opt = self.max_pos + self.min_kw_only - if i == first_opt: - add_label = label - parser_code.append(libclinic.normalize_snippet(""" - if (!noptargs) {{ - goto %s; - }} - """ % add_label, indent=4)) - if i + 1 == len(self.parameters): - parser_code.append(libclinic.normalize_snippet(parsearg, indent=4)) - else: - add_label = label - parser_code.append(libclinic.normalize_snippet(""" - if (%s) {{ - """ % (argname_fmt % i), indent=4)) - parser_code.append(libclinic.normalize_snippet(parsearg, indent=8)) - parser_code.append(libclinic.normalize_snippet(""" - if (!--noptargs) {{ - goto %s; - }} - }} - """ % add_label, indent=4)) + per_arg_code, success = self._generate_keyword_per_arg_parsing( + argname_fmt=argname_fmt, + has_optional_kw=has_optional_kw, + limited_capi=self.limited_capi, + ) + if success: + parser_code.extend(per_arg_code) + else: + parser_code = [] + use_parser_code = False - if use_parser_code: - if add_label: - parser_code.append("%s:" % add_label) - if self.varpos: - parser_code.append(libclinic.normalize_snippet(self._parse_vararg(), indent=4)) - else: + if not use_parser_code: for parameter in self.parameters: parameter.converter.use_converter() @@ -953,6 +901,7 @@ def create_template_dict(self) -> dict[str, str]: "cpp_if" : self.cpp_if, "cpp_endif" : self.cpp_endif, "methoddef_ifndef" : self.methoddef_ifndef, + "vectorcall_definition" : self.vectorcall_definition, } # make sure we didn't forget to assign something, @@ -965,6 +914,399 @@ def create_template_dict(self) -> dict[str, str]: d2[name] = value return d2 + def _vc_basename(self) -> str: + """Compute vectorcall function name from the C basename. + + Strips __init__/__new__ suffixes from c_basename and appends + _vectorcall. Respects 'as' renaming in clinic input, e.g. + 'str.__new__ as unicode_new' produces 'unicode_vectorcall'. + """ + name = self.func.c_basename + for suffix in ('___init__', '___new__', '_new', '_init'): + if name.endswith(suffix): + name = name[:-len(suffix)] + break + return f'{name}_vectorcall' + + def _generate_keyword_per_arg_parsing( + self, + *, + argname_fmt: str, + has_optional_kw: bool, + label_suffix: str = '', + limited_capi: bool = False, + ) -> tuple[list[str], bool]: + """Generate per-argument parsing code for keyword-capable functions. + + Shared between parse_general (FASTCALL|KEYWORDS) and vectorcall + keyword parsing. Returns (code_lines, success). success is False + when a converter doesn't support parse_arg. + """ + code: list[str] = [] + + def emit(text: str, indent: int = 4) -> None: + code.append(libclinic.normalize_snippet(text, indent=indent)) + + add_label: str | None = None + for i, p in enumerate(self.parameters): + if isinstance(p.converter, defining_class_converter): + raise ValueError("defining_class should be the first " + "parameter (after clang)") + displayname = p.get_displayname(i + 1) + parsearg = p.converter.parse_arg( + argname_fmt % i, displayname, limited_capi=limited_capi) + if parsearg is None: + return [], False + if add_label and (i == self.pos_only or i == self.max_pos): + code.append("%s:" % add_label) + add_label = None + if not p.is_optional(): + emit(parsearg) + elif i < self.pos_only: + add_label = f'skip_optional_posonly{label_suffix}' + emit(""" + if (nargs < %d) {{ + goto %s; + }} + """ % (i + 1, add_label)) + if has_optional_kw: + emit(""" + noptargs--; + """) + emit(parsearg) + else: + if i < self.max_pos: + label = f'skip_optional_pos{label_suffix}' + first_opt = max(self.min_pos, self.pos_only) + else: + label = f'skip_optional_kwonly{label_suffix}' + first_opt = self.max_pos + self.min_kw_only + if i == first_opt: + add_label = label + emit(""" + if (!noptargs) {{ + goto %s; + }} + """ % add_label) + if i + 1 == len(self.parameters): + emit(parsearg) + else: + add_label = label + emit(""" + if (%s) {{ + """ % (argname_fmt % i)) + emit(parsearg, indent=8) + emit(""" + if (!--noptargs) {{ + goto %s; + }} + }} + """ % add_label) + + if add_label: + code.append("%s:" % add_label) + if self.varpos: + emit(self._parse_vararg()) + return code, True + + def _generate_vc_pos_only_code( + self, + label_suffix: str = '', + indent: int = 4, + ) -> tuple[list[str], bool]: + """Generate positional-only argument parsing for vectorcall. + + Used both for the all-pos-only case and for the kwnames==NULL + fast path inside the general case. + + Returns (code_lines, success). success is False when a converter + doesn't support parse_arg (caller should fall back). + """ + max_args = NO_VARARG if self.varpos else self.max_pos + code: list[str] = [] + + def emit(text: str, ind: int = indent) -> None: + code.append(libclinic.normalize_snippet(text, indent=ind)) + + if self.min_pos or max_args != NO_VARARG: + self.codegen.add_include('pycore_modsupport.h', + '_PyArg_CheckPositional()') + emit(f""" + if (!_PyArg_CheckPositional("{{name}}", nargs, {self.min_pos}, {max_args})) {{{{ + goto exit; + }}}} + """) + + has_optional = False + skip_label = f'skip_optional_vc{label_suffix}' + for i, p in enumerate(self.parameters): + displayname = p.get_displayname(i + 1) + parsearg = p.converter.parse_arg( + f'args[{i}]', displayname, limited_capi=False) + if parsearg is None: + return [], False + if has_optional or p.is_optional(): + has_optional = True + emit(""" + if (nargs < %d) {{ + goto %s; + }} + """ % (i + 1, skip_label)) + emit(parsearg) + + if has_optional: + emit(f"{skip_label}:", ind=indent - 4) + + if self.varpos: + emit(self._parse_vararg()) + + return code, True + + def _generate_vc_parsing_code(self) -> list[str]: + """Generate FASTCALL-style argument parsing code for vectorcall.""" + no_params = (not self.parameters and not self.varpos + and not self.var_keyword) + all_pos_only = (self.pos_only == len(self.parameters) + and self.var_keyword is None) + + parser_code: list[str] = [] + snippet = libclinic.normalize_snippet + + def emit(text: str, indent: int = 4) -> None: + parser_code.append(snippet(text, indent=indent)) + + if no_params: + self.codegen.add_include('pycore_modsupport.h', + '_PyArg_NoKwnames()') + emit(""" + if (!_PyArg_NoKwnames("{name}", kwnames)) {{ + goto exit; + }} + if (nargs != 0) {{ + PyErr_Format(PyExc_TypeError, + "{name}() takes no arguments (%zd given)", + nargs); + goto exit; + }} + """) + return parser_code + elif all_pos_only: + self.codegen.add_include('pycore_modsupport.h', + '_PyArg_NoKwnames()') + emit(""" + if (!_PyArg_NoKwnames("{name}", kwnames)) {{ + goto exit; + }} + """) + + pos_code, success = self._generate_vc_pos_only_code() + if not success: + for parameter in self.parameters: + parameter.converter.use_converter() + self.codegen.add_include('pycore_modsupport.h', + '_PyArg_ParseStack()') + return [snippet(""" + if (!_PyArg_NoKwnames("{name}", kwnames)) {{ + goto exit; + }} + if (!_PyArg_ParseStack(args, nargs, "{format_units}:{name}", + {parse_arguments})) {{ + goto exit; + }} + """, indent=4)] + parser_code.extend(pos_code) + return parser_code + else: + # General case: has keyword args. Use _PyArg_UnpackKeywords + # in FASTCALL style. + + # Check if we can generate a kwnames==NULL fast path. + # This avoids the overhead of _PyArg_UnpackKeywords when + # only positional args are passed (the common case). + has_kw_only = any(p.is_keyword_only() + for p in self.parameters) + can_fast_path = (not has_kw_only and not self.varpos + and not self.var_keyword) + + if can_fast_path: + fast_code, success = self._generate_vc_pos_only_code( + label_suffix='_fast', indent=8) + if success: + emit(""" + if (kwnames == NULL) {{ + """) + parser_code.extend(fast_code) + emit(""" + goto vc_fast_end; + }} + """) + + self.codegen.add_include('pycore_modsupport.h', + '_PyArg_UnpackKeywords()') + vc_declarations = declare_parser( + self.func, codegen=self.codegen) + vc_declarations += ("\nPyObject *argsbuf[%s];" + % (len(self.converters) or 1)) + + nargs_expr = 'nargs' + if self.varpos: + nargs_expr = (f'Py_MIN(nargs, {self.max_pos})' + if self.max_pos else '0') + + has_optional_kw = ( + max(self.pos_only, self.min_pos) + self.min_kw_only + < len(self.converters) + ) + if has_optional_kw: + vc_declarations += ( + "\nPy_ssize_t noptargs = %s + " + "(kwnames ? PyTuple_GET_SIZE(kwnames) : 0) - %d;" + % (nargs_expr, self.min_pos + self.min_kw_only)) + + emit(vc_declarations) + + emit(f""" + args = _PyArg_UnpackKeywords(args, nargs, NULL, kwnames, + &_parser, + /*minpos*/ {self.min_pos}, /*maxpos*/ {self.max_pos}, + /*minkw*/ {self.min_kw_only}, + /*varpos*/ {1 if self.varpos else 0}, argsbuf); + if (!args) {{{{ + goto exit; + }}}} + """) + + per_arg_code, success = self._generate_keyword_per_arg_parsing( + argname_fmt='args[%d]', + has_optional_kw=has_optional_kw, + label_suffix='_vc', + ) + if not success: + for parameter in self.parameters: + parameter.converter.use_converter() + self.codegen.add_include( + 'pycore_modsupport.h', + '_PyArg_ParseStackAndKeywords()') + return [ + snippet(vc_declarations, indent=4), + snippet(""" + if (!_PyArg_ParseStackAndKeywords(args, nargs, + kwnames, &_parser{parse_arguments_comma} + {parse_arguments})) {{ + goto exit; + }} + """, indent=4)] + parser_code.extend(per_arg_code) + + if can_fast_path: + parser_code.append("vc_fast_end:") + + return parser_code + + def generate_vectorcall(self) -> str: + """Generate a vectorcall function for __init__ or __new__.""" + func = self.func + vc_basename = self._vc_basename() + + # Generate argument parsing code (FASTCALL-style) + parsing_code = self._generate_vc_parsing_code() + + # Build the function prototype + prototype = libclinic.normalize_snippet(f""" + static PyObject * + {vc_basename}(PyObject *type, PyObject *const *args, + size_t nargsf, PyObject *kwnames) + """) + + # Build the preamble + preamble = libclinic.normalize_snippet(""" + {{ + PyObject *return_value = NULL; + Py_ssize_t nargs = PyVectorcall_NARGS(nargsf); + {declarations} + {initializers} + """) + "\n" + + # Exact type check (if vectorcall_exact_only) + exact_check = "" + if func.vectorcall_exact_only and func.cls: + type_obj = func.cls.type_object + self.codegen.add_include('pycore_call.h', + '_PyObject_MakeTpCall()') + exact_check = libclinic.normalize_snippet(f""" + if (_PyType_CAST(type) != {type_obj}) {{{{ + PyThreadState *tstate = _PyThreadState_GET(); + return _PyObject_MakeTpCall(tstate, type, args, + nargs, kwnames); + }}}} + """, indent=4) + + # Build the finale (impl call + return) + if func.kind is METHOD_INIT: + finale = libclinic.normalize_snippet(""" + {modifications} + {lock} + {{ + PyObject *self = _PyType_CAST(type)->tp_alloc( + _PyType_CAST(type), 0); + if (self == NULL) {{ + goto exit; + }} + int _result = {c_basename}_impl({vc_impl_arguments}); + {unlock} + if (_result != 0) {{ + Py_DECREF(self); + goto exit; + }} + return_value = self; + }} + {post_parsing} + + {exit_label} + {cleanup} + return return_value; + }} + """) + else: + # METHOD_NEW + finale = libclinic.normalize_snippet(""" + {modifications} + {lock} + return_value = {c_basename}_impl({vc_impl_arguments}); + {unlock} + {return_conversion} + {post_parsing} + + {exit_label} + {cleanup} + return return_value; + }} + """) + + # Zero-arg shortcut: return a cached value when called with no args + zero_arg_shortcut = "" + if func.vectorcall_zero_arg: + zero_arg_shortcut = libclinic.normalize_snippet(f""" + if (nargs == 0 && kwnames == NULL) {{{{ + return {func.vectorcall_zero_arg}; + }}}} + """, indent=4) + + # Assemble the full function + lines = [prototype] + lines.append(preamble) + if exact_check: + lines.append(exact_check) + if zero_arg_shortcut: + lines.append(zero_arg_shortcut) + lines.extend(parsing_code) + lines.append(finale) + + code = libclinic.linear_format( + "\n".join(lines), + parser_declarations='') + return code + def parse_args(self, clang: CLanguage) -> dict[str, str]: self.select_prototypes() self.init_limited_capi() @@ -975,6 +1317,7 @@ def parse_args(self, clang: CLanguage) -> dict[str, str]: self.parser_definition = "" self.impl_prototype = None self.impl_definition = IMPL_DEFINITION_PROTOTYPE + self.vectorcall_definition = "" # parser_body_fields remembers the fields passed in to the # previous call to parser_body. this is used for an awful hack. @@ -1000,4 +1343,8 @@ def parse_args(self, clang: CLanguage) -> dict[str, str]: self.process_methoddef(clang) self.finalize(clang) + # Generate vectorcall function if requested + if self.func.vectorcall: + self.vectorcall_definition = self.generate_vectorcall() + return self.create_template_dict() From a9d0d6f4186f79b50f52fa6580677e5d35a46788 Mon Sep 17 00:00:00 2001 From: Cody Maloney Date: Sat, 28 Feb 2026 20:36:12 -0800 Subject: [PATCH 2/4] add blurb --- .../Tools-Demos/2026-02-28-20-35-47.gh-issue-87613.Nwzu6U.rst | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 Misc/NEWS.d/next/Tools-Demos/2026-02-28-20-35-47.gh-issue-87613.Nwzu6U.rst diff --git a/Misc/NEWS.d/next/Tools-Demos/2026-02-28-20-35-47.gh-issue-87613.Nwzu6U.rst b/Misc/NEWS.d/next/Tools-Demos/2026-02-28-20-35-47.gh-issue-87613.Nwzu6U.rst new file mode 100644 index 00000000000000..8ff15606829437 --- /dev/null +++ b/Misc/NEWS.d/next/Tools-Demos/2026-02-28-20-35-47.gh-issue-87613.Nwzu6U.rst @@ -0,0 +1,3 @@ +Add a ``@vectorcall`` decorator to Argument Clinic that can be used on +``__init__`` and ``__new__`` which generates :ref:`vectorcall` argument +parsing. From 15f74e8650f26cb2898417f7be7c80d481fcf222 Mon Sep 17 00:00:00 2001 From: Cody Maloney Date: Sun, 1 Mar 2026 01:33:47 -0800 Subject: [PATCH 3/4] Move enumerate, reversed in enum.c to AC vectorcall --- Objects/clinic/enumobject.c.h | 94 ++++++++++++++++++++++++++++++++++- Objects/enumobject.c | 89 ++------------------------------- 2 files changed, 98 insertions(+), 85 deletions(-) diff --git a/Objects/clinic/enumobject.c.h b/Objects/clinic/enumobject.c.h index 1bda482f4955ae..9092572d1e3ca2 100644 --- a/Objects/clinic/enumobject.c.h +++ b/Objects/clinic/enumobject.c.h @@ -81,6 +81,77 @@ enum_new(PyTypeObject *type, PyObject *args, PyObject *kwargs) return return_value; } +static PyObject * +enum_vectorcall(PyObject *type, PyObject *const *args, + size_t nargsf, PyObject *kwnames) +{ + PyObject *return_value = NULL; + Py_ssize_t nargs = PyVectorcall_NARGS(nargsf); + PyObject *iterable; + PyObject *start = 0; + + if (kwnames == NULL) { + if (!_PyArg_CheckPositional("enumerate", nargs, 1, 2)) { + goto exit; + } + iterable = args[0]; + if (nargs < 2) { + goto skip_optional_vc_fast; + } + start = args[1]; + skip_optional_vc_fast: + goto vc_fast_end; + } + #if defined(Py_BUILD_CORE) && !defined(Py_BUILD_CORE_MODULE) + + #define NUM_KEYWORDS 2 + static struct { + PyGC_Head _this_is_not_used; + PyObject_VAR_HEAD + Py_hash_t ob_hash; + PyObject *ob_item[NUM_KEYWORDS]; + } _kwtuple = { + .ob_base = PyVarObject_HEAD_INIT(&PyTuple_Type, NUM_KEYWORDS) + .ob_hash = -1, + .ob_item = { &_Py_ID(iterable), &_Py_ID(start), }, + }; + #undef NUM_KEYWORDS + #define KWTUPLE (&_kwtuple.ob_base.ob_base) + + #else // !Py_BUILD_CORE + # define KWTUPLE NULL + #endif // !Py_BUILD_CORE + + static const char * const _keywords[] = {"iterable", "start", NULL}; + static _PyArg_Parser _parser = { + .keywords = _keywords, + .fname = "enumerate", + .kwtuple = KWTUPLE, + }; + #undef KWTUPLE + PyObject *argsbuf[2]; + Py_ssize_t noptargs = nargs + (kwnames ? PyTuple_GET_SIZE(kwnames) : 0) - 1; + args = _PyArg_UnpackKeywords(args, nargs, NULL, kwnames, + &_parser, + /*minpos*/ 1, /*maxpos*/ 2, + /*minkw*/ 0, + /*varpos*/ 0, argsbuf); + if (!args) { + goto exit; + } + iterable = args[0]; + if (!noptargs) { + goto skip_optional_pos_vc; + } + start = args[1]; +skip_optional_pos_vc: +vc_fast_end: + return_value = enum_new_impl(_PyType_CAST(type), iterable, start); + +exit: + return return_value; +} + PyDoc_STRVAR(reversed_new__doc__, "reversed(object, /)\n" "--\n" @@ -110,4 +181,25 @@ reversed_new(PyTypeObject *type, PyObject *args, PyObject *kwargs) exit: return return_value; } -/*[clinic end generated code: output=155cc9483d5f9eab input=a9049054013a1b77]*/ + +static PyObject * +reversed_vectorcall(PyObject *type, PyObject *const *args, + size_t nargsf, PyObject *kwnames) +{ + PyObject *return_value = NULL; + Py_ssize_t nargs = PyVectorcall_NARGS(nargsf); + PyObject *seq; + + if (!_PyArg_NoKwnames("reversed", kwnames)) { + goto exit; + } + if (!_PyArg_CheckPositional("reversed", nargs, 1, 1)) { + goto exit; + } + seq = args[0]; + return_value = reversed_new_impl(_PyType_CAST(type), seq); + +exit: + return return_value; +} +/*[clinic end generated code: output=d0c0441d7f42cd54 input=a9049054013a1b77]*/ diff --git a/Objects/enumobject.c b/Objects/enumobject.c index 814ce4f919514b..4c7329acba572c 100644 --- a/Objects/enumobject.c +++ b/Objects/enumobject.c @@ -28,6 +28,7 @@ typedef struct { #define _enumobject_CAST(op) ((enumobject *)(op)) /*[clinic input] +@vectorcall @classmethod enumerate.__new__ as enum_new @@ -46,7 +47,7 @@ enumerate is useful for obtaining an indexed list: static PyObject * enum_new_impl(PyTypeObject *type, PyObject *iterable, PyObject *start) -/*[clinic end generated code: output=e95e6e439f812c10 input=782e4911efcb8acf]*/ +/*[clinic end generated code: output=e95e6e439f812c10 input=a139e88889360e8f]*/ { enumobject *en; @@ -87,71 +88,6 @@ enum_new_impl(PyTypeObject *type, PyObject *iterable, PyObject *start) return (PyObject *)en; } -static int check_keyword(PyObject *kwnames, int index, - const char *name) -{ - PyObject *kw = PyTuple_GET_ITEM(kwnames, index); - if (!_PyUnicode_EqualToASCIIString(kw, name)) { - PyErr_Format(PyExc_TypeError, - "'%S' is an invalid keyword argument for enumerate()", kw); - return 0; - } - return 1; -} - -// TODO: Use AC when bpo-43447 is supported -static PyObject * -enumerate_vectorcall(PyObject *type, PyObject *const *args, - size_t nargsf, PyObject *kwnames) -{ - PyTypeObject *tp = _PyType_CAST(type); - Py_ssize_t nargs = PyVectorcall_NARGS(nargsf); - Py_ssize_t nkwargs = 0; - if (kwnames != NULL) { - nkwargs = PyTuple_GET_SIZE(kwnames); - } - - // Manually implement enumerate(iterable, start=...) - if (nargs + nkwargs == 2) { - if (nkwargs == 1) { - if (!check_keyword(kwnames, 0, "start")) { - return NULL; - } - } else if (nkwargs == 2) { - PyObject *kw0 = PyTuple_GET_ITEM(kwnames, 0); - if (_PyUnicode_EqualToASCIIString(kw0, "start")) { - if (!check_keyword(kwnames, 1, "iterable")) { - return NULL; - } - return enum_new_impl(tp, args[1], args[0]); - } - if (!check_keyword(kwnames, 0, "iterable") || - !check_keyword(kwnames, 1, "start")) { - return NULL; - } - - } - return enum_new_impl(tp, args[0], args[1]); - } - - if (nargs + nkwargs == 1) { - if (nkwargs == 1 && !check_keyword(kwnames, 0, "iterable")) { - return NULL; - } - return enum_new_impl(tp, args[0], NULL); - } - - if (nargs == 0) { - PyErr_SetString(PyExc_TypeError, - "enumerate() missing required argument 'iterable'"); - return NULL; - } - - PyErr_Format(PyExc_TypeError, - "enumerate() takes at most 2 arguments (%d given)", nargs + nkwargs); - return NULL; -} - static void enum_dealloc(PyObject *op) { @@ -350,7 +286,7 @@ PyTypeObject PyEnum_Type = { PyType_GenericAlloc, /* tp_alloc */ enum_new, /* tp_new */ PyObject_GC_Del, /* tp_free */ - .tp_vectorcall = enumerate_vectorcall + .tp_vectorcall = enum_vectorcall }; /* Reversed Object ***************************************************************/ @@ -364,6 +300,7 @@ typedef struct { #define _reversedobject_CAST(op) ((reversedobject *)(op)) /*[clinic input] +@vectorcall @classmethod reversed.__new__ as reversed_new @@ -375,7 +312,7 @@ Return a reverse iterator over the values of the given sequence. static PyObject * reversed_new_impl(PyTypeObject *type, PyObject *seq) -/*[clinic end generated code: output=f7854cc1df26f570 input=4781869729e3ba50]*/ +/*[clinic end generated code: output=f7854cc1df26f570 input=7db568182ab28c59]*/ { Py_ssize_t n; PyObject *reversed_meth; @@ -417,22 +354,6 @@ reversed_new_impl(PyTypeObject *type, PyObject *seq) return (PyObject *)ro; } -static PyObject * -reversed_vectorcall(PyObject *type, PyObject * const*args, - size_t nargsf, PyObject *kwnames) -{ - if (!_PyArg_NoKwnames("reversed", kwnames)) { - return NULL; - } - - Py_ssize_t nargs = PyVectorcall_NARGS(nargsf); - if (!_PyArg_CheckPositional("reversed", nargs, 1, 1)) { - return NULL; - } - - return reversed_new_impl(_PyType_CAST(type), args[0]); -} - static void reversed_dealloc(PyObject *op) { From a243517bee269deff44d479d92a444b886b2594e Mon Sep 17 00:00:00 2001 From: Cody Maloney Date: Sun, 1 Mar 2026 01:35:42 -0800 Subject: [PATCH 4/4] Move tuple to AC vectorcall --- Objects/clinic/tupleobject.c.h | 30 +++++++++++++++++++++++++++++- Objects/tupleobject.c | 24 ++---------------------- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/Objects/clinic/tupleobject.c.h b/Objects/clinic/tupleobject.c.h index 1c12706c0bb43b..937d1764e2d610 100644 --- a/Objects/clinic/tupleobject.c.h +++ b/Objects/clinic/tupleobject.c.h @@ -111,6 +111,34 @@ tuple_new(PyTypeObject *type, PyObject *args, PyObject *kwargs) return return_value; } +static PyObject * +tuple_vectorcall(PyObject *type, PyObject *const *args, + size_t nargsf, PyObject *kwnames) +{ + PyObject *return_value = NULL; + Py_ssize_t nargs = PyVectorcall_NARGS(nargsf); + PyObject *iterable = NULL; + + if (nargs == 0 && kwnames == NULL) { + return (PyObject*)&_Py_SINGLETON(tuple_empty); + } + if (!_PyArg_NoKwnames("tuple", kwnames)) { + goto exit; + } + if (!_PyArg_CheckPositional("tuple", nargs, 0, 1)) { + goto exit; + } + if (nargs < 1) { + goto skip_optional_vc; + } + iterable = args[0]; +skip_optional_vc: + return_value = tuple_new_impl(_PyType_CAST(type), iterable); + +exit: + return return_value; +} + PyDoc_STRVAR(tuple___getnewargs____doc__, "__getnewargs__($self, /)\n" "--\n" @@ -127,4 +155,4 @@ tuple___getnewargs__(PyObject *self, PyObject *Py_UNUSED(ignored)) { return tuple___getnewargs___impl((PyTupleObject *)self); } -/*[clinic end generated code: output=bd11662d62d973c2 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=c1e02d9c2b36d1df input=a9049054013a1b77]*/ diff --git a/Objects/tupleobject.c b/Objects/tupleobject.c index 169ac69701da11..7de7ae20d1ae8c 100644 --- a/Objects/tupleobject.c +++ b/Objects/tupleobject.c @@ -713,6 +713,7 @@ static PyObject * tuple_subtype_new(PyTypeObject *type, PyObject *iterable); /*[clinic input] +@vectorcall zero_arg=(PyObject*)&_Py_SINGLETON(tuple_empty) @classmethod tuple.__new__ as tuple_new iterable: object(c_default="NULL") = () @@ -728,7 +729,7 @@ If the argument is a tuple, the return value is the same object. static PyObject * tuple_new_impl(PyTypeObject *type, PyObject *iterable) -/*[clinic end generated code: output=4546d9f0d469bce7 input=86963bcde633b5a2]*/ +/*[clinic end generated code: output=4546d9f0d469bce7 input=fff66d7a13734d92]*/ { if (type != &PyTuple_Type) return tuple_subtype_new(type, iterable); @@ -741,27 +742,6 @@ tuple_new_impl(PyTypeObject *type, PyObject *iterable) } } -static PyObject * -tuple_vectorcall(PyObject *type, PyObject * const*args, - size_t nargsf, PyObject *kwnames) -{ - if (!_PyArg_NoKwnames("tuple", kwnames)) { - return NULL; - } - - Py_ssize_t nargs = PyVectorcall_NARGS(nargsf); - if (!_PyArg_CheckPositional("tuple", nargs, 0, 1)) { - return NULL; - } - - if (nargs) { - return tuple_new_impl(_PyType_CAST(type), args[0]); - } - else { - return tuple_get_empty(); - } -} - static PyObject * tuple_subtype_new(PyTypeObject *type, PyObject *iterable) {