【问题标题】:Integer vs double arithmetic performance?整数与双倍算术性能?
【发布时间】:2025-12-02 18:15:01
【问题描述】:

我正在编写一个 C# 类来使用整数执行 2D 可分离卷积,以获得比双精度对应物更好的性能。问题是我没有获得真正的性能提升。

这是 X 过滤器代码(它对 int 和 double 情况都有效):

foreach (pixel)
{
      int value = 0;
      for (int k = 0; k < filterOffsetsX.Length; k++)
      {
          value += InputImage[index + filterOffsetsX[k]] * filterValuesX[k];  //index is relative to current pixel position
      }
      tempImage[index] = value;
 }

在整数情况下,“value”、“InputImage”和“tempImage”是“int”、“Image&lt;byte&gt;”和“Image&lt;int&gt;”类型。
在 double 情况下,“value”、“InputImage”和“tempImage”分别属于“double”、“Image&lt;double&gt;”和“Image&lt;double&gt;”类型。
(filterValues 在每种情况下都是 int[])
(类 Image&lt;T&gt; 是外部 dll 的一部分。它应该类似于 .NET 绘图图像类..)。

我的目标是通过 int += (byte * int) vs double += (double * int) 实现快速性能

以下时间是 200 次重复的平均值。
过滤器尺寸 9 = 0.031 (double) 0.027 (int)
过滤器尺寸 13 = 0.042 (double) 0.038 (int)
过滤器大小 25 = 0.078 (double) 0.070 (int)

性能增益很小。这可能是由管道停顿和次优代码造成的吗?

编辑:简化删除不重要变量的代码。

EDIT2:我认为我没有与缓存未命中相关的问题,因为“索引”遍历相邻的内存单元(逐行方式)。此外,“filterOffstetsX”仅包含相对于同一行上的像素的小偏移,并且最大距离为过滤器大小 / 2。问题可能出现在第二个可分离过滤器(Y 过滤器)中,但时间差别不大。

【问题讨论】:

  • CPU 已经内置了 FPU...
  • 是的,但是 FPU 执行乘法运算的时间仍然比整数单元长几个机器周期。
  • 重复操作(如您的测试中)非常适合最大限度地利用 CPU ALU/FPU 流水线。这意味着,不包括启动和罕见的流水线故障,双/整数操作大约在每个时钟执行一次(或更多,取决于 CPU)。 IMO,这可能会在性能方面导致非常相似的结果。
  • @digEmAll,那么重复次数的增加可能会显示平均值之间的差异减小 - 对吧?但我想这将很难证明,因为我们可能会由于缓存未命中而存在时间差异。
  • 如果您将发布可以测试的代码......可能有人可能会运行一些测试:)

标签: c# performance integer double


【解决方案1】:

使用 Visual C++,因为这样我可以确定我正在计时算术运算而不是其他。

结果(每个操作执行6亿次):

i16 add: 834575
i32 add: 840381
i64 add: 1691091
f32 add: 987181
f64 add: 979725
i16 mult: 850516
i32 mult: 858988
i64 mult: 6526342
f32 mult: 1085199
f64 mult: 1072950
i16 divide: 3505916
i32 divide: 3123804
i64 divide: 10714697
f32 divide: 8309924
f64 divide: 8266111

freq = 1562587

CPU 是 Intel Core i7,Turbo Boosted 至 2.53 GHz。

基准代码:

#include <stdio.h>
#include <windows.h>

template<void (*unit)(void)>
void profile( const char* label )
{
    static __int64 cumtime;
    LARGE_INTEGER before, after;
    ::QueryPerformanceCounter(&before);
    (*unit)();
    ::QueryPerformanceCounter(&after);
    after.QuadPart -= before.QuadPart;
    printf("%s: %I64i\n", label, cumtime += after.QuadPart);
}

const unsigned repcount = 10000000;

template<typename T>
void add(volatile T& var, T val) { var += val; }

template<typename T>
void mult(volatile T& var, T val) { var *= val; }

template<typename T>
void divide(volatile T& var, T val) { var /= val; }

template<typename T, void (*fn)(volatile T& var, T val)>
void integer_op( void )
{
    unsigned reps = repcount;
    do {
        volatile T var = 2000;
        fn(var,5);
        fn(var,6);
        fn(var,7);
        fn(var,8);
        fn(var,9);
        fn(var,10);
    } while (--reps);
}

