C#中的数学优化

65

我整天在对一个应用程序进行性能分析,优化了一些代码,但还剩下这个任务。这是神经网络的激活函数,会被调用超过一亿次。根据 dotTrace 的数据,它占据了整个函数时间的约 60%。

你会如何对其进行优化?

public static float Sigmoid(double value) {
    return (float) (1.0 / (1.0 + Math.Pow(Math.E, -value)));
}

输入值的范围是多少? - Bill the Lizard
它因运行而异,但通常在-10.00000到+10.000000之间。将其更改为浮点数并正常工作,除了一些类中的强制转换。 - hb.
1
有没有简单的方法可以确保该方法被内联呢?也许使用final修饰符? - jjnguy
p.s. 我是C#的初学者,所以只是猜测。 - jjnguy
5
在确定需要优化之前进行分析,这是一个不错的选择! - erikkallen
显示剩余4条评论
25个回答

63

试试这样:

public static float Sigmoid(double value) {
    return 1.0f / (1.0f + (float) Math.Exp(-value));
}

编辑: 我进行了一个快速的基准测试。在我的机器上,上述代码比你的方法快约43%,而这个数学上等价的代码略微更快一点(比原始代码快46%):

public static float Sigmoid(double value) {
    float k = Math.Exp(value);
    return k / (1.0f + k);
}

编辑2:我不确定C#函数有多大的开销,但是如果你在源代码中#include <math.h>,则应该能够使用这个函数,它使用了一个浮点数指数函数。速度可能会更快。

public static float Sigmoid(double value) {
    float k = expf((float) value);
    return k / (1.0f + k);
}

另外,如果您要执行数百万次调用,则函数调用开销可能是一个问题。尝试创建一个内联函数,看看是否有帮助。


你知道这个值参数的范围吗?如果知道,可以考虑生成查找表。 - Jeremy
你改变了值的符号,我的数学有点生疏,但我认为这不是同一件事...根据最初的代码,你应该使用Math.Exp(-value)。 - Marcel Popescu
1
@Marcel:不是的,他把e^-value改成了1/(e^value),然后加上了1.0并交换了分子/分母。 - lacop
请原谅我,为什么要转换为浮点数?难道不是浮点数源自双精度数字吗?如果是这样的话,使用双精度数字会更好,似乎是这样的。 - Rusty Nail
所以是 1 / (1+k) 还是 k / (1+k) - Aaron Franke
第一种情况是当k=exp(-value)时,第二种情况是当k=exp(+value)时。在数学上是等价的。 - Fred Haslam

31

如果这是针对激活函数的话,计算e^x是否完全准确其实并不那么重要?

