Java中金融应用程序的快速定点数学

财务信息(帐户,过帐和其他簿记)对浮点数不是很友好,这已经不是什么秘密了,许多文章建议使用定点算法。 实际上,在Java中,此格式仅由BigDecimal类表示,出于性能原因,该类不能始终使用。 我们必须寻找替代方案。 本文介绍了一个用于对固定精度数字执行算术运算的自写Java库。 该库是为在高性能金融应用程序中工作而创建的,它使您可以在保持可接受的性能的同时,精确到小数点后9位。 本文末尾提供了到源和基准的链接。


浮点运算


现代计算机只能以有限的精度执行算术运算。 这些是离散设备,可能无法使用所有可能的数字,而只能使用其中的一些可数子集。 在计算机内存中使用实数的最常见格式是浮点(二进制)-浮点(二进制),当数字以M * 2 ^ E的形式存储时,其中M和E是整数尾数和数字顺序。 但是某些数字(例如0.1)无法以这种格式准确表示。 因此,在复杂的计算过程中,不可避免地会积累一些误差。 也就是说,机器计算的结果(例如0.1 + 0.1 + 0.1)与数学上正确的0.3不一致。 鉴于以上所述,在编写复杂算术时,可以遵循几种策略:


策略1-忽略。 不要关注错误,将所有运算都视为理想的数学运算,并希望可用的精度足以获得可接受的结果。 最常见的选择。


策略2-精心计算。 数十年来,用于计算机器误差的公式已为人所知。 它们使从上面估算任何算术运算的相对误差成为可能。 可能是认真进行数值模拟时必须要做的。 问题在于这非常耗时。 实际上,代码中的每个+-* /字符都必须附带一个错误计算。 您需要考虑计算之间的所有依赖关系,并在每次更改代码时重复该过程。


策略3-使用小数点(浮动小数点)代替二进制。 也就是说,以M * 10 ^ E的形式存储数字。 这不能解决错误问题(尾数仍四舍五入到有效数字的有限数量),但是现在至少一个人的所有“简单”数字(如1.1)都可以精确地表示在内存中。 回报将是性能。 任何数字的归一化(即,尾数的减少和顺序的增加)都需要除以10的幂,这并不很快,这与除以2的幂不同。您必须进行大量归一化-每个加法或减法都具有不同的阶数。


策略4-使用固定点(固定小数点)。 当我们确定订单E时,简化了策略3。在这种情况下,加/减不需要归一化。 此外,所有计算都将具有相同的绝对误差。 本文专门讨论这种策略。


定点算法


与相对误差很重要的物理学相反,金融学只需要绝对误差即可。 如果在复杂的金融交易之后,向客户收取1,000,000.23美元的费用,而他预期为1,000,000,18美元,那么可能会遇到一些困难。 诸如“为什么需要8个有效数字的精度?”之类的说明。 可能不会骑。 而且,这里的要点不是损失5美分(相反,错误的是,“支持”客户并没有好得多),而是会计上的不一致。 因此,各方之间明确指定了计算和舍入规则,并且使用double和float变量产生的工件有时会使生活变得复杂。


Java具有用于定点算法的标准类-BigDecimal。 它有两个问题:它很慢(由于其通用性)并且不稳定。 不稳定意味着任何操作都会在堆上分配一个对象。 根据对象进行选择和释放会花费一些时间,但是“热”代码中的大量计算会给GC造成较大的负担,这在某些情况下是不可接受的。 您可以依靠转义分析和标量,但是它们在某种意义上是非常不稳定的,即使代码或JIT中的微小变化(例如,延迟加载新的接口实现)也可以颠倒整个内联结构,并且该方法在一分钟前可以正常工作,突然开始疯狂分配内存。
UPD由于评论中存在问题:放弃BigDecimal和BigInteger的主要原因不是计算性能低下,而是对象的不稳定和选择。


所描述的库是以下事实的结果:我厌倦了从头开始为每个新雇主重写定点非分配算法,因此,我决定编写自己的库以进行后续内包。


在继续介绍实施细节之前,我将立即显示一个使用示例:


public class Sample { private final Decimal margin; private final Quantity cumQuantity = new Quantity(); private final Quantity contraQuantity = new Quantity(); private final Quantity cumContraQuantity = new Quantity(); private final Price priceWithMargin = new Price(); private final Price avgPrice = new Price(); public Sample(int marginBp) { // 1 + margin / 10000 this.margin = Decimal.create(marginBp).divRD(10000L).add(1); } public Price calculateAvgPrice(Quantity[] quantities, Price[] prices) { cumQuantity.set(0); contraQuantity.set(0); // avg = sum(q * p * margin) / sum(q) for (int i = 0; i < quantities.length; i++) { cumQuantity.add(quantities[i]); priceWithMargin.set(prices[i]).mulRD(margin); contraQuantity.set(quantities[i]).mulRD(priceWithMargin); cumContraQuantity.add(contraQuantity); } return avgPrice.quotientRD(cumContraQuantity, cumQuantity); } public static void main(String[] args) throws ParseException { Price p1 = Price.create("1.5"); Price p2 = Price.create(1.6); Quantity q1 = Quantity.create("100"); Quantity q2 = Quantity.create(200); // apply 0.05% margin to the prices Sample sample = new Sample(5); System.out.println(sample.calculateAvgPrice(new Quantity[]{q1, q2}, new Price[]{p1, p2})); } } 

实施思路


因此,我们需要一个可变的整数基元包装,更确切地说是long'a,这将为我们提供将近19个有效数字(足以容纳整数和小数部分)。 总之,我们的意思是N个小数位。 例如,对于N = 2,数字2.56被存储为256(二进制100000000)。 负数按标准存储在其他代码中:


-2.56
-256


(此后, 斜体表示“数学”数字和计算,并用粗体表示其内部表示)


在我看来,输入NaN作为单独的值似乎很有用,在出现算术错误(而不是异常或垃圾)的情况下返回该值。 NaN在内部表示为Long.MIN_VALUE ,它通过所有操作“传播”,并允许确定所有剩余数字的符号反转。


让我们尝试估计N = 2时的算术运算算法。


加法和减法不需要任何额外的手势,只需按原样使用值即可:


1.20 + 2.30 = 3.50
120 + 230 = 350


乘法和除法需要额外的归一化,即乘以10 ^ N(在我们的示例中为100)


1.20 * 2.00 = 2.40
120 * 200/100 = 240


1.20 / 2.00 = 0.60
100 * 120/200 = 60


追加划分不是最快的操作。 但是在这种情况下,这是一个常数的除法,因为我们之前固定了N = 2和10 ^ N = 100。 按常数除法,尤其是按“美丽”(类型10)进行除法,在CPU中进行了优化,并且比除以随机数要快得多。 每次将任何数字转换为字符串时(例如,在日志中),我们都会进行10的除法运算,而CPU制造商也知道这一点( 有关优化的更多详细信息,请参见“按常数除法”)。


为了加强对我们正在做的事情的理解,我将再进行一次操作:一个数字的一​​元求逆,即1 / x。 这是除法的一种特殊情况,您只需要以我们的格式提交1.00,并且别忘了进行标准化:


1.00 / 2.00 = 0.50
100 * 100/200 = 50


好吧,尽管一切都非常简单,但让我们尝试深入研究细节。


四舍五入


让我们尝试绘制另一个数字:


1.00 / 3.00 = 0.33
100 * 100/300 = 33


一个诚实的数学结果介于0.33和0.34之间,但我们无法确切地想象它。 哪种方式取整? 通常舍入为0,这是最快的方式(支持硬件)。 但是,回到实际的财务问题上,情况并非总是如此。 通常,在与客户进行交易时,四舍五入“有利于客户”。 也就是说,如果客户在卖出,价格将四舍五入,如果客户在买入,价格将四舍五入。 但是可能还需要其他选择,例如,用子类型(上半部分,下半部分,半均匀部分)对四舍五入到最接近的数字,以最大程度地减少记帐不一致。 或将负价格四舍五入至无穷大(对于某些金融工具)。 Java BigDecimal已经包含标准舍入模式的列表,并且所描述的库支持所有这些模式。 如果操作意外需要舍入,则UNNECESSARY返回NaN。


在向上汇总模式下,我们的计算应得出:


1.00 / 3.00 = 0.34
100 * 100/300 +1 = 34


如何找出添加单位所需的内容? 您需要除法的剩余部分10,000%300 =100。这与除法本身一样慢。 幸运的是,如果您在代码“ a / b; a%b”中连续编写一行,那么JIT将意识到不需要2个除法,只需一个汇编div命令返回2个数字(商和余数)。


其他舍入选项稍微复杂一些,但也可以根据余数和除数来计算。


在API中,我故意提到了舍入的任何地方,无论是作为参数还是默认为零的方法中的Round D自己的后缀。


溢流


我们来到最困难的部分。 再次回忆我们的乘法:


1.20 * 2.00 = 2.40
120 * 200/100 = 240


现在想象一下我们在1980年代,我们有16位处理器。 也就是说,对我们来说只有short可用,最大值为65535。第一个乘法将溢出并且等于240000&0xFFFF = 44392(如果是无符号的,带符号的也将为负),这将对我们不利。


它不起作用。 我们有2个正常的(适合我们的值范围)参数,并且具有相同的正常预期结果,但是我们中途溢出。 对于64位long'om,可能完全一样,只是数字需要更多。


在1980年代,我们需要一个乘法来给出32位结果。 今天,我们需要一个具有128位结果的乘法。 最令人讨厌的是,这两个乘法分别在汇编程序8086和x86-64中可用,但是我们不能在Java中使用它们! 即使在使用快速JavaCritical进行黑客攻击的情况下,JNI也会产生数十纳秒的开销,会给部署和兼容性带来困难,并会在调用过程中冻结GC。 另外,我们将不得不以某种方式从本机方法返回一个128位结果,并且通过引用数组(在内存中)进行写入是一个额外的延迟。


通常,我不得不编写手动乘法和除法。 专栏 我需要2个辅助操作:


  1. A(64)* B(64)= T(128); T(128)/ N(32)= Q(64),R(32)-作为乘法不动点A * B的一部分
  2. N(32)* A(64)= T(96); T(96)/ B(64)= Q(64),R(64)-作为定点除法A / B的一部分
    (括号中的数据表示位的维度,T是一个不应溢出的临时变量)

这两个操作都返回商和余数(方法的结果之一,对象字段中的第二个结果)。 它们也可能溢出,但只有在不可避免的情况下,才可以在最后一步溢出。 这是一个示例(来自1980年代):


500.00 / 0.50 = 1000.00
100 * 50,000 / 50 = 100,000-溢出!


Knut的列划分不是最简单的算法。 另外,它也应该相对较快。 因此,这两个操作的代码都是数百行相当严重的魔术,这将花费我很多时间来再次记住那里到底发生了什么。 我将他们拉到一个单独的班级,并尽可能详细地评论。


乘法算法不仅限于调用操作1,其余代码也不是那么复杂,只是增加了对负数,舍入和NaN的支持。


通常(特殊情况除外),两个运算都包含4个乘法和2个除法。 运算1比2快得多,因为其中的这些除法是一个常数。


顺便说一句,如果有人注意到,N(32)是我们用于标准化的10 ^N。 它是32位,由此得出N最多可以为9。在我看到的实际应用中,使用了2、4或8个小数位。 我所看到的不超过9个,所以应该足够了。 如果使10 ^ N为64位,则代码将变得更加复杂(并减慢速度)。


几种不同的精度


有时有必要对具有不同小数位数的参数执行运算。 至少要输入涉及通常时间的操作。


例如:


2.0000(N = 4)+ 3.00(N = 2)= 5.0000(N = 4)
20,000 + 300 * 100 = 50,000


3.00(N = 2)+ 2.0000(N = 4)= 5.00(N = 2)
300 + 20,000 / 100 = 500


在这种情况下,需要对参数之一进行额外的标准化。 请注意,在数学上,这两个操作是等效的,但是由于结果的准确性不同,因此对它们的计算方式也不同。 还值得注意的是,第二个操作通常需要取整。


小数位数不存储在对象中。 而是,为每个精度假定一个单独的子类。 类名称可以面向业务,例如价格(N = 8),数量(N = 2)。 可以将其概括为:Decimal1,Decimal2,Decimal3,...精度越高,存储值的范围越小,最小范围为Decimal9:±9223372036。 假定一个或两个类足以覆盖必要的功能,在这种情况下,抽象的getScale方法很可能会被虚拟化和内联。 子类(而不是其他字段)使您可以严格地定义参数和结果的准确性,并在编译阶段发出可能舍入的信号。


该库最多允许2个(但不是3个)不同精度的操作。 也就是说,两个自变量的精度必须一致,或者自变量之一和结果的精度必须一致。 同样,支持3种不同的精度将大大降低代码速度并使API复杂化。 作为参数,您可以传递规则的long,假设其精度为N = 0。


2.0000 / 3.0 = 0.6667-确定(2种不同的精度)
2/3 = 0.6667-好的(长参数,十进制结果)
2 / 3.0 = 0.6667-不可能! (3种不同的精度)


优缺点


显然,该库执行的高位计算比硬件支持的速度慢。 但是,开销并不是很大(请参阅下面的基准)。


另外,由于Java中缺少运算符重载,因此使用方法代替算术运算符会使代码的感知复杂化。


基于此,该库通常用于绝对精度损失至关重要的地方。 例如,在考虑当前财务指标(交易头寸,PnL,已执行订单)的情况下,计算准确的财务统计信息。 在系统之间的财务信息网络交换中,使用带小数点的格式(而不是二进制)也更加方便。


复杂的数学算法(建模,统计,预测)通常更容易以标准方式双重执行,因为它们的结果在任何情况下都不是绝对准确的。


代码和基准


代号


基准测试模式碳纳米管分数失误单位
DecimalBenchmark.control平均20010.072±0.074ns / op
DecimalBenchmark.multiplyNative平均20010.625±0.142ns / op
DecimalBenchmark.multiplyMyDecimal平均20035.840±0.121ns / op
DecimalBenchmark.multiplyBigDecimal平均200126.098±0.408ns / op
DecimalBenchmark.quotientNative平均20070.728±0.230ns / op
DecimalBenchmark.quotientMyDecimal平均200138.581±7.102ns / op
DecimalBenchmark.quotientBigDecimal平均200179.650±0.849ns / op

通常,乘法比BigDecimal快4倍,除法是1.5。 除法率高度依赖于参数,因此值的分散性。

Source: https://habr.com/ru/post/zh-CN425565/


All Articles