template<typename T, void (*fn)(volatile T& var, T val)>
void fp_op( void )
{
    unsigned reps = repcount;
    do {
        volatile T var = (T)2.0;
        fn(var,(T)1.01);
        fn(var,(T)1.02);
        fn(var,(T)1.03);
        fn(var,(T)2.01);
        fn(var,(T)2.02);
        fn(var,(T)2.03);
    } while (--reps);
}

int main( void )
{
    LARGE_INTEGER freq;
    unsigned reps = 10;
    do {
        profile<&integer_op<__int16,add<__int16>>>("i16 add");
        profile<&integer_op<__int32,add<__int32>>>("i32 add");
        profile<&integer_op<__int64,add<__int64>>>("i64 add");
        profile<&fp_op<float,add<float>>>("f32 add");
        profile<&fp_op<double,add<double>>>("f64 add");

        profile<&integer_op<__int16,mult<__int16>>>("i16 mult");
        profile<&integer_op<__int32,mult<__int32>>>("i32 mult");
        profile<&integer_op<__int64,mult<__int64>>>("i64 mult");
        profile<&fp_op<float,mult<float>>>("f32 mult");
        profile<&fp_op<double,mult<double>>>("f64 mult");

        profile<&integer_op<__int16,divide<__int16>>>("i16 divide");
        profile<&integer_op<__int32,divide<__int32>>>("i32 divide");
        profile<&integer_op<__int64,divide<__int64>>>("i64 divide");
        profile<&fp_op<float,divide<float>>>("f32 divide");
        profile<&fp_op<double,divide<double>>>("f64 divide");

        ::QueryPerformanceFrequency(&freq);

        putchar('\n');
    } while (--reps);

    printf("freq = %I64i\n", freq);
}

我使用 Visual C++ 2010 32 位进行了默认优化构建。

profileaddmultdivide(在循环内)的每次调用都会被内联。函数调用仍然生成到profile,但由于每次调用完成了 6000 万次操作,我认为函数调用开销并不重要。

即使添加了volatile,Visual C++ 优化编译器也是SMART。我最初使用小整数作为右手操作数,编译器愉快地使用leaadd 指令来进行整数乘法。调用高度优化的 C++ 代码可能比常识所暗示的更好,因为 C++ 优化器比任何 JIT 做得更好。

最初我在循环外初始化了var,这使得浮点乘法代码由于常量溢出而运行缓慢。 FPU 处理 NaN 很慢,在编写高性能数字运算例程时需要牢记这一点。

依赖关系也以防止流水线的方式设置。如果您想查看流水线的效果,请在评论中说明,我将修改测试平台以对多个变量进行操作,而不是仅对一个变量进行操作。

i32乘法反汇编:

;   COMDAT ??$integer_op@H$1??$mult@H@@YAXACHH@Z@@YAXXZ
_TEXT   SEGMENT
_var$66971 = -4                     ; size = 4
??$integer_op@H$1??$mult@H@@YAXACHH@Z@@YAXXZ PROC   ; integer_op<int,&mult<int> >, COMDAT