例如,如果你使用近似值(1+x/256)^256,在我用Java测试的Pentium上(我假设C#本质上会编译为相同的处理器指令),这个近似值比e^x(Math.exp())快7-8倍,对于范围在+/-1.5左右的x值,精度可以保持在小数点后两位,并且在所述范围内有正确的数量级。(显然,要将一个数升至256次方,你需要对该数进行8次平方--不要使用Math.Pow!)在Java中:

double eapprox = (1d + x / 256d);
eapprox *= eapprox;
eapprox *= eapprox;
eapprox *= eapprox;
eapprox *= eapprox;
eapprox *= eapprox;
eapprox *= eapprox;
eapprox *= eapprox;
eapprox *= eapprox;

根据你想要的精度,不断将256加倍或减半(并添加/删除乘法)。即使n=4,对于-0.5到0.5之间的x值,它仍然可以提供约1.5个小数位的精度(并且比Math.exp()快15倍左右)。

附注:我忘了提到——显然不应该真正除以256:而是乘以一个常数1/256。Java的JIT编译器会自动进行优化(至少,Hotspot会),我假设C#也会这样做。


1
哇。这甚至降低了它的价值! - hb.
1
如果你正在乘以或除以二的幂次方,使用左移或右移(<<和>>)而不是乘法/除法,速度会更快。 - nicodemus13
@nicodemus13 -- 这对于整数情况可以工作,但在现代处理器上并不一定比直接乘法更快。但你真的可以让编译器执行这种优化。 - Neil Coffey
1
但不要假设你20年前的处理器时序和优化概念仍然适用。你可能会发现你的处理器可以在相同的时间内完成FP乘法和整数移位... - Neil Coffey

24
请查看这篇文章。它介绍了用Java编写的e^x的近似值,以下是相应的C#代码(未经过测试):
public static double Exp(double val) {  
    long tmp = (long) (1512775 * val + 1072632447);  
    return BitConverter.Int64BitsToDouble(tmp << 32);  
}

在我的基准测试中,这比Math.exp()(Java中的函数)快了5倍以上。这个近似值是基于论文“A Fast, Compact Approximation of the Exponential Function”开发的,该论文专门用于神经网络。它基本上相当于一个具有2048条目和条目之间线性逼近的查找表,但所有这些都使用IEEE浮点技巧实现。
编辑:根据Special Sauce的说法,这比CLR实现快约3.25倍。谢谢!

好奇:你能把(1072693248 - 60801)简化成1072632447吗?此外,你能把它从长整型转换为其他类型,以便不会被添加到双精度浮点数中,以提高速度吗?这会影响准确性和/或性能吗? - strager
事后看来,我意识到减法可能已经被编译器优化了,但不管怎样,值得一试。 - strager
@strager 对,这肯定是由编译器优化的。我之所以保留它,是因为这部分是公式开发的一部分,但你可以用1072632447来替换它。 - martinus
1
我在C#中对这个非常简单的函数进行了基准测试,发现它比CLR实现快了约3.25倍。为了了解误差水平,以下是五个不同数量级的随机示例对(CLR结果,近似结果):(0.0007242, 0.0007376), (1.55306, 1.57713), (307.78015, 309.18896), (1093286.54660, 1050935.0), (9.76825E+30, 9.57295E+30) - Special Sauce
@martinus 还值得注意的是,根据该论文,该方法仅支持近似于(-700,700)范围内的输入值。我注意到即使对于800+的输入值,我也看到了不可预测的行为。论文的备用链接:https://www.schraudolph.org/pubs/Schraudolph99.pdf - DuckMaestro
显示剩余2条评论

13
  1. 请记住,任何对此激活函数的更改都会带来不同的行为代价。这甚至包括切换到浮点数(从而降低精度)或使用其他激活函数。只有通过尝试您的用例才能找到正确的方法。
  2. 除了简单的代码优化外,我还建议考虑计算并行化(即利用您机器上的多个核心甚至在Windows Azure云中使用)和改进训练算法。

更新:关于ANN激活函数查找表的文章

更新2:我删除了关于LUT的内容,因为我将其与完整的哈希混淆了。感谢Henrik Gustafsson让我回到正轨。所以内存不是问题,尽管搜索空间仍然会受到局部极值的影响。


是的,我会怀疑。但是当训练算法(我正在使用一种进化算法,它允许选择网络结构)在多台机器上运行时,整个有趣的过程就开始了))。 - Rinat Abdullin
我会试一下。我也在尝试最小化内存使用(与您的帖子无关) - 显然,.NET认为传递一个巨大的数组是个好主意...咳咳... - hb.
哦...我的错。我最初混淆了LUT和完全散列。 但是,仍然使用这种方法在我的情况下并不起作用,并导致更差的训练结果。 - Rinat Abdullin
“更差的训练结果”意味着需要更多的尝试才能获得一个相对良好的训练结果。 - Rinat Abdullin
我发布了一个带有C#实现的新答案,我还包含了一些处理引入误差的论文链接。我没有时间全部阅读,但总体而言,它们似乎表明量化具有良好和不良的特征,但大多数可以使用适当的技术来管理。 - Henrik Gustafsson
显示剩余7条评论

8

当调用次数达到1亿次时,我会开始怀疑分析器开销是否会扭曲您的结果。请用一个无操作符替换计算,并查看它是否仍然报告占用60%的执行时间...

或者更好的方法是,创建一些测试数据并使用秒表计时器来分析100万次左右的调用。


8

如果您能与C++进行互操作,可以考虑将所有值存储在数组中,并使用SSE循环遍历它们,例如:

void sigmoid_sse(float *a_Values, float *a_Output, size_t a_Size){
    __m128* l_Output = (__m128*)a_Output;
    __m128* l_Start  = (__m128*)a_Values;
    __m128* l_End    = (__m128*)(a_Values + a_Size);

    const __m128 l_One        = _mm_set_ps1(1.f);
    const __m128 l_Half       = _mm_set_ps1(1.f / 2.f);
    const __m128 l_OneOver6   = _mm_set_ps1(1.f / 6.f);
    const __m128 l_OneOver24  = _mm_set_ps1(1.f / 24.f);
    const __m128 l_OneOver120 = _mm_set_ps1(1.f / 120.f);
    const __m128 l_OneOver720 = _mm_set_ps1(1.f / 720.f);
    const __m128 l_MinOne     = _mm_set_ps1(-1.f);

    for(__m128 *i = l_Start; i < l_End; i++){
        // 1.0 / (1.0 + Math.Pow(Math.E, -value))
        // 1.0 / (1.0 + Math.Exp(-value))

        // value = *i so we need -value
        __m128 value = _mm_mul_ps(l_MinOne, *i);

        // exp expressed as inifite series 1 + x + (x ^ 2 / 2!) + (x ^ 3 / 3!) ...
        __m128 x = value;

        // result in l_Exp
        __m128 l_Exp = l_One; // = 1

        l_Exp = _mm_add_ps(l_Exp, x); // += x

        x = _mm_mul_ps(x, x); // = x ^ 2
        l_Exp = _mm_add_ps(l_Exp, _mm_mul_ps(l_Half, x)); // += (x ^ 2 * (1 / 2))

        x = _mm_mul_ps(value, x); // = x ^ 3
        l_Exp = _mm_add_ps(l_Exp, _mm_mul_ps(l_OneOver6, x)); // += (x ^ 3 * (1 / 6))

        x = _mm_mul_ps(value, x); // = x ^ 4
        l_Exp = _mm_add_ps(l_Exp, _mm_mul_ps(l_OneOver24, x)); // += (x ^ 4 * (1 / 24))

#ifdef MORE_ACCURATE

        x = _mm_mul_ps(value, x); // = x ^ 5
        l_Exp = _mm_add_ps(l_Exp, _mm_mul_ps(l_OneOver120, x)); // += (x ^ 5 * (1 / 120))

        x = _mm_mul_ps(value, x); // = x ^ 6
        l_Exp = _mm_add_ps(l_Exp, _mm_mul_ps(l_OneOver720, x)); // += (x ^ 6 * (1 / 720))

#endif

        // we've calculated exp of -i
        // now we only need to do the '1.0 / (1.0 + ...' part
        *l_Output++ = _mm_rcp_ps(_mm_add_ps(l_One,  l_Exp));
    }
}

请记住,你将使用的数组应该使用_aligned_malloc(some_size * sizeof(float), 16)进行分配,因为SSE需要内存对齐到边界。

使用SSE,我可以在大约半秒钟内计算出所有1亿个元素的结果。然而,一次性分配那么多内存会花费你近2/3 GB的空间,因此我建议您每次处理更多但更小的数组。您甚至可能考虑使用双缓冲方式来处理100K个元素或更多。

此外,如果元素数量开始显著增长,您可能需要选择在GPU上处理这些内容(只需创建一个1D float4纹理并运行一个非常简单的片段着色器)。


+1 鼓励您跳出常规思维模式,利用硬件加速技术。 - nicodemus13

8

以下是与已发布答案相关的C#基准测试结果。 (Empty是一个只返回0的函数,用于测量函数调用开销)

Empty Function:       79ms   0
原始方法:             1576ms 0.7202294
简化方法: (soprano)    681ms  0.7202294
近似方法: (Neil)       441ms  0.7198783
位操作: (martinus)     836ms  0.72318
泰勒展开式: (Rex Logan) 261ms  0.7202305
查找表法: (Henrik)     182ms  0.7204863
public static object[] Time(Func<double, float> f) {
    var testvalue = 0.9456;
    var sw = new Stopwatch();
    sw.Start();
    for (int i = 0; i < 1e7; i++)
        f(testvalue);
    return new object[] { sw.ElapsedMilliseconds, f(testvalue) };
}
public static void Main(string[] args) {
    Console.WriteLine("Empty:       {0,10}ms {1}", Time(Empty));
    Console.WriteLine("Original:    {0,10}ms {1}", Time(Original));
    Console.WriteLine("Simplified:  {0,10}ms {1}", Time(Simplified));
    Console.WriteLine("Approximate: {0,10}ms {1}", Time(ExpApproximation));
    Console.WriteLine("Bit Manip:   {0,10}ms {1}", Time(BitBashing));
    Console.WriteLine("Taylor:      {0,10}ms {1}", Time(TaylorExpansion));
    Console.WriteLine("Lookup:      {0,10}ms {1}", Time(LUT));
}

好东西!由于F#是.NET,您认为也可以将其包含在内吗? http://research.microsoft.com/en-us/downloads/6f48a466-4294-4973-9e15-25e0ddff422f/ - Henrik Gustafsson

5

注意: 这是对帖子的跟进。

编辑:更新计算与相同的内容,并从处获得了一些灵感。

现在看看你让我做的事情! 你让我安装Mono!

