PEP 757 – 导入-导出 Python 整数的 C API
- 作者:
- Sergey B Kirpichev <skirpichev at gmail.com>, Victor Stinner <vstinner at python.org>
- 讨论至:
- Discourse 帖子
- 状态:
- 最终版
- 类型:
- 标准跟踪
- 创建日期:
- 2024年9月13日
- Python 版本:
- 3.14
- 发布历史:
- 2024年9月14日
- 决议:
- 2024年12月8日
摘要
添加一个新的 C API,用于导入和导出 Python 整数,即 int 对象:特别是 PyLongWriter_Create() 和 PyLong_Export() 函数。
基本原理
gmpy2、SAGE 和 Python-FLINT 等项目直接访问 Python“内部”(PyLongObject 结构)或使用低效的临时格式(Python-FLINT 的十六进制字符串)来导入和导出 Python int 对象。Python int 的实现在 Python 3.12 中发生了变化,增加了标签和“紧凑值”。
在 3.13 alpha 1 版本中,私有的未公开的 _PyLong_New() 函数已被移除,但这些项目正在使用它来导入 Python 整数。该私有函数已在 3.13 alpha 2 中恢复。
需要一个公共且高效的抽象层,以便 Python 与这些项目交互,而无需暴露实现细节。这将允许 Python 在不破坏这些项目的情况下更改其内部结构。例如,gmpy2 的实现在 CPython 3.9 和 CPython 3.12 中最近进行了更改。
规范
布局 API
-
struct PyLongLayout
- “数字”(GMP 术语中的“limb”)数组的布局,用于表示任意精度整数的绝对值。
使用
PyLong_GetNativeLayout()获取 Pythonint对象的原生布局,内部用于具有“足够大”绝对值的整数。另请参阅
sys.int_info,它向 Python 暴露类似的信息。-
uint8_t bits_per_digit
- 每个数字的位数。例如,一个 15 位数字意味着位 0-14 包含有意义的信息。
-
uint8_t digit_size
- 数字的字节大小。例如,一个 15 位数字至少需要 2 个字节。
-
int8_t digits_order
- 数字顺序
- 最高有效数字优先为
1 - 最低有效数字优先为
-1
- 最高有效数字优先为
-
int8_t digit_endianness
- 数字字节序
- 最高有效字节优先(大端)为
1 - 最低有效字节优先(小端)为
-1
- 最高有效字节优先(大端)为
-
uint8_t bits_per_digit
-
const PyLongLayout *PyLong_GetNativeLayout(void)
- 获取 Python
int对象的原生布局。请参阅
PyLongLayout结构。此函数不得在 Python 初始化之前或 Python 终结之后调用。返回的布局在 Python 终结之前有效。所有 Python 子解释器都使用相同的布局,因此可以缓存。
导出 API
-
struct PyLongExport
- Python
int对象的导出。有两种情况
-
int64_t value
- 导出
int对象的原生整数值。仅当digits为NULL时有效。
-
uint8_t negative
- 如果数字为负,则为 1,否则为 0。仅当
digits不为NULL时有效。
-
Py_ssize_t ndigits
digits数组中的数字数量。仅当digits不为NULL时有效。
-
const void *digits
- 无符号数字的只读数组。可以是
NULL。
-
int64_t value
如果 PyLongExport.digits 不为 NULL,则 PyLongExport 结构的私有字段会存储对 Python int 对象的强引用,以确保该结构在调用 PyLong_FreeExport() 之前保持有效。
-
int PyLong_Export(PyObject *obj, PyLongExport *export_long)
- 导出 Python
int对象。export_long 必须指向调用者分配的
PyLongExport结构。它不能为NULL。成功时,填充 *export_long 并返回 0。错误时,设置异常并返回 -1。
当不再需要导出时,必须调用
PyLong_FreeExport()。CPython 实现细节:如果 obj 是 Python
int对象或其子类,此函数始终成功。
在 CPython 3.14 中,PyLong_Export() 中不需要内存复制,它只是一个暴露 Python int 内部数字数组的简单包装器。
-
void PyLong_FreeExport(PyLongExport *export_long)
- 释放由
PyLong_Export()创建的导出 export_long。CPython 实现细节:如果 export_long->digits 为
NULL,则调用PyLong_FreeExport()是可选的。
导入 API
PyLongWriter API 可用于导入整数。
-
struct PyLongWriter
- 一个 Python
int写入器实例。该实例必须通过
PyLongWriter_Finish()或PyLongWriter_Discard()销毁。
-
PyLongWriter *PyLongWriter_Create(int negative, Py_ssize_t ndigits, void **digits)
- 创建一个
PyLongWriter。成功时,分配 *digits 并返回一个写入器。错误时,设置异常并返回
NULL。如果数字为负,则 negative 为
1,否则为0。ndigits 是 digits 数组中的数字数量。它必须大于 0。
digits 不能为 NULL。
成功调用此函数后,调用者应填充数字数组 digits,然后调用
PyLongWriter_Finish()以获取 Pythonint。digits 的布局由PyLong_GetNativeLayout()描述。数字必须在 [
0;(1 << bits_per_digit) - 1] 范围内(其中bits_per_digit是每个数字的位数)。任何未使用的最高有效数字必须设置为0。或者,调用
PyLongWriter_Discard()以销毁写入器实例,而不创建int对象。
在 CPython 3.14 中,PyLongWriter_Create() 的实现是私有函数 _PyLong_New() 的简单包装器。
-
PyObject *PyLongWriter_Finish(PyLongWriter *writer)
- 完成由
PyLongWriter_Create()创建的PyLongWriter。成功时,返回一个 Python
int对象。错误时,设置异常并返回NULL。该函数负责规范化数字,并在需要时将对象转换为紧凑整数。
调用后,写入器实例和 digits 数组将失效。
-
void PyLongWriter_Discard(PyLongWriter *writer)
- 丢弃由
PyLongWriter_Create()创建的PyLongWriter。writer 不能为
NULL。调用后,写入器实例和 digits 数组将失效。
优化小整数导入
提议的导入 API 对大整数高效。与直接访问 Python 内部结构相比,提议的导入 API 在小整数上可能会有显著的性能开销。
对于几个数字的小整数(例如,1 或 2 个数字),可以使用现有 API
实施
基准测试
代码
/* Query parameters of Python’s internal representation of integers. */
const PyLongLayout *layout = PyLong_GetNativeLayout();
size_t int_digit_size = layout->digit_size;
int int_digits_order = layout->digits_order;
size_t int_bits_per_digit = layout->bits_per_digit;
size_t int_nails = int_digit_size*8 - int_bits_per_digit;
int int_endianness = layout->digit_endianness;
导出:PyLong_Export() 与 gmpy2
代码
static int
mpz_set_PyLong(mpz_t z, PyObject *obj)
{
static PyLongExport long_export;
if (PyLong_Export(obj, &long_export) < 0) {
return -1;
}
if (long_export.digits) {
mpz_import(z, long_export.ndigits, int_digits_order, int_digit_size,
int_endianness, int_nails, long_export.digits);
if (long_export.negative) {
mpz_neg(z, z);
}
PyLong_FreeExport(&long_export);
}
else {
const int64_t value = long_export.value;
if (LONG_MIN <= value && value <= LONG_MAX) {
mpz_set_si(z, value);
}
else {
mpz_import(z, 1, -1, sizeof(int64_t), 0, 0, &value);
if (value < 0) {
mpz_t tmp;
mpz_init(tmp);
mpz_ui_pow_ui(tmp, 2, 64);
mpz_sub(z, z, tmp);
mpz_clear(tmp);
}
}
}
return 0;
}
参考代码:gmpy2 master 中 commit 9177648 的 mpz_set_PyLong()。
基准测试
import pyperf
from gmpy2 import mpz
runner = pyperf.Runner()
runner.bench_func('1<<7', mpz, 1 << 7)
runner.bench_func('1<<38', mpz, 1 << 38)
runner.bench_func('1<<300', mpz, 1 << 300)
runner.bench_func('1<<3000', mpz, 1 << 3000)
在 Linux Fedora 40 上,CPU 隔离,Python 在发布模式下构建的结果
| 基准测试 | 参考 | pep757 |
|---|---|---|
| 1<<7 | 91.3 纳秒 | 89.9 纳秒:快 1.02 倍 |
| 1<<38 | 120 纳秒 | 94.9 纳秒:快 1.27 倍 |
| 1<<300 | 196 纳秒 | 203 纳秒:慢 1.04 倍 |
| 1<<3000 | 939 纳秒 | 945 纳秒:慢 1.01 倍 |
| 几何平均值 | (参考) | 快 1.05 倍 |
导入:PyLongWriter_Create() 与 gmpy2
代码
static PyObject *
GMPy_PyLong_From_MPZ(MPZ_Object *obj, CTXT_Object *context)
{
if (mpz_fits_slong_p(obj->z)) {
return PyLong_FromLong(mpz_get_si(obj->z));
}
size_t size = (mpz_sizeinbase(obj->z, 2) +
int_bits_per_digit - 1) / int_bits_per_digit;
void *digits;
PyLongWriter *writer = PyLongWriter_Create(mpz_sgn(obj->z) < 0, size,
&digits);
if (writer == NULL) {
return NULL;
}
mpz_export(digits, NULL, int_digits_order, int_digit_size,
int_endianness, int_nails, obj->z);
return PyLongWriter_Finish(writer);
}
参考代码:gmpy2 master 中 commit 9177648 的 GMPy_PyLong_From_MPZ()。
基准测试
import pyperf
from gmpy2 import mpz
runner = pyperf.Runner()
runner.bench_func('1<<7', int, mpz(1 << 7))
runner.bench_func('1<<38', int, mpz(1 << 38))
runner.bench_func('1<<300', int, mpz(1 << 300))
runner.bench_func('1<<3000', int, mpz(1 << 3000))
在 Linux Fedora 40 上,CPU 隔离,Python 在发布模式下构建的结果
| 基准测试 | 参考 | pep757 |
|---|---|---|
| 1<<7 | 56.7 纳秒 | 56.2 纳秒:快 1.01 倍 |
| 1<<300 | 191 纳秒 | 213 纳秒:慢 1.12 倍 |
| 几何平均值 | (参考) | 慢 1.03 倍 |
基准测试被隐藏,因为不显著(2):1<<38, 1<<3000。
向后兼容性
对向后兼容性没有影响,只添加了新的 API。
被拒绝的想法
支持任意布局
支持任意布局以导入-导出 Python 整数将很方便。
例如,曾提议向 PyLongWriter_Create() 添加一个 layout 参数,并向 PyLongExport 结构添加一个 layout 成员。
问题在于,它的实现更复杂,而且并非真正需要。严格来说,只需要一个 API 来使用 Python“原生”布局进行导入-导出。
如果以后有任意布局的用例,可以添加新的 API。
不添加PyLong_GetNativeLayout() 函数
目前,大多数 int 导入/导出所需的信息已通过 PyLong_GetInfo()(和 sys.int_info)提供。我们还可以添加更多信息(例如数字的顺序),此接口不会对 PyLongObject 的未来演变施加任何限制。
问题在于 PyLong_GetInfo() 返回一个 Python 对象,即命名元组,而不是一个方便的 C 结构,这可能会让人们倾向于使用当前的半私有宏,例如 PyLong_SHIFT 和 PyLong_BASE。
提供类似 mpz_import/export 的 API
另一种从 int 对象导入/导出数据的方法是:期望 C 扩展提供连续缓冲区,然后 CPython 导出(或导入)整数的绝对值。
API 示例
struct PyLongLayout {
uint8_t bits_per_digit;
uint8_t digit_size;
int8_t digits_order;
};
size_t PyLong_GetDigitsNeeded(PyLongObject *obj, PyLongLayout layout);
int PyLong_Export(PyLongObject *obj, PyLongLayout layout, void *buffer);
PyLongObject *PyLong_Import(PyLongLayout layout, void *buffer);
这可能适用于 GMP,因为它有 mpz_limbs_read() 和 mpz_limbs_write() 函数,可以提供对 mpz_t 内部的所需访问。其他库可能需要使用临时缓冲区,然后在它们那边使用类似 mpz_import/export 的函数。
这种方法的主要缺点是它在 CPython 端要复杂得多(即不同布局之间的实际转换)。例如,CPython 中 PyLong_FromNativeBytes() 和 PyLong_AsNativeBytes() 的实现(共同提供了所需 API 的受限版本)大约需要 500 行代码(而当前实现大约需要 100 行)。
从导出 API 中删除value 字段
通过此建议,将只存在一种导出类型(“数字”数组)。如果给定整数没有这样的视图,它将由导出函数模拟,或者 PyLong_Export() 将返回错误。在两种情况下,都假设用户将使用其他 C-API 函数来获取“足够小”的整数(即,适合某些机器整数类型),例如 PyLong_AsLongAndOverflow()。在这种情况下,PyLong_Export() 将是低效的(或者只是失败)。
一个例子
static int
mpz_set_PyLong(mpz_t z, PyObject *obj)
{
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));
if (!overflow) {
if (LONG_MIN <= value && value <= LONG_MAX) {
mpz_set_si(z, (long)value);
}
else {
mpz_import(z, 1, -1, sizeof(int64_t), 0, 0, &value);
if (value < 0) {
mpz_t tmp;
mpz_init(tmp);
mpz_ui_pow_ui(tmp, 2, 64);
mpz_sub(z, z, tmp);
mpz_clear(tmp);
}
}
}
else {
static PyLongExport long_export;
if (PyLong_Export(obj, &long_export) < 0) {
return -1;
}
mpz_import(z, long_export.ndigits, int_digits_order, int_digit_size,
int_endianness, int_nails, long_export.digits);
if (long_export.negative) {
mpz_neg(z, z);
}
PyLong_FreeExport(&long_export);
}
return 0;
}
这从 API 设计者的角度来看可能是一种简化,但对最终用户来说会不太方便。他们将不得不跟踪 Python 的发展,对导出小整数的不同变体进行基准测试(为什么选择上述情况而不是 PyLong_AsInt64()?),可能还需要支持各种 CPython 版本或不同 Python 实现的不同代码路径。
讨论
- 讨论:PEP 757 – 导入-导出 Python 整数的 C API
- C API 工作组决策问题 #35
- 拉取请求 #121339
- 问题 #102471:Python 到 C 整数转换的 C-API,坦率地说,一团糟。
- 添加公共函数 PyLong_GetDigits()
- 考虑将 _PyLong_New() 函数恢复为公共函数
- 拉取请求 gh-106320:删除私有 _PyLong_New() 函数。
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0757.rst