; 29   : {

  00000 55       push    ebp
  00001 8b ec        mov     ebp, esp
  00003 51       push    ecx

; 30   :    unsigned reps = repcount;

  00004 b8 80 96 98 00   mov     eax, 10000000      ; 00989680H
  00009 b9 d0 07 00 00   mov     ecx, 2000      ; 000007d0H
  0000e 8b ff        npad    2
$LL3@integer_op@5:

; 31   :    do {
; 32   :        volatile T var = 2000;

  00010 89 4d fc     mov     DWORD PTR _var$66971[ebp], ecx

; 33   :        fn(var,751);

  00013 8b 55 fc     mov     edx, DWORD PTR _var$66971[ebp]
  00016 69 d2 ef 02 00
    00       imul    edx, 751       ; 000002efH
  0001c 89 55 fc     mov     DWORD PTR _var$66971[ebp], edx

; 34   :        fn(var,6923);

  0001f 8b 55 fc     mov     edx, DWORD PTR _var$66971[ebp]
  00022 69 d2 0b 1b 00
    00       imul    edx, 6923      ; 00001b0bH
  00028 89 55 fc     mov     DWORD PTR _var$66971[ebp], edx

; 35   :        fn(var,7124);

  0002b 8b 55 fc     mov     edx, DWORD PTR _var$66971[ebp]
  0002e 69 d2 d4 1b 00
    00       imul    edx, 7124      ; 00001bd4H
  00034 89 55 fc     mov     DWORD PTR _var$66971[ebp], edx

; 36   :        fn(var,81);

  00037 8b 55 fc     mov     edx, DWORD PTR _var$66971[ebp]
  0003a 6b d2 51     imul    edx, 81            ; 00000051H
  0003d 89 55 fc     mov     DWORD PTR _var$66971[ebp], edx

; 37   :        fn(var,9143);

  00040 8b 55 fc     mov     edx, DWORD PTR _var$66971[ebp]
  00043 69 d2 b7 23 00
    00       imul    edx, 9143      ; 000023b7H
  00049 89 55 fc     mov     DWORD PTR _var$66971[ebp], edx

; 38   :        fn(var,101244215);

  0004c 8b 55 fc     mov     edx, DWORD PTR _var$66971[ebp]
  0004f 69 d2 37 dd 08
    06       imul    edx, 101244215     ; 0608dd37H

; 39   :    } while (--reps);

  00055 48       dec     eax
  00056 89 55 fc     mov     DWORD PTR _var$66971[ebp], edx
  00059 75 b5        jne     SHORT $LL3@integer_op@5

; 40   : }

  0005b 8b e5        mov     esp, ebp
  0005d 5d       pop     ebp
  0005e c3       ret     0
??$integer_op@H$1??$mult@H@@YAXACHH@Z@@YAXXZ ENDP   ; integer_op<int,&mult<int> >
; Function compile flags: /Ogtp
_TEXT   ENDS

与 f64 相乘:

;   COMDAT ??$fp_op@N$1??$mult@N@@YAXACNN@Z@@YAXXZ
_TEXT   SEGMENT
_var$67014 = -8                     ; size = 8
??$fp_op@N$1??$mult@N@@YAXACNN@Z@@YAXXZ PROC        ; fp_op<double,&mult<double> >, COMDAT

; 44   : {

  00000 55       push    ebp
  00001 8b ec        mov     ebp, esp
  00003 83 e4 f8     and     esp, -8            ; fffffff8H

; 45   :    unsigned reps = repcount;

  00006 dd 05 00 00 00
    00       fld     QWORD PTR __real@4000000000000000
  0000c 83 ec 08     sub     esp, 8
  0000f dd 05 00 00 00
    00       fld     QWORD PTR __real@3ff028f5c28f5c29
  00015 b8 80 96 98 00   mov     eax, 10000000      ; 00989680H
  0001a dd 05 00 00 00
    00       fld     QWORD PTR __real@3ff051eb851eb852
  00020 dd 05 00 00 00
    00       fld     QWORD PTR __real@3ff07ae147ae147b
  00026 dd 05 00 00 00
    00       fld     QWORD PTR __real@4000147ae147ae14
  0002c dd 05 00 00 00
    00       fld     QWORD PTR __real@400028f5c28f5c29
  00032 dd 05 00 00 00
    00       fld     QWORD PTR __real@40003d70a3d70a3d
  00038 eb 02        jmp     SHORT $LN3@fp_op@3
$LN22@fp_op@3:

; 46   :    do {
; 47   :        volatile T var = (T)2.0;
; 48   :        fn(var,(T)1.01);
; 49   :        fn(var,(T)1.02);
; 50   :        fn(var,(T)1.03);
; 51   :        fn(var,(T)2.01);
; 52   :        fn(var,(T)2.02);
; 53   :        fn(var,(T)2.03);
; 54   :    } while (--reps);

  0003a d9 ce        fxch    ST(6)
$LN3@fp_op@3:
  0003c 48       dec     eax
  0003d d9 ce        fxch    ST(6)
  0003f dd 14 24     fst     QWORD PTR _var$67014[esp+8]
  00042 dd 04 24     fld     QWORD PTR _var$67014[esp+8]
  00045 d8 ce        fmul    ST(0), ST(6)
  00047 dd 1c 24     fstp    QWORD PTR _var$67014[esp+8]
  0004a dd 04 24     fld     QWORD PTR _var$67014[esp+8]
  0004d d8 cd        fmul    ST(0), ST(5)
  0004f dd 1c 24     fstp    QWORD PTR _var$67014[esp+8]
  00052 dd 04 24     fld     QWORD PTR _var$67014[esp+8]
  00055 d8 cc        fmul    ST(0), ST(4)
  00057 dd 1c 24     fstp    QWORD PTR _var$67014[esp+8]
  0005a dd 04 24     fld     QWORD PTR _var$67014[esp+8]
  0005d d8 cb        fmul    ST(0), ST(3)
  0005f dd 1c 24     fstp    QWORD PTR _var$67014[esp+8]
  00062 dd 04 24     fld     QWORD PTR _var$67014[esp+8]
  00065 d8 ca        fmul    ST(0), ST(2)
  00067 dd 1c 24     fstp    QWORD PTR _var$67014[esp+8]
  0006a dd 04 24     fld     QWORD PTR _var$67014[esp+8]
  0006d d8 cf        fmul    ST(0), ST(7)
  0006f dd 1c 24     fstp    QWORD PTR _var$67014[esp+8]
  00072 75 c6        jne     SHORT $LN22@fp_op@3
  00074 dd d8        fstp    ST(0)
  00076 dd dc        fstp    ST(4)
  00078 dd da        fstp    ST(2)
  0007a dd d8        fstp    ST(0)
  0007c dd d8        fstp    ST(0)
  0007e dd d8        fstp    ST(0)
  00080 dd d8        fstp    ST(0)

; 55   : }

  00082 8b e5        mov     esp, ebp
  00084 5d       pop     ebp
  00085 c3       ret     0
