Repository navigation
bpo-28638: Optimize namedtuple() creation time by minimizing use of exec() #3454
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 19 commits
baca675
3a2aa41
e24ef4b
49aa967
c34b444
0c7f163
5de0b7f
ca643f4
e18b92e
0851fc7
3ac1151
b31f063
deb30a2
f402cb3
86cef9e
83b5e93
0168317
dadafc0
483d08c
bc6852c
c897674
bd4ea4e
ffb78c9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -435,6 +435,12 @@ API and Feature Removals | |
| Python 3.1, and has now been removed. Use the :func:`~os.path.splitdrive` | ||
| function instead. | ||
|
|
||
| * :func:`collections.namedtuple` no longer supports the *verbose* parameter | ||
| or ``_source`` attribute which showed the generated source code for the | ||
| named tuple class. This was part of an optimization designed to speed-up | ||
| class creation. (Contributed by Jelle Zijlstra with further improvements | ||
| by INADA Naoki, Serhiy Storchaka, and Raymond Hettinger in :issue:`28638`.) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I suppose here should be "Naoki Inada" instead of "INADA Naoki".
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Perhaps. That is for him to decide.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. While I don't care in general, I prefer "INADA Naoki" here because I use it in git config "user.name". |
||
|
|
||
| * Functions :func:`bool`, :func:`float`, :func:`list` and :func:`tuple` no | ||
| longer take keyword arguments. The first argument of :func:`int` can now | ||
| be passed only as positional argument. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -301,59 +301,9 @@ def __eq__(self, other): | |
| ### namedtuple | ||
| ################################################################################ | ||
|
|
||
| _class_template = """\ | ||
| from builtins import property as _property, tuple as _tuple | ||
| from operator import itemgetter as _itemgetter | ||
| from collections import OrderedDict | ||
| _nt_itemgetters = {} | ||
|
|
||
| class {typename}(tuple): | ||
| '{typename}({arg_list})' | ||
|
|
||
| __slots__ = () | ||
|
|
||
| _fields = {field_names!r} | ||
|
|
||
| def __new__(_cls, {arg_list}): | ||
| 'Create new instance of {typename}({arg_list})' | ||
| return _tuple.__new__(_cls, ({arg_list})) | ||
|
|
||
| @classmethod | ||
| def _make(cls, iterable, new=tuple.__new__, len=len): | ||
| 'Make a new {typename} object from a sequence or iterable' | ||
| result = new(cls, iterable) | ||
| if len(result) != {num_fields:d}: | ||
| raise TypeError('Expected {num_fields:d} arguments, got %d' % len(result)) | ||
| return result | ||
|
|
||
| def _replace(_self, **kwds): | ||
| 'Return a new {typename} object replacing specified fields with new values' | ||
| result = _self._make(map(kwds.pop, {field_names!r}, _self)) | ||
| if kwds: | ||
| raise ValueError('Got unexpected field names: %r' % list(kwds)) | ||
| return result | ||
|
|
||
| def __repr__(self): | ||
| 'Return a nicely formatted representation string' | ||
| return self.__class__.__name__ + '({repr_fmt})' % self | ||
|
|
||
| def _asdict(self): | ||
| 'Return a new OrderedDict which maps field names to their values.' | ||
| return OrderedDict(zip(self._fields, self)) | ||
|
|
||
| def __getnewargs__(self): | ||
| 'Return self as a plain tuple. Used by copy and pickle.' | ||
| return tuple(self) | ||
|
|
||
| {field_defs} | ||
| """ | ||
|
|
||
| _repr_template = '{name}=%r' | ||
|
|
||
| _field_template = '''\ | ||
| {name} = _property(_itemgetter({index:d}), doc='Alias for field number {index:d}') | ||
| ''' | ||
|
|
||
| def namedtuple(typename, field_names, *, verbose=False, rename=False, module=None): | ||
| def namedtuple(typename, field_names, *, rename=False, module=None): | ||
| """Returns a new subclass of tuple with named fields. | ||
|
|
||
| >>> Point = namedtuple('Point', ['x', 'y']) | ||
|
|
@@ -390,46 +340,107 @@ def namedtuple(typename, field_names, *, verbose=False, rename=False, module=Non | |
| or _iskeyword(name) | ||
| or name.startswith('_') | ||
| or name in seen): | ||
| field_names[index] = '_%d' % index | ||
| field_names[index] = f'_{index}' | ||
| seen.add(name) | ||
| for name in [typename] + field_names: | ||
| if type(name) is not str: | ||
| raise TypeError('Type names and field names must be strings') | ||
| if not name.isidentifier(): | ||
| raise ValueError('Type names and field names must be valid ' | ||
| 'identifiers: %r' % name) | ||
| f'identifiers: {name!r}') | ||
| if _iskeyword(name): | ||
| raise ValueError('Type names and field names cannot be a ' | ||
| 'keyword: %r' % name) | ||
| f'keyword: {name!r}') | ||
| seen = set() | ||
| for name in field_names: | ||
| if name.startswith('_') and not rename: | ||
| raise ValueError('Field names cannot start with an underscore: ' | ||
| '%r' % name) | ||
| f'{name!r}') | ||
| if name in seen: | ||
| raise ValueError('Encountered duplicate field name: %r' % name) | ||
| raise ValueError(f'Encountered duplicate field name: {name!r}') | ||
| seen.add(name) | ||
|
|
||
| # Fill-in the class template | ||
| class_definition = _class_template.format( | ||
| typename = typename, | ||
| field_names = tuple(field_names), | ||
| num_fields = len(field_names), | ||
| arg_list = repr(tuple(field_names)).replace("'", "")[1:-1], | ||
| repr_fmt = ', '.join(_repr_template.format(name=name) | ||
| for name in field_names), | ||
| field_defs = '\n'.join(_field_template.format(index=index, name=name) | ||
| for index, name in enumerate(field_names)) | ||
| ) | ||
|
|
||
| # Execute the template string in a temporary namespace and support | ||
| # tracing utilities by setting a value for frame.f_globals['__name__'] | ||
| namespace = dict(__name__='namedtuple_%s' % typename) | ||
| exec(class_definition, namespace) | ||
| result = namespace[typename] | ||
| result._source = class_definition | ||
| if verbose: | ||
| print(result._source) | ||
| # Variables used in the methods and docstrings | ||
| field_names = tuple(map(_sys.intern, field_names)) | ||
| num_fields = len(field_names) | ||
| arg_list = repr(field_names).replace("'", "")[1:-1] | ||
| repr_fmt = '(' + ', '.join(f'{name}=%r' for name in field_names) + ')' | ||
| tuple_new = tuple.__new__ | ||
| _len = len | ||
|
|
||
| # Create all the named tuple methods to be added to the class namespace | ||
|
|
||
| s = f'def __new__(_cls, {arg_list}): return _tuple_new(_cls, ({arg_list}))' | ||
| namespace = {'_tuple_new': tuple_new, '__name__': f'namedtuple_{typename}'} | ||
| # Note: exec() has the side-effect of interning the typename and field names | ||
| exec(s, namespace) | ||
| __new__ = namespace['__new__'] | ||
| __new__.__doc__ = f'Create new instance of {typename}({arg_list})' | ||
|
|
||
| @classmethod | ||
| def _make(cls, iterable): | ||
| result = tuple_new(cls, iterable) | ||
| if _len(result) != num_fields: | ||
| raise TypeError(f'Expected {num_fields} arguments, got {len(result)}') | ||
| return result | ||
|
|
||
| _make.__func__.__doc__ = (f'Make a new {typename} object from a sequence ' | ||
| 'or iterable') | ||
|
|
||
| def _replace(_self, **kwds): | ||
| result = _self._make(map(kwds.pop, field_names, _self)) | ||
| if kwds: | ||
| raise ValueError(f'Got unexpected field names: {list(kwds)!r}') | ||
| return result | ||
|
|
||
| _replace.__doc__ = (f'Return a new {typename} object replacing specified ' | ||
| 'fields with new values') | ||
|
|
||
| def __repr__(self): | ||
| 'Return a nicely formatted representation string' | ||
| return self.__class__.__name__ + repr_fmt % self | ||
|
|
||
| def _asdict(self): | ||
| 'Return a new OrderedDict which maps field names to their values.' | ||
| return OrderedDict(zip(self._fields, self)) | ||
|
|
||
| def __getnewargs__(self): | ||
| 'Return self as a plain tuple. Used by copy and pickle.' | ||
| return tuple(self) | ||
|
|
||
| # Modify function metadata to help with introspection and debugging | ||
|
|
||
| for method in (__new__, _make.__func__, _replace, | ||
| __repr__, _asdict, __getnewargs__): | ||
| method.__qualname__ = f'{typename}.{method.__name__}' | ||
|
|
||
| # Helper function used in the class creation | ||
|
|
||
| def reuse_itemgetter(index): | ||
| try: | ||
| return _nt_itemgetters[index] | ||
| except KeyError: | ||
| getter = _nt_itemgetters[index] = _itemgetter(index) | ||
| return getter | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How about caching property? doc = f'Alias for field number {index}'
prop = _nt_props[index] = property(_itemgetter(index), doc=doc)
return prop
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can't cache property() the docstring is mutable and can updated by the user, so the property objects need to be distinct.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh I didn't realized it. |
||
|
|
||
| # Build-up the class namespace dictionary | ||
| # and use type() to build the result class | ||
| class_namespace = { | ||
| '__doc__': f'{typename}({arg_list})', | ||
| '__slots__': (), | ||
| '_fields': field_names, | ||
| '__new__': __new__, | ||
| '_make': _make, | ||
| '_replace': _replace, | ||
| '__repr__': __repr__, | ||
| '_asdict': _asdict, | ||
| '__getnewargs__': __getnewargs__, | ||
| } | ||
| for index, name in enumerate(field_names): | ||
| doc = _sys.intern(f'Alias for field number {index}') | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe use the same cache for docstrings?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No need. sys.intern() takes care of the caching for us and does so at C-speed. And though it probably isn't needed, it can share strings across the whole system including hand-rolled named tuples.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually using Additionally, adding strings that are not attribute or parameter names to an interned strings hashtable increases the size of a hashtable and the probability of hash collisions. |
||
| class_namespace[name] = property(reuse_itemgetter(index), doc=doc) | ||
|
|
||
| result = type(typename, (tuple,), class_namespace) | ||
|
|
||
| # For pickling to work, the __module__ variable needs to be set to the frame | ||
| # where the named tuple is created. Bypass this step in environments where | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| Changed the implementation strategy for collections.namedtuple() to | ||
| substantially reduce the use of exec() in favor of precomputed methods. As a | ||
| result, the *verbose* parameter and *_source* attribute are no longer | ||
| supported. The benefits include 1) having a smaller memory footprint for | ||
| applications using multiple named tuples, 2) faster creation of the named | ||
| tuple class (approx 4x to 6x depending on how it is measured), and 3) minor | ||
| speed-ups for instance creation using __new__, _make, and _replace. (The | ||
| primary patch contributor is Jelle Zijlstra with further improvements by | ||
| INADA Naoki, Serhiy Storchaka, and Raymond Hettinger.) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it is worth to add a note about an optimization in the "Optimizations" section.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't want to litter the docs with more notes about this. The more important part to note is the feature removal in whatsnew. The optimization is mostly negligible and insignificant to most users (normal startup doesn't use named tuples at all; a module using one named tuple was using only 0.3ms which was just under 1% of the overall start-up time; formerly, we could make 3 namedtuple classes per millisecond and now we can make about 15). It is one of the least important optimizations we could have done.