• 诡异的精度diff追查



    一、Query-diff测试发现的问题
    Query-diff是检索端常用的测试方法,其思想是使用一组相同的检索信息分别请求一个系统或模块的基线版本和待测版本。通常,基线版本和待测版本只存在少量差异(程序功能/配置等)。发送请求后,比较两个版本返回的检索结果,从而验证差异是否对最终计算结果造成了影响。


    本case中的被测模块A由C++编写,输出的核心数据为一个单精度浮点数,记为Q。
    在A模块某次升级后执行query-diff测试时,发现Q值存在精度diff,比例约为1%,最大diff在小数万分位,而该次升级预期是无diff的。
    二、深入追查
    通常出现diff,首先要明确追查的方向,如果一眼看不出原因,就需要使用排除法来逐个验证怀疑对象,缩小范围,减小不必要的精力投入。于是列出了两大排查方向:环境或程序。
    先看环境:

    • 在环境现场仔细检查了新旧环境的配置和词表,符合预期,排除了环境搭建工具的因素。
    • 由于此次升级是前向兼容的,将新旧环境的配置和词表统一,重新测试,diff复现,排除了配置差异的因素。

    环境似乎没有问题,再来验证程序:

    • 因已做了多组测试,验证结果没有改变,排除了随机策略diff的可能。
    • 打印debug日志,检查了处理过程中的每一步中间结果,均无问题,只在计算Q值的最后环节出现了diff,相继排除了线程脏数据,进程级cache脏数据,变量类型转换等风险点。
    • 为彻底确认,直接将新旧环境里的程序都替换为新版本,重新测试,如果真是程序所致,应当无diff。然而,diff复现了!明明没有随机diff的啊?!!

    此时排查到了瓶颈,环境和程序的原因似乎都不对。

    冷静下来重新思考,之前的排查分别把环境的概念解释为使用的配置和词表,认为两者相同,环境就相同。这是片面的,环境的含义还应当包含系统和硬件的编译环境和运行环境。于是有了新的验证思路:

    • 新旧版本的程序都使用公司的云编译集群产出,应当没有问题,不过为防止想当然,还是认真检查了编译参数并在本地相同机器重新编译了新旧版本,确认diff复现,排除编译因素;
    • 将新旧环境拷贝到同一台机器,重压请求,diff消失!确认为运行环境因素运行环境包括操作系统和硬件层面,趁热打铁,继续追查:
    • 确认出现diff的两台机器操作系统一致,均为centos 4.3,排除了操作系统;
    • 硬盘和内存的型号差异造成diff的可能性较小,暂不验证;
    • 新环境所在机器cpu版本Xeon E5645,旧环境所在机器cpu版本 Xeon E5-2620,怀疑cpu型号不同所致,另找了一台与旧环境cpu一致的机器部署新环境,重新测试,diff消失,目标锁定cpu。

    三、揭开真相
    分析cpu,在简单排除了核数,最大线程数,一二三级缓存的嫌疑后,cpu特性列表中的指令集差异引起了我的注意。

    补充知识一:cpu指令集的作用
    指令集是存储在CPU内部,对CPU运算进行指导和优化的硬程序。拥有这些指令集,CPU就可以更高效地运行。为解释指令集的优化方式,得提到两种技术:SISD(单指令单数据)和SIMD(单指令多数据)。
    以加法指令为例,使用SISD的CPU对加法指令译码后,执行部件先访问内存,取得第一个操作数,之后再一次访问内存,取得第二个操作数,后才能进行求和运算。而在使用SIMD的CPU中,指令译码后几个执行部件同时访问内存,一次性获得所有操作数进行运算。这个特点使SIMD特别适合于数据密集型运算。
    Cpu指令集中的SSE系列和AVX用于浮点数运算,而AVX正是两个cpu的差异之一,嫌疑很大。现在需要找到程序使用AVX进行优化的证据。
    可是,在ASQ模块中并没有直接优化的代码逻辑,涉及Q值计算的程序中虽然调用了静态libA的接口,而libA的代码也未使用指令集。不过,libA联编了静态libB,于是一路往底层追查,查到编译依赖的第四层,是IDL提供的libX,代码保密无法查看。
    只好向相关RD请教,RD告知libX中确实使用了SSE指令优化,以及Intel提供的数学函数库MKL,却没有用到AVX。

    难道又是条走不通的死路?抱着最后一点希望,查询了MKL在intel官方的介绍发现意外收获,MKL中引入了AVX优化!【1】

    现在还差最后一步,得确认AVX就是diff来源的元凶。很快,在intel的产品手中找到了进一步的证据【2】:

    AVX2中的FMA指令,在矩阵乘法、点积、多项式评估等涉及浮点数运算方面的效率和精度相对以往的指令集都有所提升,因为FMA可以将乘法与累加操作一次性完成。官方论坛里也找到了相关技术人员的帖子佐证【3】:

    补充知识二:计算机中浮点数存储方式
    float和double在存储方式上都是遵从IEEE的规范的,float遵从的是IEEE R32.24 ,而double 遵从的是R64.53。

    无论是单精度还是双精度在存储中都分为三个部分:

    1. 符号位(Sign) : 0代表正,1代表为负
    2. 指数位(Exponent):用于存储科学计数法中的指数数据,并且采用移位存储
    3. 尾数部分(Mantissa):尾数部分
    其中float的存储方式如下表所示:

     

    总长度

    尾数部分

    指数部分

    符号位

    单精度

    32bit

    0-22

    23-30

    31

    双精度

    64bit

    0-51

    52-62

    63

    扩展双精度

    80bit

    0-63

    64-78

    79

    硬件层面上,cpu的浮点运算逻辑都是放在FPU(浮点运算单元)上实现的(无论SSE还是AVX),FPU的默认计算精度是80bit,而SSE和AVX输出的float精度没那么高(均为32bit),如果FPU中计算精度存在差异(前提是均大于32bit),计算输出时截断为32bit再存入内存,必然会因近似截断造成结果diff。
    由于intel底层算法保密,只能猜测AVX和SSE的优化函数实现时设置的FPU精度有所不同,但精度差异的结论是确定的。
    此时真相已浮出水面:AVX的FMA相比SSE精度上多1bit,存在迭代计算时,差异将会累计。而Q值的产生经历复杂的矩阵运算,这个微小的1bit差异被放大至小数点万分位。同时,Intel保证了各机器的兼容性,MKL的代码在不支持AVX的cpu上运行时会被降级为SSE。
    补充知识三:使用SSE和AVX优化程序的方法
    仍以加法指令为例,对于相关头文件的引入和编译指令相关准备此处不进行介绍,可参考相关资料。
    基本版:
    简单地循环累加求和。

    // 单精度浮点数组求和_基本版.
    //
    // result: 返回数组求和结果.
    // pbuf: 数组的首地址.
    // cntbuf: 数组长度.
    float sumfloat_base(const float* pbuf, size_t cntbuf)
    {
        float s = 0;    // 求和变量.
        size_t i;
        for(i=0; i<cntbuf; ++i)
        {
            s += pbuf[i];
        }
    return s;
    }

    SSE优化版
    SSE寄存器128bit,16字节,一次可以存4个单精度浮点数,可以每4个一组存入寄存器,使用内置加法函数求和,之后再对4个分组和进行相加,最后加上分组剩余的几项,得到最终结果。

    #ifdef INTRIN_SSE
    // 单精度浮点数组求和_SSE版.
    float sumfloat_sse(const float* pbuf, size_t cntbuf)
    {
        float s = 0;    // 求和变量.
        size_t i;
        size_t nBlockWidth = 4;    // 块宽. SSE寄存器能一次处理4个float.
        size_t cntBlock = cntbuf / nBlockWidth;    // 块数.
        size_t cntRem = cntbuf % nBlockWidth;    // 剩余数量.
        __m128 xfsSum = _mm_setzero_ps();    // 求和变量。[SSE] 赋初值0
        __m128 xfsLoad;    // 加载.
        const float* p = pbuf;    // SSE批量处理时所用的指针.
        const float* q;    // 将SSE变量上的多个数值合并时所用指针.
    
        // SSE批量处理.
        for(i=0; i<cntBlock; ++i)
        {
            xfsLoad = _mm_load_ps(p);    // [SSE] 加载
            xfsSum = _mm_add_ps(xfsSum, xfsLoad);    // [SSE] 单精浮点紧缩加法
            p += nBlockWidth;
        }
        // 合并.
        q = (const float*)&xfsSum;
        s = q[0] + q[1] + q[2] + q[3];
    
        // 处理剩下的.
        for(i=0; i<cntRem; ++i)
        {
            s += p[i];
        }
        return s;
    }
    #endif    // #ifdef INTRIN_SSE

    AVX优化版
    AVX优化方式与SSE类似,但AVX寄存器使用256bit,32字节,可以存8个单精度浮点数,需要每8个float一组存入寄存器。

    #ifdef INTRIN_AVX
    // 单精度浮点数组求和_AVX版.
    float sumfloat_avx(const float* pbuf, size_t cntbuf)
    {
        float s = 0;    // 求和变量.
        size_t i;
        size_t nBlockWidth = 8;    // 块宽. AVX寄存器能一次处理8个float.
        size_t cntBlock = cntbuf / nBlockWidth;    // 块数.
        size_t cntRem = cntbuf % nBlockWidth;    // 剩余数量.
        __m256 yfsSum = _mm256_setzero_ps();    // 求和变量。[AVX] 赋初值0
        __m256 yfsLoad;    // 加载.
        const float* p = pbuf;    // AVX批量处理时所用的指针.
        const float* q;    // 将AVX变量上的多个数值合并时所用指针.
    
        // AVX批量处理.
        for(i=0; i<cntBlock; ++i)
        {
            yfsLoad = _mm256_load_ps(p);    // [AVX] 加载
            yfsSum = _mm256_add_ps(yfsSum, yfsLoad);    // [AVX] 单精浮点紧缩加法
            p += nBlockWidth;
        }
        // 合并.
        q = (const float*)&yfsSum;
        s = q[0] + q[1] + q[2] + q[3] + q[4] + q[5] + q[6] + q[7];
    
        // 处理剩下的.
        for(i=0; i<cntRem; ++i)
        {
            s += p[i];
        }
        return s;
    }
    #endif    // #ifdef INTRIN_AVX

    现在随机生成输入数组,撰写简单的测试用例,就可以验证优化的效果了,以下是三种算法的性能比较,单位为每秒可累加float的数量。结果中,SSE效率提升到普通版的4倍,而AVX是8倍!【4】

    Cpu:Intel Core i3-2310M 2.10GHz
    普通版: 667M/s
    SSE优化版: 2688M/s
    AVX优化版: 5406M/S

    四、总结和启示
    问题总结:

    • Query-diff兼容性测试时发现模块A新旧版本计算出的Q值存在diff;
    • 排查后,确定精度diff来自程序因运行环境cpu支持的浮点数指令集差异(AVX/SSE)
    • 该case中diff占比和绝对值均较小,目前虽不至影响线上服务,但若算法进一步复杂,diff积累至百分位,便会导致策略失效。
    • 其他模块的浮点数运算若用到指令集优化,也需要排查是否相同问题。

    解决方案:

    • 分配测试资源时,保证新旧环境所在机器cpu一致;
    • 执行query-diff前加入环境检查机制,再次确认硬件无差异;
    • 线上部署服务时,也需要确定机器支持AVX指令集,达到性能和精度最优;
    • 排查其他模块是否有类似使用指令集优化的情况,提前规避风险。

    启发和建议:

    • 浮点数运算密集型程序可考虑使用SSE/AVX等指令集函数优化性能,通常可显著提高运行效率(SSE:4倍,AVX:8倍);
    • 使用指令集时注意控制迭代使用的次数(即将指令集函数的输出再次作为指令集函数的输入),避免精度diff累积到不容忽视的程度;
    • 可以将query-diff测试应用到更多的兼容性测试场景中,如比较CPU,操作系统,基础库等底层系统和硬件差异对应用程序的影响。

    软件工程离不开硬件的支持,编译、运行环境的差异都有可能造成服务性能的差别和最终计算结果的差别。此类问题,在开发、测试、上线各个阶段都需要特别注意。做一个“软硬结合”的程序员很重要!

    参考资料:

    【1】https://software.intel.com/zh-cn/articles/whats-new-in-intel-mkl
    【2】https://software.intel.com/zh-cn/articles/intel-xeon-processor-e7-88004800-v3-product-family-technical-overview
    【3】https://software.intel.com/en-us/forums/topic/507004
    【4】http://www.cnblogs.com/zyl910/archive/2012/10/22/simdsumfloat.html

    百度MTC是业界领先的移动应用测试服务平台,为广大开发者在移动应用测试中面临的成本、技术和效率问题提供解决方案。同时分享行业领先的百度技术,作者来自百度员工和业界领袖等。
    >>如有问题,欢迎与我沟通

  • 相关阅读:
    PipeCAD设备管口方位图
    Status code: 404 for http://mirrors.cloud.aliyuncs.com/centos/8/AppStream/x86_64/os/repodata/repom
    LNMP下Redis介绍以及安装(Linux)
    CentOS Linux 8 AppStream 错误:为仓库 ‘appstream‘ 下载元数据失败 : Cannot prepare internal mirrorlist: No URLs
    EndNote 使用经验(GT/T 77142015格式引文)
    EndNote 使用经验(标题出现 %J .....)
    EndNote 使用经验(移除重复文献)
    EndNote 使用经验(初次使用)
    20192409潘则宇 实验七 网络欺诈与防范
    20192409潘则宇 实验六 Metasploit攻击渗透实践
  • 原文地址:https://www.cnblogs.com/baidumtc/p/5109884.html
Copyright © 2020-2023  润新知