如何在单元测试中避免浮点舍入错误?

时间:2014-01-13 17:22:51

标签: c unit-testing floating-point sse floating-accuracy

我正在尝试为一些简单的向量数学函数编写单元测试,这些函数对单精度浮点数的数组进行操作。这些函数使用SSE内在函数,当在32位系统上运行测试时,我会得到误报(至少我认为)(测试通过64位)。当操作通过数组运行时,我累积越来越多的舍入错误。这是一个单元测试代码和输出的片段(我的实际问题如下):

测试设置:

static const int N = 1024;
static const float MSCALAR = 42.42f;

static void setup(void) {
    input = _mm_malloc(sizeof(*input) * N, 16);
    ainput = _mm_malloc(sizeof(*ainput) * N, 16);
    output = _mm_malloc(sizeof(*output) * N, 16);
    expected = _mm_malloc(sizeof(*expected) * N, 16);

    memset(output, 0, sizeof(*output) * N);

    for (int i = 0; i < N; i++) {
        input[i] = i * 0.4f;
        ainput[i] = i * 2.1f;
        expected[i] = (input[i] * MSCALAR) + ainput[i];
    }
}

我的主要测试代码然后调用要测试的函数(用于生成expected数组的相同计算)并检查其输出与上面生成的expected数组。检查是为了接近(在0.0001内)不相等。

示例输出:

0.000000    0.000000    delta: 0.000000
44.419998   44.419998   delta: 0.000000
...snip 100 or so lines...
2043.319946 2043.319946 delta: 0.000000
2087.739746 2087.739990 delta: 0.000244
...snip 100 or so lines...
4086.639893 4086.639893 delta: 0.000000
4131.059570 4131.060059 delta: 0.000488
4175.479492 4175.479980 delta: 0.000488
...etc, etc...

我知道我有两个问题:

  1. 在32位机器上,387和SSE浮点运算单元之间的差异。我相信387使用更多位作为中间值。
  2. 我用于生成预期值的42.42值的非精确表示。
  3. 所以我的问题是,为浮点数据的数学运算编写有意义的可移植单元测试的正确方法是什么?

    *通过便携式,我的意思是应该传递32位和64位架构。

2 个答案:

答案 0 :(得分:6)

根据评论,我们看到正在测试的功能基本上是:

for (int i = 0; i < N; ++i)
    D[i] = A[i] * b + C[i];

其中A[i]bC[i]D[i]都有float类型。在引用单次迭代的数据时,我将acd用于A[i]C[i]D[i]

下面是对测试此函数时可用于容错的内容的分析。首先,我想指出我们可以设计测试,以便没有错误。我们可以选择A[i]bC[i]D[i]的值,以便所有结果(包括最终结果和中间结果)都可以准确表示,并且没有舍入错误。显然,这不会测试浮点运算,但这不是目标。目标是测试函数的代码:它是否执行计算所需函数的指令?只需选择能够揭示使用正确数据的任何失败,添加,乘法或存储到正确位置的值,就足以揭示函数中的错误。我们相信硬件正确地执行浮点并且没有测试它;我们只想测试函数是否正确编写。为实现此目的,我们可以将b设置为2的幂,A[i]设置为各种小整数,将C[i]设置为各种小整数乘以b。如果需要,我可以更准确地详细说明这些值的限制。那么所有结果都是准确的,任何允许比较容差的需要都会消失。

除此之外,让我们继续进行错误分析。

目标是找到函数实现中的错误。为此,我们可以忽略浮点运算中的小错误,因为我们所寻求的错误种类几乎总是会导致大错误:使用了错误的操作,使用了错误的数据,或者结果没有存储在期望的位置,因此实际结果几乎总是与预期的结果非常不同。

现在问题是我们应该容忍多少错误?由于错误通常会导致较大的错误,因此我们可以将容差设置得相当高。但是,在浮点数中,“高”仍然是相对的;与数万亿的值相比,100万的误差很小,但是当输入值在1中时,发现错误太高。所以我们至少应该做一些分析来决定水平。

正在测试的功能将使用SSE内在函数。这意味着,对于上面循环中的每个i,它将执行浮点乘法和浮点加法,或者执行融合浮点乘法加法。后者的潜在错误是前者的一个子集,所以我将使用前者。 a*b+c的浮点运算会进行一些舍入,以便计算大约为a•b + c的结果(解释为精确的数学表达式,而不是浮点数)。如果所有值都在浮点格式的正常范围内,我们可以为某些误差e0和e1写出精确的值(a•b•(1+e0)+c)•(1+e1),其中幅度最多为2 -24 。 (2 -24 是IEEE-754 32位二进制浮点格式中以舍入到最接近模式的任何正确舍入的基本浮点运算中可能出现的最大相对误差。在舍入到最接近模式下,将数学值最多改为有效数中最低有效位的一半,即最高有效位以下23位。)

