2016-11-07 14 views
13

Zauważyłem bardzo znaczący (~ 15x) spadek wydajności podczas używania Math.Round do konwersji podwójnej na int podczas kierowania na x64 w porównaniu do x86. Przetestowałem go na 64-bitowym systemie Windows na Core i7 3770K. Czy ktoś może go odtworzyć? Czy jest jakiś dobry powód, dlaczego tak się dzieje? Może jakieś dziwne warunki brzegowe?Znaczący spadek wydajności Math.Round na platformie x64

Dla porównania, porównałem Math.Round (Test1) z 2 przybliżeniami: podpowiedź warunkowa (Test2) i sztuczka 6755399441055744 (Test3).

czasy przebiegu są:

--------------------------- 
|  | x86 | x64 | 
|-------+--------+--------| 
| Test1 | 0,0662 | 0,9975 | 
| Test2 | 0,1517 | 0,1513 | 
| Test3 | 0,1966 | 0,0978 | 
--------------------------- 

Oto kod odniesienia:

using System; 
using System.Diagnostics; 
using System.Runtime.InteropServices; 
namespace MathRoundTester 
{ 
    class Program 
    { 
     private const int IterationCount = 1000000; 

     private static int dummy; 
     static void Main(string[] args) 
     { 
      var data = new double[100]; 
      var rand = new Random(0); 
      for (int i = 0; i < data.Length; ++i) 
      { 
       data[i] = rand.NextDouble() * int.MaxValue * 2 + 
        int.MinValue + rand.NextDouble(); 
      } 

      dummy ^= Test1(data); 
      dummy ^= Test2(data); 
      dummy ^= Test3(data); 
      RecordTime(data, Test1); 
      RecordTime(data, Test2); 
      RecordTime(data, Test3); 
      Console.WriteLine(dummy); 
      Console.Read(); 
     } 
     private static void RecordTime(double[] data, Func<double[], int> action) 
     { 
      GC.Collect(); 
      GC.WaitForPendingFinalizers(); 
      GC.Collect(); 

      var sw = Stopwatch.StartNew(); 
      dummy ^= action(data); 
      sw.Stop(); 
      Console.WriteLine((sw.ElapsedTicks/(double)Stopwatch.Frequency).ToString("F4")); 
     } 
     private static int Test1(double[] data) 
     { 
      int d = 0; 
      for (int i = 0; i < IterationCount; ++i) 
      { 
       for (int j = 0; j < data.Length; ++j) 
       { 
        var x = data[j]; 
        d ^= (int)Math.Round(x); 
       } 
      } 
      return d; 
     } 
     private static int Test2(double[] data) 
     { 
      int d = 0; 
      for (int i = 0; i < IterationCount; ++i) 
      { 
       for (int j = 0; j < data.Length; ++j) 
       { 
        var x = data[j]; 
        d ^= x > 0 ? (int)(x + 0.5) : (int)(x - 0.5); 
       } 
      } 
      return d; 
     } 
     [StructLayout(LayoutKind.Explicit)] 
     private struct DoubleIntUnion 
     { 
      public DoubleIntUnion(double a) 
      { 
       Int = 0; 
       Double = a; 
      } 
      [FieldOffset(0)] 
      public double Double; 
      [FieldOffset(0)] 
      public int Int; 
     } 
     private static int Test3(double[] data) 
     { 
      int d = 0; 
      for (int i = 0; i < IterationCount; ++i) 
      { 
       for (int j = 0; j < data.Length; ++j) 
       { 
        var x = data[j]; 
        d ^= new DoubleIntUnion(x + 6755399441055744.0).Int; 
       } 
      } 
      return d; 
     } 
    } 
} 

Aktualizacja 23.11.2016:

Jakiś czas po AndreyAkinshin uprzejmie napisali question na dotnet/repozytorium coreclr zostało dodane do etapu 1.2.0. Wydaje się więc, że ten problem to tylko niedopatrzenie i zostanie naprawiony.

