Skip to content

gh-102471, PEP 757: Add PyLong import and export API - #121339

Merged
vstinner merged 54 commits into
python:mainfrom
vstinner:long_export
Dec 13, 2024
Merged

gh-102471, PEP 757: Add PyLong import and export API#121339
vstinner merged 54 commits into
python:mainfrom
vstinner:long_export

Conversation

@vstinner

@vstinnervstinner commented Jul 3, 2024

Copy link
Copy Markdown
Member

Add PyLong_Export() and PyLong_Import() functions and PyLong_LAYOUT structure.


📚 Documentation preview 📚: https://cpython-previews--121339.org.readthedocs.build/

@vstinner

vstinner commented Jul 3, 2024

Copy link
Copy Markdown
MemberAuthor

cc @skirpichev@casevh

Comment threadInclude/cpython/longintrepr.h Outdated
@vstinner

Copy link
Copy Markdown
MemberAuthor

See also issue #111415

@skirpichevskirpichev left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just my 2c.

The gmpy2 code, used for benchmarking, can be found in my fork:
https://github.com/skirpichev/gmpy/tree/trying-py-import-export

Comment threadDoc/c-api/long.rst Outdated
Comment threadDoc/c-api/long.rst Outdated
Comment threadDoc/c-api/long.rst Outdated
Comment threadObjects/longobject.c Outdated
Comment on lines +6705 to +6763
PyUnstable_Long_Export(PyObject *obj, PyUnstable_LongExport *long_export)
{
if (!PyLong_Check(obj)) {
PyErr_Format(PyExc_TypeError, "expect int, got %T", obj);
return -1;
}
PyLongObject *self = (PyLongObject*)obj;

long_export->obj = (PyLongObject*)Py_NewRef(obj);
long_export->negative = _PyLong_IsNegative(self);
long_export->ndigits = _PyLong_DigitCount(self);
if (long_export->ndigits == 0) {
long_export->ndigits = 1;
}
long_export->digits = self->long_value.ob_digit;
return 0;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this mostly give a direct access to the PyLongObject - it almost as fast as using private stuff before.

Old code:

$ python -m timeit -r20 -s 'from gmpy2 import mpz;x=10**2' 'mpz(x)'
1000000 loops, best of 20: 232 nsec per loop
$ python -m timeit -r11 -s 'from gmpy2 import mpz;x=10**100' 'mpz(x)'
500000 loops, best of 11: 500 nsec per loop
$ python -m timeit -r20 -s 'from gmpy2 import mpz;x=10**1000' 'mpz(x)'
100000 loops, best of 20: 2.53 usec per loop

With proposed API:

$ python -m timeit -r20 -s 'from gmpy2 import mpz;x=10**2' 'mpz(x)'
1000000 loops, best of 20: 258 nsec per loop
$ python -m timeit -r20 -s 'from gmpy2 import mpz;x=10**100' 'mpz(x)'
500000 loops, best of 20: 528 nsec per loop
$ python -m timeit -r20 -s 'from gmpy2 import mpz;x=10**1000' 'mpz(x)'
100000 loops, best of 20: 2.56 usec per loop

Comment threadObjects/longobject.c Outdated
Comment on lines +6697 to +6742
PyObject*
PyUnstable_Long_Import(int negative, size_t ndigits, Py_digit *digits)
{
return (PyObject*)_PyLong_FromDigits(negative, ndigits, digits);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But this is something I would like to avoid. This requires allocation of a temporary buffer and using memcpy. Can we offer a writable layout to use it's digits in the mpz_export directly?

Benchmarks for old code:

$ python -m timeit -r11 -s 'from gmpy2 import mpz;x=mpz(10**2)' 'int(x)'
2000000 loops, best of 11: 111 nsec per loop
$ python -m timeit -r11 -s 'from gmpy2 import mpz;x=mpz(10**100)' 'int(x)'
500000 loops, best of 11: 475 nsec per loop
$ python -m timeit -r11 -s 'from gmpy2 import mpz;x=mpz(10**1000)' 'int(x)'
100000 loops, best of 11: 2.39 usec per loop

With new API:

$ python -m timeit -r20 -s 'from gmpy2 import mpz;x=mpz(10**2)' 'int(x)'
2000000 loops, best of 20: 111 nsec per loop
$ python -m timeit -r20 -s 'from gmpy2 import mpz;x=mpz(10**100)' 'int(x)'
500000 loops, best of 20: 578 nsec per loop
$ python -m timeit -r20 -s 'from gmpy2 import mpz;x=mpz(10**1000)' 'int(x)'
100000 loops, best of 20: 2.53 usec per loop

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This requires allocation of a temporary buffer and using memcpy.

Right, PyLongObject has to manage its own memory.

Can we offer a writable layout to use it's digits in the mpz_export directly?

That sounds strange from the Python point of view and make the internals "less opaque". I would prefer to leak less implementation details.

@skirpichevskirpichevJul 4, 2024

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, PyLongObject has to manage its own memory.

I'm not trying to change that. More complete proposal: vstinner#4

gmpy2 patch: https://github.com/skirpichev/gmpy/tree/trying-py-import-export-v2

New benchmarks:

$ python -m timeit -r20 -s 'from gmpy2 import mpz;x=mpz(10**2)' 'int(x)'
2000000 loops, best of 20: 111 nsec per loop
$ python -m timeit -r20 -s 'from gmpy2 import mpz;x=mpz(10**100)' 'int(x)'
500000 loops, best of 20: 509 nsec per loop
$ python -m timeit -r20 -s 'from gmpy2 import mpz;x=mpz(10**1000)' 'int(x)'
100000 loops, best of 20: 2.44 usec per loop

I would prefer to leak less implementation details.

I don't think this leak anything. It doesn't leak memory management details. PyLong_Import will just do allocation memory. Writting digits will be job for mpz_export, as before.

Without this, it seems - there are noticeable performance regression for integers of intermediate range. Something up to 20% vs 7% on my branch.

Edit: currently, proposed PyUnstable_Long_ReleaseImport() match PyUnstable_Long_ReleaseExport(). Perhaps, it could be one function (say, PyUnstable_Long_ReleaseDigitArray()), but I unsure - maybe it puts some constraints on internals of the PyLongObject.

Comment threadObjects/longobject.c Outdated
Comment threadDoc/c-api/long.rst
Comment threadDoc/c-api/long.rst Outdated
@skirpichev

Copy link
Copy Markdown
Member

CC @tornaria, as Sage people might be interested in this feature.

@skirpichev

Copy link
Copy Markdown
Member

CC @oscarbenjamin, you may want this for python-flint

@vstinner

Copy link
Copy Markdown
MemberAuthor

I updated my PR:

  • Use -1 for little endian and +1 for big endian.
  • Rename PyUnstable_LongExport to PyUnstable_Long_DigitArray.
  • Add "always succeed" mention in the doc.

Comment threadLib/test/test_capi/test_long.py Outdated
Comment threadLib/test/test_capi/test_long.py
Comment threadLib/test/test_capi/test_long.py Outdated
Comment threadMisc/NEWS.d/next/C API/2024-07-03-17-26-53.gh-issue-102471.XpmKYk.rst Outdated
Comment threadDoc/whatsnew/3.14.rst Outdated
@oscarbenjamin

Copy link
Copy Markdown
Contributor

CC @oscarbenjamin, you may want this for python-flint

Absolutely. Currently python-flint uses a hex-string as an intermediate format when converting between large int and fmpz so anything more direct is an improvement. I expect that python-flint would use mpz_import/mpz_export just like gmpy2.

Comment threadObjects/longobject.c Outdated
@vstinner

Copy link
Copy Markdown
MemberAuthor

@skirpichev: I added a PyLongWriter API similar to what @encukou proposed.

Example:

PyLongObject*_PyLong_FromDigits(intnegative, Py_ssize_tdigit_count, digit*digits)
{
PyLongWriter*writer=PyLongWriter_Create();
if (writer==NULL) {
returnNULL;
}
if (negative) {
PyLongWriter_SetSign(writer, -1);
}
Py_digit*writer_digits=PyLongWriter_AllocDigits(writer, digit_count);
if (writer_digits==NULL) {
goto error;
}
memcpy(writer_digits, digits, digit_count*sizeof(digit));
return (PyLongObject*)PyLongWriter_Finish(writer);
error:
PyLongWriter_Discard(writer);
returnNULL;
}

The PyLongWriter_Finish() function normalizes the number and gets a small number if needed. Example:

>>>import_testcapi; _testcapi.pylong_import(0, [100, 0, 0]) is100True

@vstinner
vstinner marked this pull request as draft July 4, 2024 13:00
@vstinner

Copy link
Copy Markdown
MemberAuthor

I mark the PR as a draft until we agree on the API.

@skirpichev

Copy link
Copy Markdown
Member

I added a PyLongWriter API similar to what @encukou proposed.

Yes, that looks better and should fix speed regression. I'll try to benchmark that, perhaps tomorrow.

But cost is 5 (!) public functions and one new struct, additionally to PyUnstable_Long_Import(), which will be a more slow API. Correct? C.f. just one function in above proposal.

@vstinner

Copy link
Copy Markdown
MemberAuthor

I updated the PR to remove the PyUnstable_ prefix, replace it with the PyLong prefix.

@vstinner

Copy link
Copy Markdown
MemberAuthor

But cost is 5 (!) public functions and one new struct, additionally to PyUnstable_Long_Import(), which will be a more slow API. Correct? C.f. just one function in #121339 (comment) proposal.

My concern is to avoid the problem capi-workgroup/api-evolution#36 : avoid exposing _PyLong_New() object until it's fully initialized. The "writer" API hides the implementation details but also makes sure that the object is not "leaked" to Python before it's fully initialized and valid. By the way, the implementation uses functions which are only safe if the object cannot be seen in Python: if Py_REFCNT(obj) is 1.

@vstinner

Copy link
Copy Markdown
MemberAuthor

@skirpichev: Would it be useful to add a PyLongWriter_SetValue(PyLongWriter *writer, long value) function? It would be similar to PyLong_FromLong(long value) (but may be less efficient), so I'm not sure if it's relevant.

Comment threadDoc/c-api/long.rst
Comment threadDoc/c-api/long.rst
Comment on lines +234 to +236
digit *digits = PyMem_Malloc((size_t)ndigits * sizeof(digit));
if (digits == NULL) {
return PyErr_NoMemory();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe bug is somewhere else? Newly added functions usually are explicitly say if they set an exception on error.

Comment threadObjects/longobject.c
Comment threadObjects/longobject.c
Comment on lines +6805 to +6812
int overflow;
#if SIZEOF_LONG == 8
long value = PyLong_AsLongAndOverflow(obj, &overflow);
#else
// Windows has 32-bit long, so use 64-bit long long instead
long long value = PyLong_AsLongLongAndOverflow(obj, &overflow);
#endif
Py_BUILD_ASSERT(sizeof(value) == sizeof(int64_t));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another reason for using this API was "no-error" contract. Maybe we can specify that in docs as a CPython implementation detail? IIUC, we are free to change such things in new releases without deprecation period.

Note also, that gmpy2 benchmarks measure not just CPython side, but the whole conversion path (int->gmpy2.mpz in this case).

Comment threadDoc/c-api/long.rst

If *export_long->digits* is not ``NULL``, :c:func:`PyLong_FreeExport` must
be called when the export is no longer needed.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.. impl-detail::
This function always succeeds if *obj* is a Python :class:`int` object
or a subclass.

Lets see if we can restore this in a that way. It might be helpful for e.g. Sage, which doesn't support PyPy.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer to not add this note. It was controversial during PEP 757 design.

@serhiy-storchaka@encukou: What do you think? Would you be ok to declare that the PyLong_Export() function cannot fail if the argument is a Python int?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was controversial during PEP 757 design.

It was proposed unconditionally, not as CPython's implementation detail.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fine with it, as an implementation detail.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vstinner

Copy link
Copy Markdown
MemberAuthor

@picnixz: I addressed your review. Please review the updated PR.

@picnixzpicnixz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final nits and LGTM. Since I'm on mobile I don't know whether the spaces are correct or not so please check it manually.

Comment threadObjects/longobject.c Outdated
Comment threadObjects/longobject.c Outdated
Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com>
@vstinner

Copy link
Copy Markdown
MemberAuthor

Thanks for all reviews. I think that the change is now ready to be merged, I addressed all comments. I plan to merge the PR Friday.

@skirpichev

Copy link
Copy Markdown
Member

I plan to merge the PR Friday.

I would appreciate this, but... Isn't now an approval from other core developer is required for this type of pr's?

@serhiy-storchakaserhiy-storchaka left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update also Doc/data/refcounts.dat.

Comment threadObjects/longobject.c
Comment on lines +6805 to +6812
int overflow;
#if SIZEOF_LONG == 8
long value = PyLong_AsLongAndOverflow(obj, &overflow);
#else
// Windows has 32-bit long, so use 64-bit long long instead
long long value = PyLong_AsLongLongAndOverflow(obj, &overflow);
#endif
Py_BUILD_ASSERT(sizeof(value) == sizeof(int64_t));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is so close to the PyLong implementation, that I think we should use _PyLong_IsCompact() + _PyLong_CompactValue() to be sure that it matches the specification.

Comment threadObjects/longobject.c Outdated
Comment threadObjects/longobject.c
Comment on lines +6828 to +6830
if (export_long->ndigits == 0) {
export_long->ndigits = 1;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's to make the API easier to use: the consumer doesn't have to both with ndigits==0 special case.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In fact it's assumed (see e.g. _PyLong_New), that the digit array always has at least one digit (and it's initialized for 0 too). And also, if you pass ndigits==0 to the mpz_import() as count parameter, then it just calls malloc(0).

But I think it's safe just drop this check. This condition is unreachible here, as 0 handled in the !overflow case.

Suggested change
if (export_long->ndigits==0) {
export_long->ndigits=1;
}

Comment threadObjects/longobject.c
PyLongWriter*
PyLongWriter_Create(int negative, Py_ssize_t ndigits, void **digits)
{
if (ndigits <= 0) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not allow 0?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, he asked to document that case (i.e. when ndigits==0).

We don't think it's a good idea to overbloat docs with this edge case (different functions should be used for small integers). PEP has a dedicated section to discuss import for small integers, suggesting different functions. (In fact, the whole API is about import/export for big integers.)

Comment threadModules/_testcapi/long.c
Comment threadModules/_testcapi/long.c
Comment threadModules/_testcapi/long.c Outdated
Comment threadModules/_testcapi/long.c
Comment threadModules/_testcapi/long.c
Comment threadLib/test/test_capi/test_long.py Outdated

@encukouencukou left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, though I have a few suggestions.

Also, please test that PyLong_FreeExportcan be used after PyLong_Export fills in the compact value. AFAICS, the tests now skip it if they can.

Comment threadDoc/c-api/long.rst Outdated
Comment threadDoc/c-api/long.rst

If *export_long->digits* is not ``NULL``, :c:func:`PyLong_FreeExport` must
be called when the export is no longer needed.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fine with it, as an implementation detail.

Comment threadDoc/c-api/long.rst Outdated
Comment threadDoc/c-api/long.rst Outdated
Comment threadObjects/longobject.c
Comment threadDoc/c-api/long.rst Outdated
Comment threadDoc/c-api/long.rst Outdated
Comment threadDoc/c-api/long.rst Outdated
Comment threadDoc/conf.py Outdated
@vstinner

Copy link
Copy Markdown
MemberAuthor

@serhiy-storchaka and @encukou: I addressed your reviews. Please review the updated PR.

@serhiy-storchaka:

Update also Doc/data/refcounts.dat.

I added PyLong_Export() and PyLongWriter_Finish() to Doc/data/refcounts.dat, the only function which takes a PyObject*.

@encukou:

Also, please test that PyLong_FreeExport can be used after PyLong_Export fills in the compact value. AFAICS, the tests now skip it if they can.

Done.

@serhiy-storchakaserhiy-storchaka left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

Please do not forget to edit the commit message before merging.

@vstinner

Copy link
Copy Markdown
MemberAuthor

Ok, I merged the PR. Big thanks to everyone who reviewed this PR which got 272 comments and 54 commits! Special thanks to @skirpichev who wrote a big part of this work.

@skirpichev

Copy link
Copy Markdown
Member

IIUIC, Now anonymous unions are allowed.

Maybe we could use them in the export API in this way:

typedefstructPyLongExport2 {
union {
int64_tcompact_value;
struct {
uint8_tnegative;
Py_ssize_tndigits;
constvoid*digits;
Py_uintptr_t_reserved;
} digit_array;
};
} PyLongExport2;

?

This structure has smaller size. Also, such API make a more clear distinction for alternative views.

@vstinner ?

@vstinner

Copy link
Copy Markdown
MemberAuthor

Also, such API make a more clear distinction for alternative views.

How do you know if compact_value or digit_array must be used?

@skirpichev

Copy link
Copy Markdown
Member

How do you know if compact_value or digit_array must be used?

By PyLong_Export's return value. Errors < 0. Nonnegative values - for possible export types.

@vstinner

Copy link
Copy Markdown
MemberAuthor

IIUIC, Now anonymous unions capi-workgroup/decisions#30 (comment). Maybe we could use them in the export API in this way: (...)

PEP 757 had to go through the C API Working Group and then the Steering Council. I don't think that this change is worth it to restart this validation process.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

11 participants

@vstinner@skirpichev@oscarbenjamin@zooba@encukou@picnixz@ericsnowcurrently@steve-s@pitrou@serhiy-storchaka@erlend-aasland