接下来,我们考虑测试程序为其预期值产生的值。它使用C代码d = a*b + c;。 (我已将问题中的长名称转换为较短的名称。)理想情况下,这也会计算IEEE-754 32位二进制浮点的乘法和加法。如果确实如此,那么结果将与正在测试的功能相同,并且不需要允许任何容差进行比较。但是,C标准允许实现在执行浮点运算时具有一定的灵活性,并且存在比标准允许更多自由的不符合实现。

一种常见的行为是计算表达式的精度高于其标称类型。某些编译器可能会使用a*b + cdouble算术计算long double。 C标准要求将结果转换为演员表或作业中的名义类型;必须丢弃额外的精度。如果C实现使用额外的精度,则计算继续进行:a*b计算时具有额外的精度,恰好产生a•b,因为doublelong double具有足够的精度来准确表示任意两个float值的乘积。然后,C实现可以将此结果舍入到float。这不太可能,但无论如何我还是允许的。但是,我也忽略它,因为它将预期结果移动到更接近被测函数的结果,我们只需要知道可能发生的最大错误。所以我会继续,更糟糕(更遥远)的情况,到目前为止的结果是a•b。然后添加c,产生(a•b + c)•(1 + e2)某些e2,其幅度最多为2 -53 (正常数字的最大相对误差) 64位二进制格式)。最后,此值转换为float以分配给d,产生(a•b + c)•(1 + e2)•(1 + e3)对于某些e3,幅度最多为2 < SUP> -24

现在我们有正确运算函数计算的精确结果的表达式,(a•b•(1 + e0)+ c)•(1 + e1),以及测试代码计算的精确结果,( a•b + c)•(1 + e2)•(1 + e3),我们可以计算它们可以有多大差异的界限。简单代数告诉我们确切的区别是a•b•(e0 + e1 + e0•e1-e2-e3-e2•e3)+ c•(e1-e2-e3-e2•e3)。这是e0,e1,e2和e3的简单函数,我们可以看到它的极值发生在e0,e1,e2和e3的潜在值的端点。由于值的符号可能性之间的相互作用,存在一些复杂性,但是对于最坏的情况,我们可以简单地允许一些额外的错误。差异的最大值的界限是| a•b |•(3•2 -24 +2 -53 +2 -48 )+ | C |。•(2•2 -24 2 -53 2 -77

因为我们有足够的空间,所以我们可以简化它,只要我们朝着使价值变大的方向去做。例如,使用| a•b |•3.001•2 -24 + | c |•2.001•2 -24 可能是方便的。这个表达式应该足以允许在浮点计算中进行舍入,同时检测几乎所有的实现错误。

请注意,表达式与最终值a*b+c不成比例,由正在测试的函数或测试程序计算得出。这意味着,通常,使用相对于被测试函数或测试程序计算的最终值的公差的测试是错误的。正确的测试形式应该是这样的:

double tolerance = fabs(input[i] * MSCALAR) * 0x3.001p-24 + fabs(ainput[i]) * 0x2.001p-24;
double difference = fabs(output[i] - expected[i]);
if (! (difference < tolerance))
   // Report error here.

总之,这给了我们一个容差,它比浮点舍入更大的任何可能的差异,所以它永远不应该给我们一个误报(报告测试函数没有被破坏)。但是,与我们想要检测的错误引起的错误相比,它非常小,所以它应该很少给我们一个假阴性(无法报告实际错误)。

(注意,还有计算公差的舍入误差,但它们小于我在系数中使用.001所允许的斜率,所以我们可以忽略它们。)

(另请注意,! (difference < tolerance)不等同于difference >= tolerance。如果函数产生NaN,由于错误,任何比较都会产生错误:difference < tolerance和{{1} } yield false,但difference >= tolerance得到真。)

答案 1 :(得分:2)

  

在32位机器上,387和SSE浮点运算单元之间存在差异。我相信387使用更多位作为中间值。

如果您使用GCC作为32位编译器,您可以告诉它使用选项-msse2 -mfpmath=sse生成SSE2代码。可以告诉Clang用两个选项中的一个做同样的事情而忽略另一个(我忘了哪个)。在这两种情况下,二进制程序应该实现严格的IEEE 754语义,并计算与64位程序相同的结果,该程序也使用SSE2指令来实现严格的IEEE 754语义。

  

我用于生成预期值的42.42值的非精确表示。

C标准表示必须将42.42f之类的文字转换为紧接在十进制数字表示的数字之上或之下的浮点数。此外,如果文字可以完全表示为预期格式的浮点数,则必须使用此值。但是,质量编译器(如GCC)会给你(*)最近的可表示的浮点数,其中只有一个,所以再次,这不是一个真正的可移植性问题因为您正在使用高质量的编译器(或者至少使用相同的编译器)。

如果这是一个问题,解决方案是写出您想要的常量的精确表示。十进制格式的这种精确表示可以非常长(对于double的精确表示,最多可达750个十进制数字),但在C99的十六进制格式中始终非常紧凑:0x1.535c28p+5用于精确表示float最接近42.42。最新版本的C程序静态分析平台Frama-C可provide使用选项-warn-decimal-float:all的所有不精确十进制浮点常量的十六进制表示。


(*)禁止旧GCC版本中的一些转换错误。有关详细信息,请参阅Rick Regan's blog