Code Snippets——Unreal代码缺陷之浮点误差

发表于2017-06-04
评论0 2.8k浏览

先介绍一下这次的文章封面。最近几天我在泰国的某一小岛上休假。今年下午在写这篇文章的时候突然一只小鸟撞在了玻璃门上,撞蒙了无法继续飞。于是我将它捡进来,等它清醒了又把它放了出去。非常漂亮的一只小鸟,于是我决定干脆就用小鸟的照片作为封面吧!


背景

大约在去年的这个时候我在Unreal中发现这么一段代码:


这是Unreal的Runtime下的MediaAssets模块中的一段代码(详见MediaSoundWave.cpp文件中GeneratePCMData函数),主要负责将音频解码器解出来的音频数据进行处理,然后进行声音的播放。


最近打开Unreal发现这段代码还是老样子,并没有什么改变。这段代码逻辑非常直观,很好理解,但是仔细分析会发现这里边包含着一处代码缺陷。


上面图片的代码中包含有一句加法赋值运算:

InputFrameInterp += ResampleRatio;


这是两个float类型的浮点数相加,InputFrameInterp的初始值为0.0f,ResampleRatio值的计算过程如下:

const float ResampleRatio = (float)SinkSampleRate / 44100.0f;


SinkSampleRate的值为我们常见的音频采样率,可以是如下值:

8000.0f, 11025.0f, 16000.0f, 22050.0f, 32000.0f, 44100.0f, 48000.0f, 64000.0f, 88200.0f, 96000.0f


在代码中有这样一步操作:

QueuedAudio.RemoveAt(0, InputIndex * sizeof(int16));


InputIndex的数值由InputFrameInterp计算而来:

int32 InputFrame = (int32)InputFrameInterp;

......

InputIndex = InputFrame * SinkNumChannels;


那么问题就出现了,一旦InputFrameInterp相关的浮点计算出现严重误差,就可能导致QueuedAudio的RemoveAt操作发生越界Remove。


实例分析

我们将上面的计算过程复制出来,并枚举在各种可能数值情况下的浮点误差范围。


声道数枚举值为:

1、2、4、6


采样率枚举值为:

8000.0f、11025.0f、16000.0f、22050.0f、32000.0f、44100.0f、48000.0f、64000.0f、88200.0f、96000.0f

上面这些都是比较常见的音频编码格式数值。


SamplesAvailable值定为8192,因为这是Unreal调用GeneratePCMData传进来的最大数值:


测试代码如下:

#include "stdafx.h"

#include 


#define FLOATNUM 1.08843541f

typedef int int32;

typedef short int16;


class FPlatformMath

{

public:

       static int32 FloorToInt(float F)

       {

             return _mm_cvt_ss2si(_mm_set_ss(F + F - 0.5f)) >> 1;

       }

};


int main()

{

       /** The sink's number of audio channels. */

       const int32 NumChannels[4] = { 1, 2, 4, 6 };

       const float SampleRates[10] = { 8000.0f, 11025.0f, 16000.0f, 22050.0f, 32000.0f, 44100.0f, 48000.0f, 64000.0f, 88200.0f, 96000.0f };

       const int32 NumOutputFrames = 8192 * 2;

       for (int32 IndexSampleRate = 0; IndexSampleRate < 10; ++IndexSampleRate)

       {

             for (int32 IndexChannels = 0; IndexChannels < 4; ++IndexChannels)

             {

                    // poor man's re-sampling

                    const float ResampleRatio = (float)SampleRates[IndexSampleRate] / 44100.0f;

                    float InputFrameInterp = 0.0f;

                    int32 InputIndex = 0;

                    for (int32 OutputFrame = 0; OutputFrame < NumOutputFrames; ++OutputFrame)

                    {

                           int32 InputFrame = (int32)InputFrameInterp;

                           InputIndex = InputFrame * NumChannels[IndexChannels];

                           InputIndex += NumChannels[IndexChannels];

                           InputFrameInterp += ResampleRatio;

                    }

                    

                    int32 RemoveLength = (int32)(InputIndex * sizeof(int16)); // QueuedAudio.RemoveAt(0, InputIndex * sizeof(int16));

                    int32 SamplesAvailable = (int32)((NumOutputFrames * NumChannels[IndexChannels] *ResampleRatio + NumChannels[IndexChannels]) * sizeof(int16));

                    std::cout << "SampleRate:" << SampleRates[IndexSampleRate] << " NumChannel:" <<NumChannels[IndexChannels] << " " <<RemoveLength - SamplesAvailable << std::endl;

             }

       }

       return 0;

}


执行结果:

我们可以通过打印出的结果来看RemoveLength始终小于SamplesAvailable,也就是SamplesAvailable值为8192的时候不会出问题。


我们调整SamplesAvailable数值为8192*2,然后输出数值如下:

我们可以看到结果中有很多正值,也就是RemoveLength有时会大于SamplesAvailable,会发生越界Remove。


我们将代码“InputFrameInterp += ResampleRatio; 改为“InputFrameInterp = (OutputFrame + 1) * ResampleRatio;”,将浮点加法改为乘法,结果如下:

即使SamplesAvailable数值为8192*2,也不会发生问题。


问题总结

随着SamplesAvailable数值的增大,浮点误差会越来越明显。这是由于IEEE单精度浮点格式共32位,但其中只能有7位来表示数值。计算机内部一部分十进制的有限位小数可以表示为7位以内的有限位二进制小数,而另一部分十进制的有限位小数会表示为一个二进制的无限位小数,这样就导致了丢失精度。每一次加法运算都会有一定的计算误差,累计误差会越来越大。加之InputFrameInterp数值被累加到很大的时候两个加数间的数级差别太大,已经无法进行正确的加法计算了。


举个极端的例子:

     float Vlaue0 = 1234567.0f;

     float Vlaue1 = Vlaue1 + 0.001f;

此时Vlaue0的值还是1234567.0f,这个0.001f加了就跟没加是一样的。


关于这个问题有很多的无误差方案可以用。当然我上面的改为乘法的方法只是减小误差的一种最简单的办法。


这段代码在Unreal中跑起来目前可能还不会存在什么问题,因为SamplesAvailable的值目前最大为8192,浮点误差不至于导致发生越界Remove。所以这不是一个bug,而是一处代码缺陷。然而代码缺陷也不容小视,因为这是一枚炸弹,外部调用的代码发生变化,这里马上就变成了比较棘手的bug。


这个问题其实可以作为一道不错的技术面试题来考察一下应聘者的程序设计能力。


写在最后

历史上由于浮点误差导致重大损失的事故有很多:

1.1991年2月25日,海湾战争中美国“爱国者”导弹对伊拉克“飞毛腿”导弹进行拦截,但是没有拦住。导致美军一个军事基地28位军人牺牲。导致这次事故的原因就是浮点累计误差。

2.1996年6月4日,Ariane5运载火箭开启数秒后被人为摧毁,同时被摧毁的还包括4颗卫星。研发历时8年多时间,研制费用高达80亿美元以上。官方给出的报告说他们将一个64-bit float转换为一个16 bit signed int导致的数值溢出bug。

3.In 1982 (I figure) the Vancouver Stock Exchange instituted a new index initialized to a value of 1000.000. The index was updated after each transaction. Twenty two months later it had fallen to 520. The cause was that the updated value was truncated rather than rounded. The rounded calculation gave a value of 1098.892.


由于证券相关知识匮乏,我直接贴出最后一个事故的资料原文。


我发布在这里的文章都是转至我的微信订阅号,如果你想及时获得最新发布的文章,可以关注我的微信订阅号。

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引