$ gmcs -optimize test.cs && mono test.exe
Max deviation is 0.001663983
10^7 iterations using Sigmoid1() took 1646.613 ms
10^7 iterations using Sigmoid2() took 237.352 ms

C语言已经不值得再花费精力了,世界在向前发展 :)
所以,快了超过10的6倍。有人使用Windows电脑,使用MS工具来调查内存使用和性能方面的问题 :)
在硬件中使用查找表(LUT)进行激活函数并不罕见。如果你愿意使用这些类型的表格,那么这方面已有许多经过验证的变体。然而,正如之前已经指出的那样,别名可能会成为一个问题,但是也有解决办法。一些进一步的阅读材料:

此方法中的一些问题:

  • 当超出表格范围时,误差会增加(但在极端情况下会收敛为0);对于大约+-7.0的x值。这是由于所选择的缩放因子。SCALE的值越大,中间范围的误差就越大,但边缘处的误差就越小。
  • 这通常是一个非常愚蠢的测试,并且我不懂C#,只是将我的C代码转换而来 :)
  • Rinat Abdullin非常正确地指出别名和精度丢失可能会导致问题,但由于我没有看到这些变量,所以只能建议您尝试此方法。实际上,除了查找表的问题外,我同意他所说的一切。

抱歉复制粘贴代码...

using System;
using System.Diagnostics;

class LUTTest {
    private const float SCALE = 320.0f;
    private const int RESOLUTION = 2047;
    private const float MIN = -RESOLUTION / SCALE;
    private const float MAX = RESOLUTION / SCALE;
    
    private static readonly float[] lut = InitLUT();

    private static float[] InitLUT() {
      var lut = new float[RESOLUTION + 1];
      
      for (int i = 0; i < RESOLUTION + 1; i++) {
        lut[i] = (float)(1.0 / (1.0 + Math.Exp(-i / SCALE)));
      }
      return lut;
    }
    
    public static float Sigmoid1(double value) {
        return (float) (1.0 / (1.0 + Math.Exp(-value)));
    }
    
    public static float Sigmoid2(float value) {
      if (value <= MIN) return 0.0f;
      if (value >= MAX) return 1.0f;
      if (value >= 0) return lut[(int)(value * SCALE + 0.5f)];
      return 1.0f - lut[(int)(-value * SCALE + 0.5f)];
    }
    
    public static float error(float v0, float v1) {
      return Math.Abs(v1 - v0);
    }
    
    public static float TestError() {
        float emax = 0.0f;
        for (float x = -10.0f; x < 10.0f; x+= 0.00001f) {
          float v0 = Sigmoid1(x);
          float v1 = Sigmoid2(x);
          float e = error(v0, v1);
          if (e > emax) emax = e;
        }
        return emax;
    }

    public static double TestPerformancePlain() {
        Stopwatch sw = new Stopwatch();
        sw.Start();
        for (int i = 0; i < 10; i++) {
            for (float x = -5.0f; x < 5.0f; x+= 0.00001f) {
                Sigmoid1(x);
            }
        }
        sw.Stop();
        return sw.Elapsed.TotalMilliseconds;
    }    
    
    public static double TestPerformanceLUT() {
        Stopwatch sw = new Stopwatch();
        sw.Start();
        for (int i = 0; i < 10; i++) {
            for (float x = -5.0f; x < 5.0f; x+= 0.00001f) {
                Sigmoid2(x);
            }
        }
        sw.Stop();
        return sw.Elapsed.TotalMilliseconds;
    }    
    
    static void Main() {
        Console.WriteLine("Max deviation is {0}", TestError());
        Console.WriteLine("10^7 iterations using Sigmoid1() took {0} ms", TestPerformancePlain());
        Console.WriteLine("10^7 iterations using Sigmoid2() took {0} ms", TestPerformanceLUT());
    }
}

感谢更新。请注意,您可能需要选择不同的错误测量方式,否则将-6.0f; x < 6.0f更改为7f会将错误提高到100%。 - Rinat Abdullin
此外,以发布模式编译代码并在没有调试器附加的情况下运行可得出结果:1195毫秒与41毫秒。这比之前快了10倍以上)) - Rinat Abdullin
但是修复Sigmoid1将速度优势降至10倍。此外,通过保存中间值,可以将Sigmoid2改进2ms。 参见:http://rabdullin.com/journal/2009/1/5/caching-activation-function-is-not-worth-it.html - Rinat Abdullin
我并没有费心去变得聪明。我认为最好将其保持真正简单,并接近http://en.wikipedia.org/wiki/Approximation_error。 要处理v1=0,需要完全不同的方法,但这是该测量的固有和众所周知的弱点。 - Henrik Gustafsson
关于“局部极值”,我们在一个与机器学习完全无关的项目中,采用的一种方法是向信号添加一些噪声来消除其影响。我认为增加这种不确定性的因素有助于避免学习算法被困在那里。 - Henrik Gustafsson
显示剩余2条评论

