Repository navigation
Conversation
… in C The C implementation considerably boosts the performance of the key UUID operations: ------------------------------------ Operation Speedup ------------------------------------ uuid4() generation 15.01x uuid7() generation 29.64x UUID from string 6.76x UUID from bytes 5.16x str(uuid) conversion 6.66x ------------------------------------ Summary of changes: * The UUID type is reimplemented in C in its entirety. * The pure-Python is kept around and is used of the C implementation isn't available for some reason. * Both implementations are tested extensively; additional tests are added to ensure that the C implementation of the type follows the pure Python implementation fully. * The Python implementation stores UUID values as int objects. The C implementation stores them as `uint8_t[16]` array. * The C implementation supports unpickling of UUIDs created with Python 2 using protocols starting with 0. That necessitated a small fix to the `copyreg` module (the change is only affecting legacy pickle pathway). * The C implementation has faster hash() implementation but also caches the computed hash value to speedup cases when UUIDs are used as set/dict keys. * The C implementation has a freelist to make new UUID object instantiation as fast as possible. * uuid4() and uuid7() are now implmented in C. The most performance boost (10x) comes from overfetching entropy to decrease the number of _PyOS_URandom() calls. On its own it's a safe optimization with the edge case that Unix fork needs to be explicitly handled. We do that by comparing the current PID to the PID of when the random buffer was populated. * Portions of code are coming from my implementation of faster UUID in gel-python [1]. I did use AI during the development, but basically had to rewrite the code it generated to be more idiomatic and efficient. * The benchmark can be found here [2]. * This PR makes Python UUID operations as fast as they are in NodeJS and Bun runtimes. [1] https://github.com/MagicStack/py-pgproto/blob/b8109fb311a59f30f9947567a13508da9a776564/uuid.pyx [2] https://gist.github.com/1st1/f03e816f34a61e4d46c78ff98baf4818
2d0a381 to
c4363f8
Compare
fa12192 to
4a5c2a2
Compare
|
Note to reviewers:
|
|
|
||
| *counter = (((uint64_t)(high & 0x1FF) << 32) | (low >> 32)) & 0x1FFFFFFFFFF; | ||
| *tail = (uint32_t)low; | ||
| return 0; |
There was a problem hiding this comment.
I haven't tested thoroughly (mostly looking at GodBolt output), but this ought to generate more efficient code (and there might be more ways to generate the right values directly, I just didn't go that far):
struct {
uint16_t high;
uint32_t low1;
uint32_t low2;
} rand_bytes;
if (gen_random(state, (uint8_t*)&rand_bytes, sizeof(rand_bytes)) < 0) {
return -1;
}
*counter = (((uint64_t)(rand_bytes.high & 0x1FF) << 32) | (rand_bytes.low1)) & 0x1FFFFFFFFFF;
*tail = rand_bytes.low2;
|
I would be happy to have a C implementation of UUID but for reviewing purposes, may I suggest that we first focus on implementing the wrapper in C in one PR and have different PRs for each UUID? it would be much easier to focus on the algorithm to review. |
picnixz
left a comment
There was a problem hiding this comment.
These are the first round comments I have. I realy want multiple PRs because the module becomes really huge and there are parts that I don't think we need to reimplement in C.
Sorry, I'm not going to do multiple PRs, I've no time for that. But I will address your comments here for sure! |
da233b4 to
ba9217e
Compare
|
I would REALLY appreciate smaller PRs. Usually this is our (current) workflow, namely incrementing changes. I think other core devs would agree with me here (@vstinner, @serhiy-storchaka and @encukou). Note that whatever we do, this is a feature that will only be included in 3.15 so I can also make the small PRs once I am back at home. Changing 3.14 is no more possible unless the RM gives their approval here (cc @hugovk, who could also whether he prefers one or multiple PR) as performance improvements are usually considered as new features (especially if we are adding a C implementation). |
The current PR layout:
I'm usually also for making smaller PRs, but objectively there's no point for that right here; I don't see how reviewing this would be fundamentally any different if instead of one PR with 1700 lines in uuidmodule.c, you'd have 3 PRs, one with 1600 lines and another with like 100 lines, and yet another with 30. Reviewing time would be the same, no? What's the point? If you absolutely insist I can do this, but tbqh I really don't understand why you're pushing for this so hard. It would create a lot of work for me and not necessarily make reviewers more happy.
I don't think this is 3.14 material and I wasn't pushing for that. 3.15 is fine (unless other people but me want this to be in 3.14, in which case I won't say no). That said I'd like to see my work through and would love to still merge this myself. |
|
@picnixz I'm sorry for my snappiness (I blame 4 days spent deep in C coding! :)) I'm adding some basic machinery to test that C implementation of uuid() functions behave identically. Since this is even more extra code it's now very reasonable to split this into two prs: one for the base UUID type, another for uuid4() and uuid7(). I'll do that later. |
This fixes a segfault; regr test added. While there, make the freelist only work with our base UUID type and no subclasses of it.
Documentation build overview
11 files changed ·
|
|
The following reproducer crashes for me: import gc
import sys
import uuid
_uuid = sys.modules['_uuid']
UUID = _uuid.UUID
assert uuid.UUID is UUID, "C implementation not in use"
# Pass 1: drop the Python module and its NAMESPACE_*/NIL/MAX instances, so no
# base-type instance is left to keep the C type alive.
del sys.modules['uuid']
del uuid
gc.collect()
class Sub(UUID):
pass
Sub.keep = Sub(int=1) # instance reachable only from the (younger) subclass
del sys.modules['_uuid']
del _uuid, UUID, Sub
print('final collect')
gc.collect() # module, base type, Sub and Sub.keep are all garbage
print("survived")The crash is not due to the freelist, but is related: the crash is due to module state lookup which is there for the freelist. The |
Should be fixed! |
picnixz
left a comment
There was a problem hiding this comment.
Commenting first for the Python and the documentation part (some comments I left last year should still be addressed but those are mainly for rendering docs or tests).
| variant and version number set according to :rfc:`9562`, overriding bits in the | ||
| given *hex*, *bytes*, *bytes_le*, *fields*, or *int*. | ||
|
|
||
| .. versionchanged:: 3.16 |
There was a problem hiding this comment.
| .. versionchanged:: 3.16 | |
| .. versionchanged:: next |
| self.assertEqual( | ||
| self.uuid.UUID(*kwargs.values(), version=None), expected) |
There was a problem hiding this comment.
| self.assertEqual( | |
| self.uuid.UUID(*kwargs.values(), version=None), expected) | |
| u_with_args = self.uuid.UUID(*kwargs.values(), version=None) | |
| self.assertEqual(u_with_args, expected) |
| with self.subTest(input=name): | ||
| kwargs = dict.fromkeys(inputs) | ||
| kwargs[name] = value | ||
| self.assertEqual(self.uuid.UUID(**kwargs, version=None), expected) |
There was a problem hiding this comment.
| self.assertEqual(self.uuid.UUID(**kwargs, version=None), expected) | |
| u_with_kwargs = self.uuid.UUID(**kwargs, version=None) | |
| self.assertEqual(u_with_kwargs, expected) |
| for safety in self.uuid.SafeUUID | ||
| ) | ||
| with support.swap_item(sys.modules, 'uuid', self.uuid): | ||
| for mapping in (dict, UserDict, MappingProxyType): |
There was a problem hiding this comment.
| for mapping in (dict, UserDict, MappingProxyType): | |
| for mapping in (dict, frozendict, UserDict, MappingProxyType): |
|
|
||
| def test_setstate_missing_int(self): | ||
| # A missing required key must retain the mapping's KeyError and key. | ||
| for mapping in (dict, UserDict, MappingProxyType): |
There was a problem hiding this comment.
| for mapping in (dict, UserDict, MappingProxyType): | |
| for mapping in (dict, frozendict, UserDict, MappingProxyType): |
| _c_uuid4 = uuid4 | ||
| _c_uuid7 = uuid7 | ||
|
|
||
| def main(): |
There was a problem hiding this comment.
| _c_uuid4 = uuid4 | |
| _c_uuid7 = uuid7 | |
| def main(): | |
| _c_uuid4 = uuid4 | |
| _c_uuid7 = uuid7 | |
| def main(): |
| _py_UUID = UUID | ||
| try: | ||
| from _uuid import UUID, uuid4, uuid7 | ||
| from _uuid import _install_c_hooks as _install_c_hooks |
There was a problem hiding this comment.
I'm not sure about the as _install_c_hooks relevance.
| _c_UUID = None | ||
| _c_uuid4 = None | ||
| _c_uuid7 = None |
There was a problem hiding this comment.
| _c_UUID = None | |
| _c_uuid4 = None | |
| _c_uuid7 = None | |
| _c_UUID = _c_uuid4 = _c_uuid7 = None |
| _py_uuid4 = uuid4 | ||
| _py_uuid7 = uuid7 | ||
| _py_UUID = UUID |
There was a problem hiding this comment.
| _py_uuid4 = uuid4 | |
| _py_uuid7 = uuid7 | |
| _py_UUID = UUID | |
| _py_UUID = UUID | |
| _py_uuid4 = uuid4 | |
| _py_uuid7 = uuid7 |
| Reimplement base UUID type, uuid4(), and uuid7() in C | ||
|
|
There was a problem hiding this comment.
| Reimplement base UUID type, uuid4(), and uuid7() in C | |
| :mod:`uuid`: Reimplement :class:`~uuid.UUID` objects, :func:`~uuid.uuid4`, | |
| and :func:`~uuid.uuid7` in C. |
Just to get some nice markup in the changelog.
picnixz
left a comment
There was a problem hiding this comment.
Mostly LGTM with a few nitpicks and questions (about __str__/__repr__/__setattr__).
|
|
||
| *counter = (((uint64_t)(high & 0x1FF) << 32) | (low >> 32)) & 0x1FFFFFFFFFF; | ||
| *tail = (uint32_t)low; | ||
| return 0; |
|
|
||
| /*[clinic input] | ||
| @critical_section | ||
| _uuid._install_c_hooks |
There was a problem hiding this comment.
Could you add a docstring for this one to say "Only for testing purposes."
| int i, j; | ||
|
|
||
| // Reimplement `hex = hex.replace('urn:', '').replace('uuid:', '')` | ||
| if (size > 0 && start[0] == 'u') { |
There was a problem hiding this comment.
There is a slight difference here where uuid:urn:... is also supported in the pure Python version. So what we could do is reject it in the pure Python version as well as it's not a legitimate change. More precisely, what is allowed are prefixes urn:uuid:, urn: and uuid:.
I would suggest that we change the pure Python implementation so that we only parse those forms and not those that contain uuid in the middle of the string like abcuuid:def (which would be 'abcdef').
Looking at one of your other comment, if you want to retain exact same semantics, you need to really re-implement the substring replacement (not just the prefix replacement).
| if (size >= 1 && start[0] == '{') { | ||
| start++; | ||
| size -= 1; | ||
| } | ||
| if (size >= 1 && start[size - 1] == '}') { | ||
| size -= 1; | ||
| } |
There was a problem hiding this comment.
Strictly speaking you need a while loop here for the strip. Either do it with a while loop or change the Python implementation to removeprefix('{') and removesuffix('}') so that we only strip one character and not consecutive ones.
| if (PyBytes_GET_SIZE(bytes_le) != UUID_SIZE) { | ||
| PyErr_SetString(PyExc_ValueError, | ||
| "bytes_le is not a 16-char string"); |
There was a problem hiding this comment.
| if (PyBytes_GET_SIZE(bytes_le) != UUID_SIZE) { | |
| PyErr_SetString(PyExc_ValueError, | |
| "bytes_le is not a 16-char string"); | |
| if (PyBytes_GET_SIZE(bytes_le) != UUID_SIZE) { | |
| PyErr_Format(PyExc_ValueError, | |
| "bytes_le is not a %d-char string", UUID_SIZE); |
| // UUID string format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (36 chars) | ||
| char str[36]; | ||
|
|
||
| for (int i = 0; i < 4; i++) { |
There was a problem hiding this comment.
If you unrolled the loops, is it faster?
| } | ||
|
|
||
| // Get the class name (sadly can't use tp_name -- we don't need the full name) | ||
| PyObject *cls_name = PyObject_GetAttrString((PyObject *)Py_TYPE(self), "__name__"); |
| return NULL; | ||
| } | ||
|
|
||
| PyObject *repr = PyUnicode_FromFormat("%U('%U')", cls_name, str_obj); |
There was a problem hiding this comment.
I don't remember now but if someone subclass the UUID class and has a different __str__, wouldn't it be better if we use %U('%S') then? using PyType_GetName() should also be enought.
| } | ||
|
|
||
| static int | ||
| Uuid_setattr(PyObject *Py_UNUSED(self), PyObject *Py_UNUSED(name), |
There was a problem hiding this comment.
Do we really need to override the __setattr__? aren't we by default read-only here?
| state->uuid_type = (PyTypeObject *)PyType_FromMetaclass( | ||
| NULL, | ||
| module, | ||
| &Uuid_spec, | ||
| NULL | ||
| ); |
There was a problem hiding this comment.
| state->uuid_type = (PyTypeObject *)PyType_FromMetaclass( | |
| NULL, | |
| module, | |
| &Uuid_spec, | |
| NULL | |
| ); | |
| state->uuid_type = (PyTypeObject *)PyType_FromModuleAndSpec( | |
| module, | |
| &Uuid_spec, | |
| NULL | |
| ); |
I think it's enough that way.
The C implementation considerably boosts the performance of the key UUID operations:
Summary of changes:
The UUID type is reimplemented in C in its entirety.
The pure-Python is kept around and is used of the C implementation isn't available for some reason.
Both implementations are tested extensively; additional tests are added to ensure that the C implementation of the type follows the pure Python implementation fully.
The Python implementation stores UUID values as int objects. The C implementation stores them as
uint8_t[16]array.The C implementation supports unpickling of UUIDs created with Python 2 using protocols starting with 0.
The C implementation has faster hash() implementation but also caches the computed hash value to speedup cases when UUIDs are used as set/dict keys.
The C implementation has a freelist to make new UUID object instantiation as fast as possible.
uuid4() and uuid7() are now implmented in C. The most performance boost (10x) comes from overfetching entropy to decrease the number of _PyOS_URandom() calls. On its own it's a safe optimization with the edge case that Unix fork needs to be explicitly handled. We do that by comparing the current PID to the PID of when the random buffer was populated.
Portions of code are coming from my implementation of faster UUID in gel-python [1]. I did use AI during the development, but basically had to rewrite the code it generated to be more idiomatic and efficient.
The benchmark can be found here [2].
This PR makes Python UUID operations as fast as they are in NodeJS and Bun runtimes.
[1] https://github.com/MagicStack/py-pgproto/blob/b8109fb311a59f30f9947567a13508da9a776564/uuid.pyx
[2] https://gist.github.com/1st1/f03e816f34a61e4d46c78ff98baf4818