+5

matematyki zmiennoprzecinkowej odbywa * bardzo * inaczej, gdy cel 64. W trybie 32-bitowym jitter wykorzystuje dotychczasową jednostkę FPU. W trybie 64-bitowym można mieć pewność, że procesor obsługuje SSE2. To nie jest kompletny zamiennik FPU. Jitter x86 może polegać na instrukcji FISTP, przez co Math.Round() jest * wewnętrzną *. Innymi słowy, nie jest to wywołanie metody, ale tylko jedna instrukcja procesora. Brak szczęścia dla jittera x64, widzisz narzut związany z koniecznością wywołania CLR do funkcji pomocnika. –

+1

Możesz spowolnić 32-bitową wersję z MidpointRounding.AwayFromZero. Teraz samoistnie już nie działa. Wersja 64-bitowa jest szybsza, typowy wynik SSE2. Cóż, mówi, dlaczego korzystne ramy zaokrąglania wonky bankiera jako domyślny tryb :) –

+0

zaokrąglania Wystarczy, aby potwierdzić - na mój rdzeń i7-4700HQ jest jeszcze gorzej - 0,0723 (32bit) vs 1,1548 (64bit) –

Odpowiedz

9

Spójrzmy na asmę (int) Math.Round(data[j]).

LegacyJIT-x86:

01172EB0 fld   qword ptr [eax+edi*8+8] 
01172EB4 fistp  dword ptr [ebp-14h] 

RyuJIT-x64:

`d7350617 c4e17b1044d010 vmovsd xmm0,qword ptr [rax+rdx*8+10h] 
`d735061e e83dce605f  call clr!COMDouble::Round (`3695d460) 
`d7350623 c4e17b2ce8  vcvttsd2si ebp,xmm0 

Źródło clr!COMDouble::Round:

clr!COMDouble::Round: 
`3695d460 4883ec58  sub  rsp,58h 
`3695d464 0f29742440  movaps xmmword ptr [rsp+40h],xmm6 
`3695d469 0f57c9   xorps xmm1,xmm1 
`3695d46c f2480f2cc0  cvttsd2si rax,xmm0 
`3695d471 0f297c2430  movaps xmmword ptr [rsp+30h],xmm7 
`3695d476 0f28f0   movaps xmm6,xmm0 
`3695d479 440f29442420 movaps xmmword ptr [rsp+20h],xmm8 
`3695d47f f2480f2ac8  cvtsi2sd xmm1,rax 
`3695d484 660f2ec1  ucomisd xmm0,xmm1 
`3695d488 7a17   jp  clr!COMDouble::Round+0x41 (`3695d4a1) 
`3695d48a 7515   jne  clr!COMDouble::Round+0x41 (`3695d4a1) 
`3695d48c 0f28742440  movaps xmm6,xmmword ptr [rsp+40h] 
`3695d491 0f287c2430  movaps xmm7,xmmword ptr [rsp+30h] 
`3695d496 440f28442420 movaps xmm8,xmmword ptr [rsp+20h] 
`3695d49c 4883c458  add  rsp,58h 
`3695d4a0 c3    ret 
`3695d4a1 440f28c0  movaps xmm8,xmm0 
`3695d4a5 f2440f5805c23a7100 
      addsd xmm8,mmword ptr [clr!_real (`37070f70)] ds:`37070f70=3fe0000000000000 
`3695d4ae 410f28c0  movaps xmm0,xmm8 
`3695d4b2 e821000000  call clr!floor (`3695d4d8) 
`3695d4b7 66410f2ec0  ucomisd xmm0,xmm8 
`3695d4bc 0f28f8   movaps xmm7,xmm0 
`3695d4bf 7a06   jp  clr!COMDouble::Round+0x67 (`3695d4c7) 
`3695d4c1 0f8465af3c00 je  clr! ?? ::FNODOBFM::`string'+0xdd8c4 (`36d2842c) 
`3695d4c7 0f28ce   movaps xmm1,xmm6 
`3695d4ca 0f28c7   movaps xmm0,xmm7 
`3695d4cd ff1505067000 call qword ptr [clr!_imp__copysign (`3705dad8)] 
`3695d4d3 ebb7   jmp  clr!COMDouble::Round+0x2c (`3695d48c) 

Jak widać, LegacyJIT-x86 wykorzystuje niezwykle szybko fld - para fistp; według Instruction tables by Agner Fog, mamy następujące numery Haswell:

Instruction | Latency | Reciprocal throughput 
------------|---------|---------------------- 
FLD m32/64 | 3  | 0.5 
FIST(P) m | 7  | 1 

RyuJIT-64 wywołuje bezpośrednio clr!COMDouble::Round (LegacyJIT-x64 to samo). Kod źródłowy tej metody można znaleźć w repozytorium dotnet/coreclr. Jeśli pracujesz z release-1.0.0, trzeba floatnative.cpp:

#if defined(_TARGET_X86_) 
__declspec(naked) 
double __fastcall COMDouble::Round(double d) 
{ 
    LIMITED_METHOD_CONTRACT; 

    __asm { 
     fld QWORD PTR [ESP+4] 
     frndint 
     ret 8 
    } 
} 

#else // !defined(_TARGET_X86_) 
FCIMPL1_V(double, COMDouble::Round, double d) 
    FCALL_CONTRACT; 

    double tempVal; 
    double flrTempVal; 
    // If the number has no fractional part do nothing 
    // This shortcut is necessary to workaround precision loss in borderline cases on some platforms 
    if (d == (double)(__int64)d) 
     return d; 
    tempVal = (d+0.5); 
    //We had a number that was equally close to 2 integers. 
    //We need to return the even one. 
    flrTempVal = floor(tempVal); 
    if (flrTempVal==tempVal) { 
     if (0 != fmod(tempVal, 2.0)) { 
      flrTempVal -= 1.0; 
     } 
    } 
    flrTempVal = _copysign(flrTempVal, d); 
    return flrTempVal; 
FCIMPLEND 
#endif // defined(_TARGET_X86_) 

Jeśli pracujesz z gałęzi głównej, można znaleźć podobny kod w floatdouble.cpp.

FCIMPL1_V(double, COMDouble::Round, double x) 
    FCALL_CONTRACT; 

    // If the number has no fractional part do nothing 
    // This shortcut is necessary to workaround precision loss in borderline cases on some platforms 
    if (x == (double)((INT64)x)) { 
     return x; 
    } 

    // We had a number that was equally close to 2 integers. 
    // We need to return the even one. 

    double tempVal = (x + 0.5); 
    double flrTempVal = floor(tempVal); 

    if ((flrTempVal == tempVal) && (fmod(tempVal, 2.0) != 0)) { 
     flrTempVal -= 1.0; 
    } 

    return _copysign(flrTempVal, x); 
FCIMPLEND 

Wygląda na to, że pełny .NET Framework używa tej samej logiki.

Tak więc, (int)Math.Round działa naprawdę znacznie szybciej na x86 niż na x64 z powodu różnicy w wewnętrznych implementacjach różnych kompilatorów JIT. Pamiętaj, że to zachowanie można zmienić w przyszłości.

Nawiasem mówiąc, można napisać mały i niezawodny punkt odniesienia przy pomocy BenchmarkDotNet:

[LegacyJitX86Job, LegacyJitX64Job, RyuJitX64Job] 
public class MathRoundBenchmarks 
{ 
    private const int N = 100; 
    private double[] data; 

    [Setup] 
    public void Setup() 
    { 
     var rand = new Random(0); 
     data = new double[N]; 
     for (int i = 0; i < data.Length; ++i) 
     { 
      data[i] = rand.NextDouble() * int.MaxValue * 2 + 
         int.MinValue + rand.NextDouble(); 
     } 
    } 

    [Benchmark(OperationsPerInvoke = N)] 
    public int MathRound() 
    { 
     int d = 0; 
     for (int i = 0; i < data.Length; ++i) 
      d ^= (int) Math.Round(data[i]); 
     return d; 
    } 
} 

Wyniki:

BenchmarkDotNet.Core=v0.9.9.0 
OS=Microsoft Windows NT 6.2.9200.0 
Processor=Intel(R) Core(TM) i7-4702MQ CPU 2.20GHz, ProcessorCount=8 
Frequency=2143475 ticks, Resolution=466.5321 ns, Timer=TSC 
CLR=MS.NET 4.0.30319.42000, Arch=64-bit RELEASE [RyuJIT] 
GC=Concurrent Workstation 
JitModules=clrjit-v4.6.1586.0 

Type=MathRoundBenchmarks Mode=Throughput 

    Method | Platform |  Jit |  Median | StdDev | 
---------- |--------- |---------- |----------- |---------- | 
MathRound |  X64 | LegacyJit | 12.8640 ns | 0.2796 ns | 
MathRound |  X64 | RyuJit | 13.4390 ns | 0.4365 ns | 
MathRound |  X86 | LegacyJit | 1.0278 ns | 0.0373 ns | 
+0

Naprawdę doceniam tak dokładną odpowiedź. Wspomniałeś "że to zachowanie może zostać zmienione w przyszłości", ale czy naprawdę? Ta cała metoda zasadniczo emuluje bankowców zaokrąglających przed użyciem konwersji na liczbę całkowitą z obcięciem (cvttsd2si). Musi istnieć bardzo dobry powód, aby nie używać po prostu cvtsd2si (konwersja z zaokrąglaniem) w trybie RN - od rundy do najbliższej (parzysta). I po prostu ciągle mnie żywcem żywym, nie znając tego powodu! Nie mogłem (jeszcze) znaleźć niczego na temat niespójnego zachowania między cvtsd2si i fistp. – user98418468459

+0

@ user98418468459, wysłałem pytanie w dotnet/coreclr repo: https://github.com/dotnet/coreclr/issues/8053 – AndreyAkinshin

+0

Do tej pory tylko znalazłem następującą na fistp/cvtsd2si problem: [LINK] (https : //software.intel.com/en-us/articles/fast-floating-point-to-integer-conversions) (patrz Rozważania precyzyjne). Ale musimy zacząć od 80-bitowego numeru zmiennoprzecinkowego na stosie x87. Wydaje się, że nie ma to zastosowania, jeśli po prostu ładujemy go za każdym razem z 64-bitowej porcji pamięci. – user98418468459

0

nie odpowiedzi jako takie, ale niektóre kod, który inni mogą znaleźć przydatne w krytycznych obszarach wydajności w systemach x64 w zależności od dokładnych wymagań dotyczących zaokrąglania.

razy wydajności w ms 100000000 operacji są:

Round(x):   1112 
Round(x,y):   2183 
FastMath.Round(x): 155 
FastMath.Round(x,y): 519 

Kod:

public static class FastMath 
{ 
    private static readonly double[] RoundLookup = CreateRoundLookup(); 

    private static double[] CreateRoundLookup() 
    { 
     double[] result = new double[15]; 
     for (int i = 0; i < result.Length; i++) 
     { 
      result[i] = Math.Pow(10, i); 
     } 

     return result; 
    } 

    public static double Round(double value) 
    { 
     return Math.Floor(value + 0.5); 
    } 

    public static double Round(double value, int decimalPlaces) 
    { 
     double adjustment = RoundLookup[decimalPlaces]; 
     return Math.Floor(value * adjustment + 0.5)/adjustment; 
    } 
} 
Powiązane problemy