5

F#在.NET数学算法中比C#表现更好。 因此,将神经网络重写为F#可能会提高整体性能。

如果我们在F#中重新实现LUT基准测试片段(我一直在使用稍微调整过的版本),则生成的代码:

  • sigmoid1基准测试用588.8毫秒执行,而不是3899.2毫秒
  • sigmoid2(LUT)基准测试用156.6毫秒执行,而不是411.4毫秒

有关详细信息,请参见博客文章。以下是F#片段,以防万一:

#light

let Scale = 320.0f;
let Resolution = 2047;

let Min = -single(Resolution)/Scale;
let Max = single(Resolution)/Scale;

let range step a b =
  let count = int((b-a)/step);
  seq { for i in 0 .. count -> single(i)*step + a };

let lut = [| 
  for x in 0 .. Resolution ->
    single(1.0/(1.0 +  exp(-double(x)/double(Scale))))
  |]

let sigmoid1 value = 1.0f/(1.0f + exp(-value));

let sigmoid2 v = 
  if (v <= Min) then 0.0f;
  elif (v>= Max) then 1.0f;
  else
    let f = v * Scale;
    if (v>0.0f) then lut.[int (f + 0.5f)]
    else 1.0f - lut.[int(0.5f - f)];

let getError f = 
  let test = range 0.00001f -10.0f 10.0f;
  let errors = seq { 
    for v in test -> 
      abs(sigmoid1(single(v)) - f(single(v)))
  }
  Seq.max errors;

open System.Diagnostics;

let test f = 
  let sw = Stopwatch.StartNew(); 
  let mutable m = 0.0f;
  let result = 
    for t in 1 .. 10 do
      for x in 1 .. 1000000 do
        m <- f(single(x)/100000.0f-5.0f);
  sw.Elapsed.TotalMilliseconds;

printf "Max deviation is %f\n" (getError sigmoid2)
printf "10^7 iterations using sigmoid1: %f ms\n" (test sigmoid1)
printf "10^7 iterations using sigmoid2: %f ms\n" (test sigmoid2)

let c = System.Console.ReadKey(true);

输出结果(使用F# 1.9.6.2 CTP进行发布编译,不使用调试器):

Max deviation is 0.001664
10^7 iterations using sigmoid1: 588.843700 ms
10^7 iterations using sigmoid2: 156.626700 ms

更新: 更新了基准测试使用10^7次迭代,以便与C语言的结果进行比较

更新2: 下面是来自同一台机器的C语言实现的性能结果,以作比较:

Max deviation is 0.001664
10^7 iterations using sigmoid1: 628 ms
10^7 iterations using sigmoid2: 157 ms

我会加入时间记录的C代码,但我不会用F#,而且我们的机器差异很大,所以最好由你来运行测试。敬请关注。 - Henrik Gustafsson
嘿,你要执行10^7次迭代,是吗? - Henrik Gustafsson
好的,没错。我已经修复了博客文章中的拼写错误并忘记了这个片段。谢谢。 至于 C 片段,我稍后会在我的计算机上运行它。只需获取一些 C 编译器即可。 - Rinat Abdullin
这是我在mono中的数字: 使用sigmoid1进行10^7次迭代:1661.244000毫秒使用sigmoid2进行10^7次迭代:732.762000毫秒 - Henrik Gustafsson
1
@Rinat Abdullin:你的基准测试有误。你观察到的效果是在C#中使用float作为for循环计数器。如果你使用int作为计数器,并像在F#中执行sigmoid算法一样在C#中使用委托,那么C#会稍微快一些。http://thoughtfulcode.wordpress.com/2010/12/30/is-f-math-really-faster-than-c/ - Brian Reiter

4

我能够想到的一个方法是通过滥用浮点数来近似指数,这篇论文详细介绍了该方法(点击右上角链接以获取PDF),但我不确定它是否对.NET有用。

此外,还有一点需要注意:为了快速训练大型网络,你正在使用的逻辑Sigmoid函数相当糟糕。请参阅LeCun等人的Efficient Backprop第4.4节,并使用一些零中心化的函数(实际上,请阅读整篇论文,它非常有用)。


2
你的论文链接现在好像已经损坏了。 - fig

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接