??$fp_op@N$1??$mult@N@@YAXACNN@Z@@YAXXZ ENDP        ; fp_op<double,&mult<double> >
; Function compile flags: /Ogtp
_TEXT   ENDS

【讨论】:

    【解决方案2】:

    您似乎是在说,即使在最长的情况下,您也只会运行该内循环 5000 次。我上次检查的 FPU(诚然很久以前)只用了大约 5 个周期来执行乘法运算,而不是整数单元。因此,通过使用整数,您将节省大约 25,000 个 CPU 周期。这是假设没有缓存未命中或任何其他会导致 CPU 在任一事件中等待的情况。

    假设现代 Intel Core CPU 的时钟频率在 2.5Ghz 附近,使用整数单位可以节省大约 10 微秒的运行时间。有点微不足道。我以实时编程为生,即使我们在某个地方错过了最后期限,我们也不会在这里浪费那么多 CPU。

    digEmAll 在 cmets 中提出了一个非常好的观点。如果编译器和优化器在做他们的工作,那么整个事情都是流水线的。这意味着实际上整个内部循环将花费 5 个周期来运行 FPU,而不是整数单元,而不是其中的每个操作。如果是这种情况,您预计节省的时间会非常少,很难衡量。

    如果您确实做了足够多的浮点运算以使整个 shebang 花费很长时间,我建议您考虑执行以下一项或多项操作:

    1. 并行化您的算法并在您的处理器可用的每个 CPU 上运行它。
    2. 不要在 CLR 上运行它(使用原生 C++、Ada 或 Fortran 等)。
    3. 重写它以在 GPU 上运行。 GPU 本质上是数组处理器,旨在对浮点值数组进行大规模并行数学运算。

    【讨论】:

    • 好吧,我简化了一点代码。外循环在整个图像上运行(例如 500000 次迭代)。然后对于每个像素,内部循环(这里不超过 25-27 次迭代,取决于过滤器大小)计算新的像素值。我认为你们是对的。
    • 是的,我真的需要增强代码,因为我需要它在很短的时间内构建一个比例空间(一组图像随着高斯增加而平滑)。似乎并行化或者是唯一(易于实现)可行的选择
    • 必须在CLR上运行吗?对我来说,这似乎是三者中最简单的。
    • @T.E.D.:.NET 代码在运行时编译为本机代码,大多数操作的运行速度或多或少与直接转为本机代码的速度相同。也有例外,例如数组索引,因为它是一个执行边界检查的函数调用,所以速度较慢。出于这个原因,我建议使用指针而不是数组索引,因为根据我的测试,我认为这实际上是他循环中最慢的部分。
    • 可能避免使用 CLR 会加快速度,但这远不能保证。换句话说,尝试一下,但同时衡量两种方式。
    【解决方案3】:

    您的算法似乎以非常不连续的模式访问大块内存区域。它可能会产生大量的缓存未命中。瓶颈可能是内存访问,而不是算术。使用整数应该会稍微快一点,因为整数是 32 位,而双精度数是 64 位,这意味着缓存的使用效率会更高一些。但是,如果几乎每个循环迭代都涉及缓存未命中,那么除非您可以对算法或数据结构布局进行一些更改以提高引用的局部性,否则您基本上就不走运了。

    顺便说一句,您是否考虑过使用 FFT 进行卷积?那会让你进入一个完全不同的大 O 班。

    【讨论】:

    • 你能解释一下为什么你认为我有很多缓存未命中吗?我正在使用迭代相邻存储单元的线性“索引”。此外,“filterOffstetsX”包含相对于同一行上的像素的小偏移量,并且最大距离为过滤器大小 / 2。
    • 我认为 FFT 不能很好地解决我的问题(构建增量卷积图像的金字塔)。我没有一个大卷积,而是一个带有中小型过滤器的卷积迭代。如果我想知道如何以增量方式应用 FFT,那么它可能很有用..
    • 缓存未命中问题可能存在于可分离卷积的 Y 过滤部分。但这似乎对性能影响不大(我的 X 和 Y 过滤时间相似)
    【解决方案4】:

    至少在 32 位系统上比较 int(DWORD,4 字节)和 double(QWORD,8 字节)是不公平的。比较 intfloatlongdouble 以获得公平的结果。 double 提高了精度,你必须为此付费。

    PS:对我来说,它闻起来像微(+过早)优化,而且味道不好。

    编辑:好的,很好。比较 long 和 double 是不正确的,但是在 32 CPU 上比较 int 和 double 仍然是不正确的,即使它们都有内在指令。这不是魔术,x86 是胖 CISC,仍然 double 不会在内部作为单步处理。

    【讨论】:

    • 代码经过优化,运行良好。然而,原始版本只接受双图像。由于 32 位整数运算,我希望引入整数。
    • 比较 long 和 double 也不公平 - IIRC,x86 CPU 具有双精度浮点数的算术指令,但不适用于 64 位整数。
    • @nikie 是的,但是在 32 CPU 上比较 int 和 double 仍然是不正确的,即使它们都有内在指令。这不是魔术,x86 是胖 CISC,仍然 double 不会在内部作为单步处理。
    • 实际上,在某些情况下比较 4 字节 int 操作和 8 字节浮点操作是公平的。 C 编译器可以将浮点数转换为双精度数以进行算术运算,然后再返回浮点数。浮点硬件通常能够原生处理 8 字节浮点数。
    【解决方案5】:

    在我的机器上,我发现浮点乘法与整数乘法的速度差不多。

    我正在使用这个计时功能:

    static void Time<T>(int count, string desc, Func<T> action){
        action();
    
        Stopwatch sw = Stopwatch.StartNew();
        for(int i = 0; i < count; i++)
            action();
    
        double seconds = sw.Elapsed.TotalSeconds;
    
        Console.WriteLine("{0} took {1} seconds", desc, seconds);
    }
    

    假设您使用 25 长度的过滤器处理 200 x 200 数组 200 次,然后您的内部循环执行 200 * 200 * 25 * 200 = 200,000,000 次。每次,您都在执行一个乘法、一个加法和 3 数组索引。所以我使用这个分析代码

    const int count = 200000000;
    
    int[]  a = {1};
    double d = 5;
    int    i = 5;
    
    Time(count, "array index", ()=>a[0]);
    Time(count, "double mult", ()=>d * 6);
    Time(count, "double add ", ()=>d + 6);
    Time(count, "int    mult", ()=>i * 6);
    Time(count, "int    add ", ()=>i + 6);
    

    在我的机器上(我认为比你的慢),我得到以下结果:

    数组索引耗时 1.4076632 秒 双倍乘用 1.2203911 秒 双加耗时 1.2342998 秒 int mult 耗时 1.2170384 秒 int add 耗时 1.0945793 秒

    如您所见,整数乘法、浮点乘法和浮点加法都花费了大约相同的时间。数组索引需要更长的时间(你做了三遍),并且整数加法更快。

    因此,我认为在您的场景中整数数学的性能优势太小了,无法产生显着差异,尤其是当您为数组索引支付的相对巨大的代价超过时。如果你真的需要加快速度,那么你应该使用指向数组的不安全指针来避免偏移计算和边界检查。

    顺便说一句,除法的性能差异要显着得多。按照上面的模式,我得到:

    双 div 耗时 3.8597251 秒 int div 耗时 1.7824505 秒

    还有一点:

    为了清楚起见,所有分析都应该使用优化的发布版本来完成。调试构建总体上会较慢,并且某些操作相对于其他操作可能没有准确的时间。

    【讨论】:

    • 使用float_int64 尝试相同的算法,这样您就不会因为移动的数据量太大而出现时间差异。
    • @T.E.D.:我认为没有必要。一方面,我并没有真正移动数据,因为我在同一个数据上重复执行操作,我应该一直呆在 L1 缓存中。所以我几乎只为操作本身计时。对于两个,我已经表明浮点乘法与整数乘法的速度大致相同。实在没必要​​再去证明了。顺便说一句,C# 中没有 _int64 类型(或任何其他语言,就此而言。非标准的 MSVC 扩展名是 __int64,带有两个下划线)。在 C# 中,long 是 64 位的。
    • 啊,我明白了。我只是在查看分区数字,并认为 2x 可能与数据量翻倍有关,但我现在从上面的数字中看到情况并非如此。我确实记得在我上学的时候(读到关于品牌的新“奔腾”芯片)浮点除法比其他任何东西都要多得多的周期。这就是为什么很多人试图将所有 FP 数学重新调整为乘法(通过在紧密循环之外对一个参数进行交互)。
    • 假设 2 GHz 处理器,您的时间感觉太高了 10-12 倍,因为现代台式机 CPU 没有一个 32 位整数加法需要多个周期。您是否检查了生成的机器代码并确保 lambda 已内联?事实上,由于 lambda 可证明没有副作用,因此您尝试测试的所有操作可能都已优化,而您所测量的只是委托调用(基本上,通过本机级别的函数指针进行的调用) .
    • @Ben Voigt:你的假设毫无价值:我没有使用 2 GHz CPU。 你的逻辑有问题:结果永远不会是count * number_of_CPU_cycles_for_ADD_instruction——至少你还必须考虑构成循环本身的指令,我没有扣除的时间。 而且您没有注意: 确切的时间无关紧要——重点是比较 OP 问题的不同候选操作的相对时间。我希望将来不要因为毫无价值的假设、错误的逻辑和不恰当的焦点而被否决。
    【解决方案6】:

    如果您测量的时间是准确的,那么您的过滤算法的运行时间似乎会随着过滤器大小的立方增长。那是什么样的过滤器?也许您可以减少所需的乘法次数。 (例如,如果您使用的是可分离的过滤器内核?)

    否则,如果您需要原始性能,则可以考虑使用类似 Intel Performance Primitives 之类的库 - 它包含针对此类使用 CPU SIMD 指令的高度优化的函数。它们通常比 C# 或 C++ 中的手写代码快得多。

    【讨论】:

    • 我正在使用一个大小增加的高斯可分离内核(我正在计算一个尺度空间,这是一个非常耗时的过程)。 IPP 在 .NET 中工作吗?
    • @Marco:对图像应用大小为 k 的高斯滤波器应该是 O(k) 操作,但您发布的时间看起来不像 O(k)。也许你应该检查你的算法。也就是说,是的,您可以使用 P/Invoke 从 C# 中使用 IPP。或者您可以为 OpenCV 使用 .NET 包装器(如果可用,OpenCV 会自动使用 IPP)
    • 好吧,我发布的代码是一维过滤器,但这只是原始二维过滤器的一半。该过滤器沿 2 个维度应用,因此总复杂度应为 2*kmn(m*m = 图像大小)
    【解决方案7】:

    您是否尝试查看反汇编代码?在高级语言中,我非常相信编译器会优化我的代码。 例如for(i=0;i&lt;imageSize;i++) 可能比foreach 快。 此外,算术运算可能会被编译器优化......当您需要优化某些东西时,您可以优化整个“黑盒”并可能重新发明该循环中使用的算法,或者您首先查看反汇编的代码,看看有什么问题

    【讨论】: