MindSpore踩坑——昇腾上的Cosine误差

这两天遇到一个很经典的issue,为啥说经典呢,因为这是一个能够体现框架研发人员和算法工程师认知严重错位的典型案例。先放链接,有兴趣的同学可以去看下全过程。

【AICC】CosineDecayLR余弦学习率实现方式强转float32类型计算(目前测试源码只能用fp32),导致出现负值学习率影响模型最后收敛!!!

写模型的老师(maybe 学生?)用了三个感叹号来表达内心的不爽。我先来简单描述一下这个问题:

P.Cos()(Tensor(math.pi, mstype.float32))
# result: -1.000004

用于计算余弦函数的Cos算子,结果会有误差,正常情况下cos(pi) = -1.0,但是用MindSpore计算得到的结果多了一个-4e-6。一般情况下,如果在网络里用到Cos算子,其实影响也不大,但是。。。。这个问题是发生在CosineDecayLR,也就是利用余弦函数动态调整学习率,这时候,就会出现问题:

P.Cos()(Tensor(math.pi, mstype.float32)) + 1.0
# result: -4.053116e-06

这个时候影响就非常大了,入门常识问题,学习率不能为负数,否则梯度更新会是反方向。此外,一般如BERT这样的模型,学习率的数量级在1e-5左右,可以看到这个误差就会严重影响梯度下降了。

算子精度误差达标=功能正确?

既然问题很大,要怎么解决呢?我大概简述一下issue创建者和专家回复过程。

issue创建者:CosineDecayLR余弦学习率出现负值学习率影响模型最后收敛!!!误差是-4.053116e-06。
专家:百万分之4的误差,满足算子合理的计算误差范围
issue创建者:但是导致我学习率为负值之后,我模型梯度更新方向反了,loss从稳定逐渐升高......

这也是我说这是典型的原因,从硬件芯片到驱动使能再到算子库和框架,其实每一个层级的角度是不同的,所以对于昇腾(或者说CANN)而言,算子的精度误差在合理范围内,这个算子是可以验收发布的。这时候,如果默认其正确,然后交由上层封装(即MindSpore)也是没有问题的。

但是!!!深度学习框架的研发和测试如果没有充分的背景知识(其实是常识),就会出现这样的问题。

显然,算子精度误差达标,绝不会等价于功能正确,像CosineDecayLR这样的API应该合理验证边界条件可能触发的问题。再次再次再次吐槽一遍,AI框架开发者要有深度学习基础!

GPU和Ascend上的正/余弦函数误差处理

回到问题本身,既然余弦函数有误差,正弦函数也得看看。然后我又在GPU上跑了一下,发现一个有趣的现象。

Ascend:

P.Cos()(Tensor(math.pi, mstype.float32))
# result: -1.000004
P.Sin()(Tensor(math.pi, mstype.float32))
# result: 0.0

GPU:

P.Cos()(Tensor(math.pi, mstype.float32))
# result: -1.0
P.Sin()(Tensor(math.pi, mstype.float32))
# result: -8.7423e-08

这个结果就很耐人寻味了,Ascend上Sin是是没有精度误差的,GPU刚好相反。为了确认不是MindSpore的问题,我又用Pytorch跑了一下:

torch.cos(torch.tensor(math.pi))
# result: -1.
torch.sin(torch.tensor(math.pi))
# result: -8.7423e-08

可以明确Pytorch同样存在误差,但是GPU上应该对Cos做了处理。考虑到一般Cos的使用场景更多(构建网络、学习率甚至权重初始化),这个处理也就可以理解了。而Ascend上Sin是无误差的,与GPU刚好相反,不知道是出于什么原因的考虑。但是从MindSpore跨平台使用而言,同样的CosineDecayLR代码,在这个时候会造成巨大差异是毫无疑问的。

 

CosineDecayLR的解决(规避)方案

方案1

根据 @用什么名字没那么重要 的建议,直接clip数值更合适,不会出现误差问题。

代码如下:

import mindspore.ops as P
import mindspore.common.dtype as mstype
from mindspore import context
from mindspore.nn.learning_rate_schedule import LearningRateSchedule

class CosineDecayLR(LearningRateSchedule):
    def __init__(self, min_lr, max_lr, decay_steps):
        super(CosineDecayLR, self).__init__()
        if not isinstance(min_lr, float):
            raise TypeError("For 'CosineDecayLR', the argument 'min_lr' must be type of float, "
                            "but got 'min_lr' type: {}.".format(type(min_lr)))
        if min_lr >= max_lr:
            raise ValueError("For 'CosineDecayLR', the 'max_lr' should be greater than the 'min_lr', "
                             "but got 'max_lr' value: {}, 'min_lr' value: {}.".format(max_lr, min_lr))
        self.min_lr = min_lr
        self.max_lr = max_lr
        self.decay_steps = decay_steps
        self.math_pi = math.pi
        self.delta = 0.5 * (max_lr - min_lr)
        self.cos = P.Cos()
        self.min = P.Minimum()
        self.max = P.Maximum()
        self.cast = P.Cast()

    def construct(self, global_step):
        p = self.cast(self.min(global_step, self.decay_steps), mstype.float32)
        return self.min_lr + self.delta * self.max((1.0 + self.cos(self.math_pi * (p / self.decay_steps))), 0.0)

方案2

有了前面的分析,其实从前端角度解决或规避就比较简单了,既然Sin算子不会出现误差,那就直接使用Sin替代Cos即可:

cos(a) = sin(a + pi/2)

公式也很简单,直接改造一下CosineDecayLR源码即可。

import mindspore.ops as P
import mindspore.common.dtype as mstype
from mindspore import context
from mindspore.nn.learning_rate_schedule import LearningRateSchedule

class CosineDecayLR(LearningRateSchedule):
    def __init__(self, min_lr, max_lr, decay_steps):
        super(CosineDecayLR, self).__init__()
        if not isinstance(min_lr, float):
            raise TypeError("For 'CosineDecayLR', the argument 'min_lr' must be type of float, "
                            "but got 'min_lr' type: {}.".format(type(min_lr)))
        if min_lr >= max_lr:
            raise ValueError("For 'CosineDecayLR', the 'max_lr' should be greater than the 'min_lr', "
                             "but got 'max_lr' value: {}, 'min_lr' value: {}.".format(max_lr, min_lr))
        self.min_lr = min_lr
        self.max_lr = max_lr
        self.decay_steps = decay_steps
        self.math_pi = math.pi
        self.delta = 0.5 * (max_lr - min_lr)
        self.cos = P.Cos()
        self.sin = P.Sin()
        self.min = P.Minimum()
        self.cast = P.Cast()
        self.is_ascend = context.get_context("device_target") == "Ascend"

    def construct(self, global_step):
        p = self.cast(self.min(global_step, self.decay_steps), mstype.float32)
        if self.is_ascend:
            return self.min_lr + self.delta * (1.0 + self.sin(self.math_pi * (p / self.decay_steps + 0.5)))
        return self.min_lr + self.delta * (1.0 + self.cos(self.math_pi * (p / self.decay_steps)))

经过实测,

P.Cos()(Tensor(math.pi, mstype.float32))
# result: -1.000004
P.Sin()(Tensor(math.pi * (1 + 0.5), mstype.float32))
# result: -0.9999996

虽然也有误差,但是不会出现 cos(pi) + 1.0 < 0.0 的情况了,因此学习率不会出现负值,梯度更新不会反向。但是精度问题还在,而且框架研发人员和算法工程师认知严重错位的问题值得更加重视。

以上。

posted @ 2022-07-17 17:37  Skytier  阅读(163)  评论(0编辑  收藏  举报