20 KiB
高精度类型原地运算优化方案
1. 背景
TypePHP 当前将 BigInt、BigFloat 和 Decimal 实现为存放在 Zend resource 中的 PHPX Box。高精度运算采用不可变结果接口,例如:
target = php::BigInt::mul(target, rhs);
即使 PHP 源码使用复合赋值:
$target *= $rhs;
编译器仍会生成“创建新结果并重新赋值”的代码。以 BigInt 为例,当前一次乘法通常需要:
- 创建新的
BigIntBox。 - 注册新的 Zend resource。
- 初始化新的
mpz_t。 - 为计算结果分配 GMP limb 存储。
- 将新 resource 移动赋值给目标变量。
- 析构旧 resource、Box 和底层数值存储。
在循环、累计、阶乘、金额聚合等场景中,这些开销会随运算次数线性增长:
for ($i = 0; $i < $count; $i++) {
$value = $value * 1000;
}
GMP、MPFR 和 mpdecimal 的底层对象并不是不可变对象,三者都支持输出与输入重叠的原地运算。当前不可变行为来自 PHPX 的高精度 API,而不是底层数学库的限制。
本文给出高精度类型原地运算的实现方案、语义约束、分阶段计划和验收标准。
2. 优化目标
2.1 主要目标
- 对唯一持有的 BigInt、BigFloat 和 Decimal Box 复用现有对象。
- 尽可能复用 GMP limb、MPFR mantissa 和 mpdecimal coefficient 存储。
- 消除复合赋值中的结果 Box 和 Zend resource 临时对象。
- 对整数等原生 RHS 直接使用
php::Int或php::Var,避免构造 RHS 高精度 Box。 - 将安全的
$x = $x {op} $rhs融合为原地运算。 - 保持 PHP 的值语义、引用语义、求值顺序和异常行为。
- 不安全或无法证明安全的场景自动回退到当前不可变实现。
2.2 非目标
- 第一阶段不优化数组元素、动态属性、属性 Hook 等复杂左值。
- 不依赖全程序别名分析才能保证正确性。
- 不修改 GMP、MPFR 或 mpdecimal 第三方源码。
- 不把所有普通二元表达式改为可变计算。
- 不改变不同高精度类型之间的隐式转换规则。
3. 底层库能力
| 类型 | 底层对象 | 原地操作 | 内存复用特征 |
|---|---|---|---|
| BigInt | GMP mpz_t / mpz_class |
支持 | 容量足够时复用 limb,结果增长时才扩容 |
| BigFloat | MPFR mpfr_t |
支持 | 当前固定 256-bit 精度,普通运算通常可持续复用 mantissa |
| Decimal | mpdecimal mpd_t / decimal::Decimal |
支持 | 可复用 coefficient,库本身提供 operator+= 等原地接口 |
典型原地调用如下:
mpz_mul(dst, dst, rhs);
mpfr_mul(dst, dst, rhs, MPFR_RNDN);
mpd_qmul(dst, dst, rhs, context, &status);
mpdecimal 的 C++ 包装已经提供:
Decimal::operator+=
Decimal::operator-=
Decimal::operator*=
Decimal::operator/=
Decimal::operator%=
因此技术瓶颈主要在 PHP Box 的共享语义、异常安全和编译器求值顺序,而不是数学库本身。
4. 必须保持的语言语义
4.1 Copy-on-write
高精度值可能被多个 PHP 变量共享同一个 Box:
$a = std::bigInt(10);
$b = $a;
$a *= 2;
结果必须是:
$a = 20
$b = 10
不能直接修改共享 Box。PHPX 必须在运算前检查 Zend resource 引用计数:
- resource 唯一持有:直接修改原 Box。
- resource 被共享:复制 Box,将目标变量绑定到副本,再修改副本。
运行时 copy-on-write 是正确性的最后防线。编译器静态分析只负责减少不必要的检查和识别可融合表达式,不能替代运行时检查。
4.2 PHP 引用
以下场景中两个变量指向同一个 PHP 引用容器:
$a = std::bigInt(10);
$b =& $a;
$a *= 2;
结果必须是 $a 和 $b 同时为 20。原地 API 必须通过 Variant::unwrap_ptr() 操作引用中的实际 zval;发生 copy-on-write 时,应更新引用容器中的值,而不是重新绑定 PHPX 包装对象。
4.3 RHS 与目标变量别名
必须正确处理:
$a *= $a;
建议原地接口将目标以引用传递、RHS 以值传递:
BigInt::mulAssign(Variant &target, Variant rhs);
如果 RHS 与 target 共享同一 resource,RHS 的临时引用计数会使 copy-on-write 走复制分支。这可能错失一次原地机会,但能自然保证正确性。后续可增加“RHS 与 target 是同一 Box”的专门路径。
4.4 求值顺序
下面两段代码不能在所有情况下直接视为等价:
$x = $x * changeValue($x);
$x *= changeValue($x);
RHS 可能重新赋值、按引用修改或通过闭包捕获修改 $x。C++ 函数参数求值顺序也不能用来替代 PHP 求值规则。
编译器必须遵循以下规则:
- 对真正的
AssignOp使用现有有序操作数和副作用捕获机制。 - 对
$x = $x {op} $rhs,仅在 RHS 不会写入或逃逸$x时融合。 - 无法证明安全时使用当前“计算新结果后赋值”路径。
- 如果为了保持顺序必须保存旧
$x,该临时值会增加引用计数,此时应允许运行时自动回退 copy-on-write。
4.5 复杂左值
以下表达式不能在第一阶段改写:
$array[getIndex()] = $array[getIndex()] * 2;
$object->value = $object->value * 2;
$object->hooked = $object->hooked * 2;
原因包括:
- 下标表达式可能执行两次。
- Getter、Setter 或属性 Hook 的调用次数可能改变。
- 动态属性读写可能触发魔术方法。
- 左值本身可能带有副作用。
第一阶段只支持简单局部变量。复杂左值在后续阶段通过“只求值一次的 writable target”抽象单独设计。
4.6 异常安全
当前不可变实现先计算新结果,成功后才赋值,因此异常发生时目标变量不变:
$value = std::decimal('10');
try {
$value /= 0;
} catch (DivisionByZeroError $e) {
}
echo $value; // 仍然是 10
原地实现必须保持这一行为。
- BigInt:在修改前检查除数、模数、指数等错误条件。
- BigFloat:在修改前检查除零和当前 API 明确定义的错误条件。
- Decimal:
context.raise(status)可能在底层结果已经写入后抛出异常,需要事务式提交或回滚机制。 - 内存分配失败也不能将目标留在部分修改状态。
4.7 Resource identity
高精度 Box 当前以 resource 暴露,get_resource_id() 和严格比较可能观察 resource identity。原地运算会让唯一持有的变量保持 resource id,而当前不可变实现会生成新的 resource id。
实施前需要明确以下契约之一:
- 高精度类型是值类型,resource identity 属于内部实现细节,不保证运算前后变化。
- 必须保持当前 resource identity 变化,此时只能复用底层数值存储并重新封装 resource,收益会降低。
推荐采用方案 1,并在高精度类型文档中明确:用户应比较数值,不应依赖内部 resource id。共享变量的值语义仍由 copy-on-write 严格保证。
5. PHPX 设计
5.1 显式原地 API
不建议给通用 Variant 增加高精度运算符重载。应在各高精度类型上增加明确接口:
class BigInt {
public:
static Variant &addAssign(Variant &target, Variant rhs);
static Variant &subAssign(Variant &target, Variant rhs);
static Variant &mulAssign(Variant &target, Variant rhs);
static Variant &divAssign(Variant &target, Variant rhs);
static Variant &modAssign(Variant &target, Variant rhs);
};
BigFloat、Decimal 使用同样的命名方式。BigInt 还应覆盖位运算和移位:
bitAndAssign
bitOrAssign
bitXorAssign
bitShiftLeftAssign
bitShiftRightAssign
接口返回 Variant &,使复合赋值仍可作为表达式使用:
$result = ($value *= 2);
如果实际生成代码对引用返回处理不方便,可以同时提供 statement-only 的 void 快速路径,但不能牺牲赋值表达式语义。
5.2 Box 唯一化工具
在 PHPX 内部提供复用的 C++17 helper,而不是在三种类型中重复 Zend resource 逻辑:
template <typename T>
T *separateBoxForWrite(Variant &target);
职责包括:
- 解引用 indirect/reference zval。
- 校验 target 是目标 Box 类型。
- 检查 Zend resource 引用计数。
- 唯一持有时返回原 Box。
- 共享时复制 Box,并通过
Variant赋值语义更新目标。 - 保持 typed reference 检查和异常传播。
三种 Box 都必须具备正确复制能力:
- BigInt:复制
mpz_class。 - BigFloat:按源精度初始化并复制
mpfr_t。 - Decimal:复制
decimal::Decimal。
5.3 RHS 提取
原地接口应直接接受 Variant rhs,复用现有 operand extractor:
php::Int直接转换为底层整数操作数。php::Var在运行时检查其实际类型。- 已经是同类 Box 时直接读取底层值。
- 字符串、浮点数和不同高精度类型继续遵循当前转换限制。
生成代码应优先为:
php::BigInt::mulAssign(value, 1000L);
php::Decimal::mulAssign(value, factor);
避免:
php::BigInt::mulAssign(value, php::toBigInt(1000L));
php::Decimal::mulAssign(value, php::toDecimal(1000L));
对于 Decimal 的整数 RHS,可进一步使用 mpdecimal 的 _i64/_u64 接口,避免构造临时 decimal::Decimal:
mpd_qmul_i64(result, left, rhs, context, &status);
5.4 BigInt 实现策略
BigInt 优先实现真正原地操作:
Variant &BigInt::mulAssign(Variant &target, Variant rhs) {
BigIntOperand right;
// 先提取并验证 RHS。
// 再执行 target 的 copy-on-write。
// 最后调用 mpz_mul(dst, dst, right)。
return target;
}
必须在修改前完成所有可恢复错误检查,例如除零、模零和非法移位量。GMP 扩容由其内部管理;容量足够时会复用原 limb 存储。
5.5 BigFloat 实现策略
BigFloat 当前统一使用 BIG_FLOAT_DEFAULT_PRECISION,适合直接原地运算:
mpfr_mul(dst, dst, rhs, MPFR_RNDN);
如果未来支持每对象精度,需要规定非原地结果精度与复合赋值目标精度的关系,并加入不同精度对象测试。
5.6 Decimal 实现策略
Decimal 分两步实施。
第一步采用异常安全的事务式提交:
decimal::Decimal temporary;
uint32_t status = 0;
mpd_qmul(temporary.get(), current.getconst(), rhs, context, &status);
context.raise(status);
current = std::move(temporary);
该方案可以消除结果 Box 和 Zend resource,但仍会创建一个底层 Decimal 临时对象。
第二步评估真正原地运算:
- 修改前完成除零等显式检查。
- 确认哪些 status/trap 可能在操作后抛出。
- 为可能抛出的操作提供备份/回滚,或仅在能够证明不会触发 trap 时原地执行。
- 对 Overflow、InvalidOperation、DivisionByZero 和模拟分配失败做专项测试。
不能为了性能接受“异常后目标值已被部分修改”。
6. 编译器设计
6.1 真正的复合赋值
首先修改现有 Big* AssignOp 生成路径:
$value *= $rhs;
从:
value = php::BigInt::mul(value, rhs);
改为:
php::BigInt::mulAssign(value, rhs);
支持矩阵:
| 类型 | 第一阶段操作符 |
|---|---|
| BigInt | `+= -= *= /= %= &= |
| BigFloat | += -= *= /= |
| Decimal | += -= *= /= %= |
6.2 普通赋值融合
识别以下 AST:
$x = $x {op} $rhs;
只有同时满足以下条件才融合:
- 左值是简单命名变量。
- 二元表达式左操作数是同一个变量。
- 变量静态类型是 BigInt、BigFloat 或 Decimal。
- 操作符在对应类型的支持列表中。
- RHS 不包含对目标变量的赋值、引用获取或已知按引用传参。
- RHS 不包含无法安全分析的
eval、动态调用或其他逃逸路径;或者现有副作用分析明确证明安全。 - 当前表达式上下文可以正确接收原地接口的返回值。
以下场景第一阶段不融合:
$x = 2 - $x;
$x = $x * ($x = 2);
$x = $x * dynamicCall();
$array[$key] = $array[$key] * 2;
$object->value = $object->value * 2;
对交换律操作的 $x = $rhs + $x 或 $x = $rhs * $x 优化放到后续阶段,避免扩大第一版范围。
6.3 失败回退
优化必须是可选 codegen 路径:
可安全原地运算 -> emit *Assign()
无法证明安全 -> emit 当前 new-result 路径
任何类型不确定、复杂左值、引用逃逸或副作用分析失败都不能导致编译错误,只应失去该项优化。
6.4 与 SSA/优化器的关系
初始版本可在 AssignOpTrait 和普通赋值解析中做局部 AST 匹配,不依赖完整 SSA。
后续可由 SSA 提供:
- 目标变量是否存在别名。
- RHS 是否写入目标变量。
- 变量是否逃逸到动态调用或引用。
- 是否能静态证明 Box 唯一持有。
即使 SSA 证明唯一,PHPX 运行时 copy-on-write 检查仍建议保留,除非有严格的逃逸证明和专项测试。
7. 分阶段实施计划
阶段 0:基线与观测
- 添加高精度 Box/resource 创建计数的测试辅助设施。
- 建立 BigInt、BigFloat、Decimal 循环运算 benchmark。
- 记录当前 wall time、Box 数量、resource 数量和底层分配次数。
- 固化当前别名、引用、异常和 resource identity 行为。
交付物:基准报告和行为测试,不改变生成代码。
阶段 1:原生 RHS 快速路径
- BigInt 运算直接接受
php::Int。 - BigFloat 运算直接接受
php::Int、php::Float。 - Decimal 运算直接接受
php::Int和实际为 int 的php::Var。 - Decimal 整数路径优先使用
mpd_q*_i64。 - 消除编译器为 RHS 创建的高精度 Box。
交付物:RHS 不再出现不必要的 toBigInt()、toBigFloat()、toDecimal()。
阶段 2:PHPX copy-on-write 基础设施
- 实现
separateBoxForWrite<T>()。 - 为三类 Box 完善复制测试。
- 覆盖普通变量、共享变量、PHP 引用、indirect zval 和 RHS 同 Box。
- 明确 resource identity 契约。
交付物:独立 PHPX 单元测试,不修改编译器生成路径。
阶段 3:BigInt 与 BigFloat 复合赋值
- 实现 BigInt
*Assign()方法族。 - 实现 BigFloat
*Assign()方法族。 - 修改真正的 PHP
AssignOp生成代码。 - 保留不安全路径回退。
- 运行 PHPX 全量测试、编译器全量 PHPUnit、相关 PHPT 和自举编译。
交付物:$x *= $rhs 等语法使用真正原地操作。
阶段 4:普通赋值融合
- 识别简单局部变量
$x = $x {op} $rhs。 - 实现目标变量写入/逃逸检查。
- 对纯字面量和纯变量 RHS 优先启用。
- 为有副作用 RHS 保留旧路径。
交付物:问题描述中的常见写法无需用户手动改成复合赋值。
阶段 5:Decimal 事务式原地接口
- 实现 Decimal
*Assign()API。 - 先使用“底层临时结果 + 成功后提交”。
- 使用
_i64快速路径优化整数 RHS。 - 覆盖所有 Decimal trap 和异常后的目标值。
交付物:消除 Decimal 结果 Box/resource,保持强异常安全。
阶段 6:Decimal 真正原地计算
- 按操作符分析可能触发的 status/trap。
- 对可证明安全的操作直接使用目标
mpd_t。 - 对高风险操作保留事务式路径。
- 通过 benchmark 判断复杂度是否值得。
交付物:Decimal 常见累计运算复用 coefficient 存储。
阶段 7:复杂左值与进一步优化
- 设计只求值一次的 writable target 抽象。
- 评估数组元素、静态属性、普通属性的支持。
- 属性 Hook、魔术方法和动态属性默认不启用,除非能严格保持调用次数和顺序。
- 评估交换律表达式融合和 SSA 唯一性证明。
8. 测试计划
8.1 PHPX 单元测试
每种类型、每个操作符至少覆盖:
- 唯一 Box 原地更新。
- 共享 Box 触发 copy-on-write。
- PHP 引用更新同一引用值。
- RHS 与 target 为同一 Box。
- Int、Float、String、Var 等允许的 RHS 类型。
- 非法 RHS 类型异常。
- 除零、模零、负指数等边界。
- 异常后目标值不变。
- 极大数导致扩容。
- 连续多次运算。
8.2 编译器 PHPUnit
检查生成代码:
AssignOp生成BigInt::mulAssign()等调用。$x = $x * 1000被融合。- RHS 原生整数不再构造 Big* Box。
- RHS 有副作用时不融合。
- 数组元素和属性第一阶段不融合。
- 不支持的操作符继续给出原有 FatalError。
8.3 PHPT
至少覆盖:
$a *= 2;
$a = $a * 2;
$b = $a; $a *= 2;
$b =& $a; $a *= 2;
$a *= $a;
$a *= ($factor = 2);
$result = ($a *= 2);
并为三种高精度类型覆盖:
- 正数、负数、零。
- 极大值和精度边界。
- 所有支持的复合赋值操作符。
- 异常后的左值。
- 循环中的连续更新。
8.4 集成验证
每个阶段至少执行:
./vendor/bin/phpunit
php run-tests.php tests/compiler/bigint tests/compiler/bignumber tests/compiler/decimal
php bin/tpc.php project.yml
PHPX 修改还必须执行 PHPX 全量单元测试。
9. 性能验收
性能测试至少包含:
- 1、4、16、64、256、1024 个 limb/decimal digit 规模。
- RHS 为小整数、同类高精度值和动态
php::Var。 - 唯一 Box 和共享 Box。
- 1 千、10 万、100 万次循环。
- BigInt 增长型乘法与稳定容量加法。
- BigFloat 固定精度累计。
- Decimal 固定 50 位精度累计。
功能验收标准:
- 唯一 BigInt/BigFloat 的复合赋值每次迭代不创建结果 Box/resource。
- 原生 RHS 不创建高精度 Box。
- 共享 Box 正确触发 copy-on-write。
- 所有异常路径保持目标值不变。
- 自举编译和全量测试通过。
性能验收以基线数据为准,不预设不现实的固定倍数。至少应分别报告:
- 总耗时。
- Box/resource 创建次数。
- 底层内存分配次数与字节数。
- 峰值内存。
- copy-on-write 命中率和回退率。
如果某条优化路径不能减少分配,或者导致常见非原地表达式明显退化,应保留旧路径或撤销该子优化。
10. 风险与回滚策略
主要风险:
- Box 共享判断错误导致其他变量被意外修改。
- 引用或 indirect zval 被重新绑定而不是更新。
- RHS 副作用改变求值顺序。
- Decimal 异常后目标值被污染。
- resource identity 行为发生未记录变化。
- 原地扩容失败留下非法底层对象。
控制措施:
- 所有优化集中在独立 PHPX API 和单一编译器 codegen 分支。
- 无法证明安全时回退旧实现。
- 分类型、分操作符逐步启用。
- 每个阶段独立提交,避免同时修改过多语义。
- 在完成异常、别名和引用测试前,不删除现有不可变 API。
回滚时只需让编译器重新生成:
target = Type::operation(target, rhs);
原有不可变 API 在整个迁移期必须保留。
11. 推荐优先级
综合收益、复杂度和风险,推荐顺序为:
- BigFloat 原地复合赋值。
- BigInt 原地复合赋值。
- BigInt/BigFloat 普通赋值融合。
- Decimal 原生整数 RHS 快速路径。
- Decimal 事务式
*Assign()。 - Decimal 真正原地计算。
- 复杂左值和 SSA 增强。
BigFloat 固定精度,最容易稳定复用底层内存;BigInt 的应用面更广,整体收益可能最大;Decimal 的异常和 trap 语义最复杂,应最后推进真正原地修改。
12. 最终目标代码
对安全的简单变量:
$value = $value * 1000;
最终生成:
php::BigInt::mulAssign(value, 1000L);
运行时:
唯一 Box:原地复用 Box、resource 和底层存储
共享 Box:copy-on-write 后修改新 Box
不安全场景:回退当前不可变结果实现
该设计把性能优化限制在可验证的边界内,同时保留 TypePHP 与 PHP 赋值、引用和异常语义的一致性。