这其实是一个迟来的好消息——今年春节期间我完成了一项重要工作却未大规模宣传,现在终于可以和大家分享:「Sdcb.Arithmetic库已实现全平台发布!」
这是一个基于GMP(GNU Multiple Precision Arithmetic Library)和MPFR(GNU MPFR Library)的高精度数值计算库,旨在为.NET开发者提供「跨平台、高效、精确、开箱即用」的数值计算解决方案。它支持无限精度的整数和浮点数运算,适用于科学计算、金融建模等需要高精度数值处理的场景。无论是计算100万位的圆周率π,还是模拟复杂的天体运动轨迹,都能轻松应对。
虽然对多数开发者而言这可能是个小众领域,但对我而言却是执念的结晶。从高中参加NOIP到大学征战ACM竞赛,高精度数值计算始终伴随左右。那时我们有个共识:遇到高精度计算题就选Java,因其内置的BigInteger/BigDecimal类能轻松处理大数运算。反观.NET平台,虽然.NET Framework 4.0加入了BigInteger,却始终缺失高性能的BigFloat实现。因此我立下目标:要以最高标准打造.NET的高精度数值计算库(当然,目前仍有改进空间)。
该项目实际在2023年就已发布:超越.NET极限-我做的高精度数值计算库,但初期仅支持Windows平台。今年春节前,受开源项目pdfium-binaries启发,我决心将跨平台方案应用到自己的开源项目中。经过整个春节假期的全力攻坚,终于实现了全平台支持的目标。 📦 多平台NuGet包发布 libgmp运行时包
Package Id | Version | License |
---|---|---|
Sdcb.Arithmetic.Gmp | MIT | |
Sdcb.Arithmetic.Gmp.runtime.win-x64 | LGPL | |
Sdcb.Arithmetic.Gmp.runtime.win-x86 | LGPL | |
Sdcb.Arithmetic.Gmp.runtime.linux-x64 | LGPL | |
Sdcb.Arithmetic.Gmp.runtime.linux-x86 | LGPL | |
Sdcb.Arithmetic.Gmp.runtime.linux-arm | LGPL | |
Sdcb.Arithmetic.Gmp.runtime.linux-arm64 | LGPL | |
Sdcb.Arithmetic.Gmp.runtime.linux-musl-x64 | LGPL | |
Sdcb.Arithmetic.Gmp.runtime.linux-musl-arm64 | LGPL | |
Sdcb.Arithmetic.Gmp.runtime.osx-arm64 | LGPL | |
Sdcb.Arithmetic.Gmp.runtime.osx-x64 | LGPL | |
Sdcb.Arithmetic.Gmp.runtime.android-arm | LGPL | |
Sdcb.Arithmetic.Gmp.runtime.android-arm64 | LGPL | |
Sdcb.Arithmetic.Gmp.runtime.android-x86 | LGPL | |
Sdcb.Arithmetic.Gmp.runtime.android-x64 | LGPL |
Package Id | Version | License |
---|---|---|
Sdcb.Arithmetic.Mpfr | MIT | |
Sdcb.Arithmetic.Mpfr.runtime.win-x64 | LGPL | |
Sdcb.Arithmetic.Mpfr.runtime.win-x86 | LGPL | |
Sdcb.Arithmetic.Mpfr.runtime.linux-x64 | LGPL | |
Sdcb.Arithmetic.Mpfr.runtime.linux-x86 | LGPL | |
Sdcb.Arithmetic.Mpfr.runtime.linux-arm | LGPL | |
Sdcb.Arithmetic.Mpfr.runtime.linux-arm64 | LGPL | |
Sdcb.Arithmetic.Mpfr.runtime.linux-musl-x64 | LGPL | |
Sdcb.Arithmetic.Mpfr.runtime.linux-musl-arm64 | LGPL | |
Sdcb.Arithmetic.Mpfr.runtime.osx-arm64 | LGPL | |
Sdcb.Arithmetic.Mpfr.runtime.osx-x64 | LGPL | |
Sdcb.Arithmetic.Mpfr.runtime.android-arm | LGPL | |
Sdcb.Arithmetic.Mpfr.runtime.android-arm64 | LGPL | |
Sdcb.Arithmetic.Mpfr.runtime.android-x86 | LGPL | |
Sdcb.Arithmetic.Mpfr.runtime.android-x64 | LGPL |
❝✅ 「关键进展」: 现已覆盖Windows、Linux、macOS、Android等主流平台,所有运行时包均通过全平台测试验证。 ℹ️ 注:iOS/WASM平台包已编译但暂未发布,因缺乏测试环境 ❞ 🚄 这个库怎么使用?
许多程序员可能已经熟悉.NET Framework 3.5
引入的大整数类System.Numeric.BigInteger
。我们可以使用这个类来计算2^65536
的最后20位数字,代码如下:
Stopwatch sw = Stopwatch.StartNew();
int count = 10;
BigInteger b = new BigInteger();
for (int c = 0; c < count; ++c)
{
b = 1;
for (int i = 1; i <= 65536; ++i)
{
b *= 2;
}
}
Console.WriteLine($"耗时:{sw.Elapsed.TotalMilliseconds / count:F2}ms");
Console.WriteLine($"2^65536最后20位数字:{b.ToString()[^20..]}");
在我的i9-9880h
电脑上,这个程序的运行结果如下:
耗时:94.00ms
2^65536最后20位数字:45587895905719156736
Sdcb.Arithmetic
库下面我们将展示如何使用我们的库Sdcb.Arithmetic
来进行同样的计算。为了安装这个库,你需要使用以下的NuGet包:Sdcb.Arithmetic.Gmp
和Sdcb.Arithmetic.Gmp.runtime.win64
(或其它对应环境包)。
Stopwatch sw = Stopwatch.StartNew();
int count = 10;
GmpInteger b = new GmpInteger();
for (int c = 0; c < count; ++c)
{
b = 1;
for (int i = 1; i <= 65536; ++i)
{
b *= 2;
}
}
Console.WriteLine($"耗时:{sw.Elapsed.TotalMilliseconds / count:F2}ms");
Console.WriteLine($"2^65536最后20位数字:{b.ToString()[^20..]}");
运行结果如下:
耗时:89.52ms
2^65536最后20位数字:45587895905719156736
可以看到,Sdcb.Arithmetic.Gmp
的结果与.NET
原生实现相匹配,并且计算速度相近。
虽然上述Gmp
代码已经达到了与原生.NET
实现相近的性能,但是我们可以通过使用底层的C API
来进一步优化我们的库。以下是优化后的代码:
// 安装NuGet包:Sdcb.Arithmetic.Gmp
// 安装NuGet包:Sdcb.Arithmetic.Gmp.runtime.win64 (或其它对应环境包)
// 函数需要标注unsafe
// 项目需要启用unsafe编译选项
Stopwatch sw = Stopwatch.StartNew();
int count = 10;
Mpz_t mpz;
GmpLib.__gmpz_init((IntPtr)(&mpz));
for (int c = 0; c < count; ++c)
{
GmpLib.__gmpz_set_si((IntPtr)(&mpz), 1);
for (int i = 1; i <= 65536; ++i)
{
GmpLib.__gmpz_mul_si((IntPtr)(&mpz), (IntPtr)(&mpz), 2);
}
}
sw.Stop();
IntPtr ret = GmpLib.__gmpz_get_str(IntPtr.Zero, 10, (IntPtr)(&mpz));
string wholeStr = Marshal.PtrToStringUTF8(ret)!;
GmpMemory.Free(ret);
Console.WriteLine($"耗时:{sw.Elapsed.TotalMilliseconds / count:F2}ms");
Console.WriteLine($"2^65536最后20位数字:{wholeStr[^20..]}");
GmpLib.__gmpz_clear((IntPtr)(&mpz));
在同一台电脑上,输出结果如下:
耗时:20.87ms
2^65536最后20位数字:45587895905719156736
InplaceAPI
优化为了做到在性能和可维护性之间找到平衡,我还开发了InplaceAPI
,这是一个直接调用底层API
,但用起来很方便的函数。以下是使用该API
后的代码:
// 安装NuGet包:Sdcb.Arithmetic.Gmp
// 安装NuGet包:Sdcb.Arithmetic.Gmp.runtime.win64 (或其它对应环境包)
Stopwatch sw = Stopwatch.StartNew();
int count = 10;
using GmpInteger b = new GmpInteger();
for (int c = 0; c < count; ++c)
{
b.Assign(1);
for (int i = 1; i <= 65536; ++i)
{
GmpInteger.MultiplyInplace(b, b, 2);
}
}
Console.WriteLine($"耗时:{sw.Elapsed.TotalMilliseconds / count:F2}ms");
Console.WriteLine($"2^65536最后20位数字:{b.ToString()[^20..]}");
作为参考,当然少不了和Java
的性能对比,这是用于对比的Java
代码:
import java.math.BigInteger;
import java.time.Duration;
import java.time.Instant;
publicclass Main {
public static void main(String[] args) {
Instant start = Instant.now();
int count = 10;
BigInteger b = BigInteger.ONE;
for (int c = 0; c < count; ++c) {
b = BigInteger.ONE;
for (int i = 1; i <= 65536; ++i) {
b = b.multiply(BigInteger.valueOf(2));
}
}
Instant finish = Instant.now();
long timeElapsed = Duration.between(start, finish).toMillis();
String str = b.toString();
String last20Digits = str.substring(str.length() - 20);
System.out.printf("耗时:%f ms\n", (double) timeElapsed / count);
System.out.println("2^65536最后20位数字:" + last20Digits);
}
}
我使用的Java
版本是OpenJDK version "11.0.16.1" 2022-08-12 LTS
,使用相同的电脑,输出结果如下:
耗时:103.100000 ms
2^65536最后20位数字:45587895905719156736
可见Java
的速度比.NET
原生的BigInteger
稍慢,比Sdcb.Arithmetic.Gmp
的速度慢了不少。
通过拉马努金公式计算100万位π值(完整算法见代码):
// Install NuGet package: Sdcb.Arithmetic.Gmp
// Install NuGet package: Sdcb.Arithmetic.Gmp.runtime.win-x64(for windows)
using Sdcb.Arithmetic.Gmp;
Stopwatch sw = Stopwatch.StartNew();
using GmpFloat pi = CalcPI();
double elapsed = sw.Elapsed.TotalMilliseconds;
Console.WriteLine($"耗时:{elapsed:F2}ms");
Console.WriteLine($"结果:{pi:N1000000}");
GmpFloat CalcPI(int inputDigits = 1_000_000)
{
constdouble DIGITS_PER_TERM = 14.1816474627254776555; // = log(53360^3) / log(10)
int DIGITS = (int)Math.Max(inputDigits, Math.Ceiling(DIGITS_PER_TERM));
uint PREC = (uint)(DIGITS * Math.Log2(10));
int N = (int)(DIGITS / DIGITS_PER_TERM);
constint A = 13591409;
constint B = 545140134;
constint C = 640320;
constint D = 426880;
constint E = 10005;
constdouble E3_24 = (double)C * C * C / 24;
using PQT pqt = ComputePQT(0, N);
GmpFloat pi = new(precision: PREC);
// pi = D * sqrt((mpf_class)E) * PQT.Q;
pi.Assign(GmpFloat.From(D, PREC) * GmpFloat.Sqrt((GmpFloat)E, PREC) * (GmpFloat)pqt.Q);
// pi /= (A * PQT.Q + PQT.T);
GmpFloat.DivideInplace(pi, pi, GmpFloat.From(A * pqt.Q + pqt.T, PREC));
return pi;
PQT ComputePQT(int n1, int n2)
{
int m;
if (n1 + 1 == n2)
{
PQT res = new()
{
P = GmpInteger.From(2 * n2 - 1)
};
GmpInteger.MultiplyInplace(res.P, res.P, 6 * n2 - 1);
GmpInteger.MultiplyInplace(res.P, res.P, 6 * n2 - 5);
GmpInteger q = GmpInteger.From(E3_24);
GmpInteger.MultiplyInplace(q, q, n2);
GmpInteger.MultiplyInplace(q, q, n2);
GmpInteger.MultiplyInplace(q, q, n2);
res.Q = q;
GmpInteger t = GmpInteger.From(B);
GmpInteger.MultiplyInplace(t, t, n2);
GmpInteger.AddInplace(t, t, A);
GmpInteger.MultiplyInplace(t, t, res.P);
// res.T = (A + B * n2) * res.P;
if ((n2 & 1) == 1) GmpInteger.NegateInplace(t, t);
res.T = t;
return res;
}
else
{
m = (n1 + n2) / 2;
PQT res1 = ComputePQT(n1, m);
using PQT res2 = ComputePQT(m, n2);
GmpInteger p = res1.P * res2.P;
GmpInteger q = res1.Q * res2.Q;
// t = res1.T * res2.Q + res1.P * res2.T
GmpInteger.MultiplyInplace(res1.T, res1.T, res2.Q);
GmpInteger.MultiplyInplace(res1.P, res1.P, res2.T);
GmpInteger.AddInplace(res1.T, res1.T, res1.P);
res1.P.Dispose();
res1.Q.Dispose();
returnnew PQT
{
P = p,
Q = q,
T = res1.T,
};
}
}
}
publicrefstruct PQT
{
public GmpInteger P;
public GmpInteger Q;
public GmpInteger T;
public readonly void Dispose()
{
P?.Dispose();
Q?.Dispose();
T?.Dispose();
}
}
在我的i9-9880h
电脑中,输出如下(100万位中间有...省略):
耗时:435.35ms
结果:3.141592653589793238462643383...83996346460422090106105779458151
可见速度是非常快的。
❝🔍 百万位π参考值 ❞
这个项目离我的“执念”具体还有哪些差距呢?
虽然目前的性能已经很不错,但在某些极端情况下,可能还会有进一步的优化空间。 这个项目有一个很意外的事情,就是mpz/mpq/mpf这些内存现在其实是直接在class中分配的一个struct,为什么这样设计呢?因为我一开始使用了Marshal.AllocHGlobal来分配内存,但发现这样在绝大多数情况下性能反而更差,原因是因为Marshal.AllocHGlobal分配的内存是非托管内存,而在.NET中,直接new一个struct会从.NET的托管堆中分配内存,这样可以减少内存分配和释放的开销,尤其是在频繁创建和销毁对象的情况下。
但是代价是什么?代价就是每次调用PInvoke的时候都需要将这个struct pin起来,这样会导致性能下降,尤其是在频繁调用PInvoke的情况下——但是我测试了,这个性能下降远不如使用Marshal.AllocHGlobal分配内存的性能下降严重,因此我决定继续使用这种方式。
这是一些数据可供参考:
方案 | 初始化/释放操作数 | PInvoke耗时 |
---|---|---|
Struct in class | 82,055,792 ops | 1237ms |
Raw memory IntPtr | 15,543,619 ops | 1134ms |
可见,使用struct in class的方式在初始化和释放时性能远远好于使用Raw memory IntPtr的方式,速度快了5倍以上,在执行具体PInvoke计算时,性能下降了大约8%,我相信有了这个数据,大家就可以理解为什么我会选择使用struct in class的方式了。
有没有两全其美的办法呢?是有的,在这个项目发布之后我一直在思考这个问题,我想到了一个办法,就是使用一个内存池来管理这些struct,这样就可以减少内存分配和释放的开销,同时又可以避免频繁的pinning操作。这个想法我会在未来的版本中实现——或许需要等另一个春节。
目前的API已经相对简单易用,但仍有提升空间,特别是如果你想兼顾性能的话,就不得不需要手动管理内存了(如上面的计算100万位π的例子,其中是优化过的)。
我觉得应该怎么做呢?我在想也许应该考虑加入一个Expression的机制,允许用户通过表达式来描述计算过程,这样可以在实际求值时进行优化里面的临时对象,从而减少内存分配和释放的开销。
这个库目前还不支持.NET 7的Generic Math特性,这个特性可以让我们使用更通用的方式来处理数值计算,比如可以使用INumber<T>
接口来定义数值类型的通用操作。
这个特性是我最最最想要的C#特性,在很久以前还在上大学的时候,我就写过一篇文章,那时候我说由于没有这个特性,就成为了C#的缺点之一。 没想到13年过去了,这个特性终于在C# 14中实现了!这个特性可以让我们定义自己的复合赋值运算符,比如+=
、-=
等,这样可以让代码更简洁、更易读。 我一定会在未来的版本中加入这个特性,这样就可以让这个库的使用更加方便。
这个库目前还没有加入求解器的功能,求解器可以让我们更方便地求解方程、积分等数学问题,也算是我的执念之一了,因为我对模拟天体运动很有兴趣,但现在求解器——比如龙格库塔法(Runge-Kutta)、欧拉法(Euler's method)、Cash-Karp法等等都需要用户手撸代码,而我希望能有一个现成的求解器可以直接使用,这样就可以更方便地进行数值计算 结语
Sdcb.Arithmetic的全平台发布标志着该库进入成熟阶段,现已覆盖Windows/Linux/macOS/Android等主流平台。无论您从事科学计算、金融分析还是高精度数值处理,本库都将成为您的得力工具。