| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
Sorry, something went wrong.
|
numpy has a Npy_HashDouble() compatibility layer to handle _Py_HashDouble() of Python 3.9 and older which only takes a single C double argument. numpy calls Npy_HashDouble(obj, value) in scalartypes.c.src.
Proposed API has a single argument and cannot be used as a drop-in replacement for _Py_HashDouble(). I would prefer to let C extensions decide how to handle not-a-number special case. Example: Py_hash_t hash = PyHash_Double(value);
if (hash == -1) {
return _Py_HashPointer(obj);
}
return hash;Problem: _Py_HashPointer() is also private. Once this PR is merged, I will prepare a follow-up PR to add a public PyHash_Pointer() function. Since numpy already has a Npy_HashDouble() compatibility layer, it can reimplement such "PyHash_Double() or _Py_HashPointer()" logic in a single place (npy_pycompat.h). The _Py_HashDouble() function had 1 argument (Py_hash_t _Py_HashDouble(double v)) but was changed in Python 3.10a7 (or b1) to get a second argument: Py_hash_t _Py_HashDouble(PyObject *inst, double v). Python now uses the identity for the hash when the value is a NaN, see gh-87641. In Python 3.9, hash(float("nan")) returned 0 (#define _PyHASH_NAN 0). By the way, in Python 3.13, sys.hash_info.nan still exists and is equal to 0, even if hash(float("nan)) no longer return 0! See https://docs.python.org/dev/library/sys.html#sys.hash_info documentation:
|
Sorry, something went wrong.
|
Another slice of Python history. In Python 3.2, PyObject_Hash() return type changed from long to Py_hash_t to reduce hash collisions on 64-bit Windows where long is only 32-bit (whereas Py_hash_t is 64-bit). The Py_hash_t and Py_uhash_t types were added to Python 3.2. See commit 8f67d08 and commit 8035bc5 of issue gh-53987 (bpo-9778). |
Sorry, something went wrong.
|
I'm not a fan of signed number of hash. For example, I prefer to avoid it when using modulo operator (x % y). I would prefer to use the unsigned Py_uhash_t type. The signed Py_hash_t type is used as the return type of the PyTypeObject.tp_hash function. |
Sorry, something went wrong.
Draft PR gh-112096. I prefer to only start with PyHash_Double() to discuss the PyHash API first:
|
Sorry, something went wrong.
Sorry, something went wrong.
|
I merged a first change to make this PR smaller and so easier to review. The PR #112098 added documentation and tests on the PyHash_GetFuncDef() function which was added by PEP 456. |
Sorry, something went wrong.
|
The PR adds Py_hash_t PyHash_Double(double value).
If needed, a second function can be added: Py_hash_t PyHash_DoubleOrPointer(double value, const void *ptr)=> Compute value hash, or compute ptr hash if value is not-a-number (NaN). For me, it's surprising that when passing a Python object in _Py_HashDouble(), the function doesn't call PyObject_Hash(obj) but _Py_HashPointer(obj). Or do you prefer to just expose Py_hash_t _Py_HashDouble(PyObject *inst, double v) as it is? |
Sorry, something went wrong.
I think, this attribute should be deprecated (or just removed?). |
Sorry, something went wrong.
|
|
||
| Hash a C double number. | ||
|
|
||
| Return ``-1`` if *value* is not-a-number (NaN). |
There was a problem hiding this comment.
Maybe you should document return value for inf too? This is exposed in sys.hash_info.
Sorry, something went wrong.
There was a problem hiding this comment.
I prefer to wait until the _PyHASH_INF constant is added to the API. That's the C API documentation, not the Python documentation.
Sorry, something went wrong.
| Functions | ||
| ^^^^^^^^^ | ||
|
|
||
| .. c:function:: Py_hash_t PyHash_Double(double value) |
There was a problem hiding this comment.
I would prefer to expose this as unstable API. Hashing of numeric types is relatively low-level detail of implementation, which was changed in past for minor (3.x.0) releases (nans hashing was last). Why not keep this freedom in future?
Sorry, something went wrong.
There was a problem hiding this comment.
I don't expect PyHash_Double() API to change in the future. The result of the function can change in Python 3.x.0 releases, but I don't consider that it qualifies the function for the PyUnstable API.
The PyUnstable API is more when there is a risk that the function can be removed, that its API can change, or that a major change can happen in a Python 3.x.0 release.
In Python 3.2 (2010), _Py_HashChange() was written in commit dc787d2 of issue gh-52435.
commit dc787d2055a7b562b64ca91b8f1af6d49fa39f1c
Author: Mark Dickinson <dickinsm@gmail.com>
Date: Sun May 23 13:33:13 2010 +0000
Issue #8188: Introduce a new scheme for computing hashes of numbers
(instances of int, float, complex, decimal.Decimal and
fractions.Fraction) that makes it easy to maintain the invariant that
hash(x) == hash(y) whenever x and y have equal value.
As written above, the latest major change was in Python 3.10 to treat NaN differently.
Sorry, something went wrong.
There was a problem hiding this comment.
The result of the function can change in Python 3.x.0 releases
Previously, the function signature for _Py_HashDouble() was changed too.
Sorry, something went wrong.
If you consider that something should be changed, you can open a new issue about sys.hash_info.nan. |
Sorry, something went wrong.
There was a problem hiding this comment.
This PR contains many cosmetic changes, but also some changes that can affect performance in theory (like adding the "else" branch or adding additional check for -1). Please make precise benchmarks for this. Also consult with previous authors of this code.
To make PyHash_Double a replacement of _PyHash_Double you need Py_HashPointer. Maybe add it first?
BTW, should it be PyHash_Double or Py_HashDouble?
Sorry, something went wrong.
|
In terms of the proposed C API guidleines: this violates the one where negative results are reserved for errors. Actually, hashes might be a good argument for only reserving -1 for errors, leaving the other negative numbers mean success. |
Sorry, something went wrong.
* Add again _PyHASH_NAN constant. * _Py_HashDouble(NULL, value) now returns _PyHASH_NAN. * Add tests: Modules/_testcapi/hash.c and Lib/test/test_capi/test_hash.py.
|
I updated the PR to address @serhiy-storchaka and @encukou's comments. @serhiy-storchaka and @encukou: Please review the updated PR. The API changed to: Py_hash_t PyHash_Double(double value, PyObject *obj) The interesting case is that obj can be NULL! In that case, the function returns sys.float.nan which comes back from death! Changes:
|
Sorry, something went wrong.
| * If *obj* is not ``NULL``, return the hash of the *obj* pointer. | ||
| * Otherwise, return :data:`sys.hash_info.nan <sys.hash_info>` (``0``). | ||
|
|
||
| The function cannot fail: it cannot return ``-1``. |
There was a problem hiding this comment.
But we do want users to check the result, so that the function can start failing in some cases in the future.
| The function cannot fail: it cannot return ``-1``. | |
| On failure, the function returns ``-1`` and sets an exception. | |
| (``-1`` is not a valid hash value; it is only returned on failure.) |
Sorry, something went wrong.
There was a problem hiding this comment.
I don't see the point of asking developers to make their code slower for a case which cannot happen. It would make C extensions slower for no reason, no?
PyObject_Hash(obj) can call arbitrary __hash__() method in Python and so can fail. But PyHash_Double() is simple and cannot fail. It's just that it has the same API than PyObject_Hash() and PyTypeObject.tp_hash for convenience.
For me, it's the same as PyType_CheckExact(obj): the function cannot fail. Do you want to suggest users to start checking for -1 because the API is that it may set an exception and return -1? IMO practicability beats purity here.
Sorry, something went wrong.
There was a problem hiding this comment.
I am strongly for allowing deprecation via runtime warnings, and for keeping new API consistent in that respect.
If the speed is an issue (which I doubt, with branch prediction around), let's solve that in a way that still allows the API to report errors.
Sorry, something went wrong.
There was a problem hiding this comment.
I am strongly for allowing deprecation via runtime warnings, and for keeping new API consistent in that respect.
I created capi-workgroup/api-evolution#43 to discuss functions which cannot fail: when the caller is not expected to check for errors.
If the speed is an issue (which I doubt, with branch prediction around), let's solve that in a way that still allows the API to report errors.
Would you mind to elaborate how you plan to solve this issue?
My concern is more about usability of the API than performance here.
But yeah, performance matters as well. Such function can be used in a hash table (when floats as used as key), and making such function as fast as possible matters.
Sorry, something went wrong.
There was a problem hiding this comment.
Would you mind to elaborate how you plan to solve this issue?
It's possible for specific compilers: add a static inline wrapper with if (result == -1) __builtin_unreachable(); or __assume(result != -1).
That way the compiler can optimize error checking away, until a later Python version decides to allow failures.
Sorry, something went wrong.
|
Stepping back, I think I figured out why this API seems awkward to me. The actual use case the proposed function allows is implementing a custom object that needs to hash the same as a Python double, where NaN should be hashable (like Python doubles are). That use case is what Python needs, and I assume it's what NumPy needs, but IMO it's unnecessarily limited -- which makes the function well suited for an internal (or unstable) function, but worse for public API. With a signature like: int PyHash_Double(double value, Py_hash_t *result)
// -> 1 for non-NaN (*result is set to the hash)
// -> 0 for NaN (*result is set to 0)the function would cover the more general use case as well. The docs can note that when implementing hash for a custom object, if you get a 0 result you can:
(I'll leave the fallibility argument to the WG repos, just noting that this API would allow adding -1 as a possible result.) |
Sorry, something went wrong.
|
FWIW, I also find the proposed API (Py_hash_t PyHash_Double(double value, PyObject *obj)) awkward. The interesting part of the hash computation, and the part that I presume would be useful to NumPy and others, is the part that converts a non-NaN double to its integer hash. I'd prefer an API that just exposed that part, so that third parties can use it in whatever way they want to. The signature proposed by @encukou looks reasonable to me. I don't want to get involved in the discussion about error returns, but if we really wanted to we could have a documented sentinel hash value that's only ever returned for NaNs (e.g., 2**62). |
Sorry, something went wrong.
There is sys.hash_info.nan which is equal to 0 but it's no longer used as I explained before. The problem with 2**62 is that it doesn't fit into Py_hash_t on 32-bit platforms. The Py_hash_t type is defined as Py_ssize_t which is 32-bit on a platform with 32-bit pointers. |
Sorry, something went wrong.
|
The first PR version used the API: Py_hash_t PyHash_Double(double value), return _PyHASH_NAN if value is NaN. The second PR version changed the API to Py_hash_t PyHash_Double(double value, PyObject *obj) to micro-optimize the code, avoid having to check PyHash_Double() result for NaN, and provide a drop-in replacement for numpy. Well, nobody (including me, to be honest) likes Py_hash_t PyHash_Double(double value, PyObject *obj) API. So I wrote PR #112449 which implements the API proposed by @encukou: int PyHash_Double(double value, Py_hash_t *result), return 0 if value is NaN or return 1 otherwise (if value is finite or is infinity). |
Sorry, something went wrong.
|
I like the simpler API proposed in this PR more, my only concern is about performance. Can the difference be observed in microbenchmarks or it is insignificant? Instead of checking the result of the function, the user code can check the argument before calling the function: if (Py_IS_NAN(value)) {
return _Py_HashPointer(obj);
}
return PyHash_Double(value);As for names, "Hash" is a verb, and "Double" is an object. _Py_HashPointer(), _Py_HashBytes() and _Py_HashDouble() names all say what to do ("to hash") and with what object ("double", "bytes" or "pointer"). PyHash_GetFuncDef() also has a verb ("Get"). If you want to use the PyHash_ prefix, you need to repeat Hash twice: PyHash_HashDouble(), PyHash_HashBytes(), PyHash_HashPointer(). I think it is better to use simple prefix Py_. |
Sorry, something went wrong.
|
I don't see this used in tight loops, so I'd go for the prettier API even if it's a few instructions slower. (Note that NumPy uses it for scalars -- degenerate arrays of size 1 -- to ensure compatibility with Python doubles.) +1 on the naming note, Py_HashDouble (or PyHash_HashDouble) is a bit better. |
Sorry, something went wrong.
Yes, I'm rather familiar with the history here. :-) I was simply suggesting that if we picked and documented a value that's not used for the hash of any non-NaN, then the hash value itself could be a way of detecting NaNs after the fact. 0 isn't viable for that because it's already the hash of 0.0. And yes, of course a different value would be needed for 32-bit builds; the actual value could be stored in sys.hash_info, as before. In any case, I was just mentioning this as a possibility. I'd prefer a more direct method of NaN detection, like what you have currently implemented (or not having the API do NaN detection at all, but leave that to the user to do separately if necessary). |
Sorry, something went wrong.
|
I ran microbenchmarks on 3 different APIs. Measured performance is between 13.6 ns and 14.7 ns. The maximum difference is 1.1 ns: 1.08x slower. It seems like the current _Py_HashDouble() API is the fastest. In the 3 APIs, the C double input value is passed through the xmm0 register at the ABI level. I expected Py_hash_t PyHash_Double(double value) to be the fastest API. It's not the case. I always get bad surprises in my benchmarks. You should try to reproduce them and play with the code to double check that I didn't mess up with my measurement. I wrote 3 benchmarks on:
Results using CPU isolation, Python built with gcc -O3 (without PGO, without LTO, just ./configure):
+-----------+---------+-----------------------+-----------------------+ | Benchmark | A | B | C | +===========+=========+=======================+=======================+ | bench | 13.6 ns | 14.0 ns: 1.03x slower | 14.7 ns: 1.08x slower | +-----------+---------+-----------------------+-----------------------+ I added benchmark code in _testinternalcapi extension which is built as a shared library, so calling _Py_HashDouble() and PyHash_Double() goes go through PLT (procedure linkage table) indirection. I added an artificial test on the hash value to use it in the benchmark, so the compiler doesn't remove the whole function call, and the code is a little bit more realistic. (A) assembly code: Py_hash_t hash = _Py_HashDouble(obj, d);
if (hash == -1) {
...
}
mov rax,QWORD PTR [rip+0x698e] # 0x7ffff7c09690
mov rdi,rbp
movq xmm0,rax
call 0x7ffff7c022b0 <_Py_HashDouble@plt>
cmp rax,0xffffffffffffffff
jne ...
(B) assembly code: Py_hash_t hash;
if (PyHash_Double(d, &hash) == 0) {
return NULL;
}
mov rax,QWORD PTR [rip+0x6a1f] # 0x7ffff7c09690
mov rdi,rbp
movq xmm0,rax
call 0x7ffff7c02990 <PyHash_Double@plt>
test eax,eax
jne ...
(C) assembly code: Py_hash_t hash = PyHash_Double(d);
if (hash == -1) {
...
}
mov rax,QWORD PTR [rip+0x6a26] # 0x7ffff7c09690
movq xmm0,rax
call 0x7ffff7c02990 <PyHash_Double@plt>
cmp rax,0xffffffffffffffff
jne ...
Change used to benchmark (A) and (B). Measuring (C) requires minor changes. diff --git a/Modules/_testinternalcapi.c b/Modules/_testinternalcapi.c
index 4607a3faf17..9b671acf916 100644
--- a/Modules/_testinternalcapi.c
+++ b/Modules/_testinternalcapi.c
@@ -1625,6 +1625,51 @@ get_type_module_name(PyObject *self, PyObject *type)
}
+static PyObject *
+test_bench_private_hash_double(PyObject *Py_UNUSED(module), PyObject *args)
+{
+ Py_ssize_t loops;
+ if (!PyArg_ParseTuple(args, "n", &loops)) {
+ return NULL;
+ }
+ PyObject *obj = Py_None;
+ double d = 1.0;
+
+ _PyTime_t t1 = _PyTime_GetPerfCounter();
+ for (Py_ssize_t i=0; i < loops; i++) {
+ Py_hash_t hash = _Py_HashDouble(obj, d);
+ if (hash == -1) {
+ return NULL;
+ }
+ }
+ _PyTime_t t2 = _PyTime_GetPerfCounter();
+
+ return PyFloat_FromDouble(_PyTime_AsSecondsDouble(t2 - t1));
+}
+
+
+static PyObject *
+test_bench_public_hash_double(PyObject *Py_UNUSED(module), PyObject *args)
+{
+ Py_ssize_t loops;
+ if (!PyArg_ParseTuple(args, "n", &loops)) {
+ return NULL;
+ }
+ double d = 1.0;
+
+ _PyTime_t t1 = _PyTime_GetPerfCounter();
+ for (Py_ssize_t i=0; i < loops; i++) {
+ Py_hash_t hash;
+ if (PyHash_Double(d, &hash) == 0) {
+ return NULL;
+ }
+ }
+ _PyTime_t t2 = _PyTime_GetPerfCounter();
+
+ return PyFloat_FromDouble(_PyTime_AsSecondsDouble(t2 - t1));
+}
+
+
static PyMethodDef module_functions[] = {
{"get_configs", get_configs, METH_NOARGS},
{"get_recursion_depth", get_recursion_depth, METH_NOARGS},
@@ -1688,6 +1733,8 @@ static PyMethodDef module_functions[] = {
{"restore_crossinterp_data", restore_crossinterp_data, METH_VARARGS},
_TESTINTERNALCAPI_TEST_LONG_NUMBITS_METHODDEF
{"get_type_module_name", get_type_module_name, METH_O},
+ {"bench_private_hash_double", test_bench_private_hash_double, METH_VARARGS},
+ {"bench_public_hash_double", test_bench_public_hash_double, METH_VARARGS},
{NULL, NULL} /* sentinel */
};
Bench script for (A), private API: import pyperf
import _testinternalcapi
runner = pyperf.Runner()
runner.bench_time_func('bench', _testinternalcapi.bench_private_hash_double)Bench script for (B) and (C), public API: import pyperf
import _testinternalcapi
runner = pyperf.Runner()
runner.bench_time_func('bench', _testinternalcapi.bench_public_hash_double) |
Sorry, something went wrong.
|
Other benchmark results using PGO:
These numbers are surprising. |
Sorry, something went wrong.
I wrote PR #112476 to run this benchmark differently. Results look better (less surprising, more reliable than my previous benchmark). Results with CPU isolation, gcc -O3 and PGO, but without LTO:
API (A) and (C) have the same performance. API (B) is 0.9 ns slower than (A) and (C): it is 1.07x slower than (A) and (C). I ran the benchmark with v = 1.0. |
Sorry, something went wrong.
If we only care about performance, an alternative is API (D): Py_hash_t hash_api_D(double v, int *is_nan) where is_nan can be NULL.
Note: Passing non-NULL is_nan or passing NULL is_nan has no significant impact on API (D) performance. It may be interesting if you know that the number cannot be NaN. |
Sorry, something went wrong.
It would be interesting to design the C API namespace in a similar way than Python packages and Python package sub-modules: import pyhash; h = pyhash.hash_double(1.0) would become h = PyHash_HashDouble(1.0) in C. So yeah, repeat "Hash" here. PyHash is the namespace, HashDouble() is the function of the namespace. But Python C API is far from respecting such design :-) The "Py_" namespace is a giant bag full of "anything". |
Sorry, something went wrong.
|
By the way, see also PR #112096 which adds PyHash_Pointer() function. |
Sorry, something went wrong.
|
How much it makes difference if remove the check for NaN from the function, but add it before calling the function, like in #112095 (comment) ? |
Sorry, something went wrong.
|
As far as I know, microbenchmarks at this level are susceptible to “random” variations due to code layout. An individual function should be benchmarked in a variety of calling situations to get a meaningful result. (But to reiterate: I don't think this is the place for micro-optimizations.) |
Sorry, something went wrong.
It would be bad to return the same hash value for +inf, -inf and NaN values. Current code: if (!Py_IS_FINITE(v)) {
if (Py_IS_INFINITY(v))
return v > 0 ? _PyHASH_INF : -_PyHASH_INF;
else
return _Py_HashPointer(inst); // v is NaN
} |
Sorry, something went wrong.
We can take it in account in the API design. Now we know that the API int hash_api_B(double v, Py_hash_t *result) is around 1.07x slower than other discussed API, which means less than a nanosecond (0.9 ns) in absolute timing. I don't think that 0.9 ns is a significant difference. If a workflow is impacted by 0.9 ns per function call, maybe they should copy PyHash_Double() code and design a very specialized flavor for their workflow (ex: remove code for infinity and NaN, and inline all code). In terms of performance, I think that any proposed API is fine. |
Sorry, something went wrong.
|
@serhiy-storchaka @encukou: Do you prefer PyHash_Double(), PyHash_HashDouble() or Py_HashDouble() name?
I think that now that I read previous discussions, I prefer Py_HashDouble() name. It fits better in the current C API naming scheme. |
Sorry, something went wrong.
|
Yeah, Py_HashDouble sounds best to me. |
Sorry, something went wrong.
First, I proposed Py_hash_t PyHash_Double(double v) API in this PR. Then I modified it to use the int PyHash_Double(double v, Py_hash_t *result) API. I'm not fully comfortable with this API neither. I close this PR. Let's continue the discussion in PR #112449 which implements the API proposed by @encukou. |
Sorry, something went wrong.
| Back | FazBrowse Home | New Git URL |
Cleanup PyHash_Double() implementation based _Py_HashDouble():
Add tests: Modules/_testcapi/hash.c and Lib/test/test_capi/test_hash.py.
📚 Documentation preview 📚: https://cpython-previews--112095.org.readthedocs.build/