Почему в этом конкретном случае структуры намного быстрее, чем классы?

У меня есть три случая для проверки относительной производительности классов, классов с наследованием и структур. Они должны использоваться для плотных циклов, поэтому показатели производительности. Точечные продукты используются как часть многих алгоритмов в 2D и 3D-геометрии, и я запускаю профилировщик на реальном коде. Нижеприведенные тесты свидетельствуют о реальных проблемах производительности, которые я видел.

Результаты за 100000000 раз через цикл и применение точечного продукта дают

ControlA 208 ms   ( class with inheritence )
ControlB 201 ms   ( class with no inheritence )
ControlC 85  ms   ( struct )

Тесты выполнялись без отладки и оптимизации. Мой вопрос: что это за классы в этом случае, которые заставляют их быть настолько медленными?

Я предположил, что JIT все равно сможет встраивать все вызовы, класс или структуру, поэтому результаты должны быть идентичными. Обратите внимание, что если я отключу оптимизацию, то мои результаты будут идентичными.

ControlA 3239
ControlB 3228
ControlC 3213

Они всегда находятся в пределах 20 мс друг от друга, если тест повторно запускается.

Изучаемые классы

using System;
using System.Diagnostics;

public class PointControlA
{
    public double X
    {
        get;
        set;
    }

    public double Y
    {
        get;
        set;
    }

    public PointControlA(double x, double y)
    {
        X = x;
        Y = y;
    }
}

public class Point3ControlA : PointControlA
{
    public double Z
    {
        get;
        set;
    }

    public Point3ControlA(double x, double y, double z): base (x, y)
    {
        Z = z;
    }

    public static double Dot(Point3ControlA a, Point3ControlA b)
    {
        return a.X * b.X + a.Y * b.Y + a.Z * b.Z;
    }
}

public class Point3ControlB
{
    public double X
    {
        get;
        set;
    }

    public double Y
    {
        get;
        set;
    }

    public double Z
    {
        get;
        set;
    }

    public Point3ControlB(double x, double y, double z)
    {
        X = x;
        Y = y;
        Z = z;
    }

    public static double Dot(Point3ControlB a, Point3ControlB b)
    {
        return a.X * b.X + a.Y * b.Y + a.Z * b.Z;
    }
}

public struct Point3ControlC
{
    public double X
    {
        get;
        set;
    }

    public double Y
    {
        get;
        set;
    }

    public double Z
    {
        get;
        set;
    }

    public Point3ControlC(double x, double y, double z):this()
    {
        X = x;
        Y = y;
        Z = z;
    }

    public static double Dot(Point3ControlC a, Point3ControlC b)
    {
        return a.X * b.X + a.Y * b.Y + a.Z * b.Z;
    }
}

Тест Script

public class Program
{
    public static void TestStructClass()
    {
        var vControlA = new Point3ControlA(11, 12, 13);
        var vControlB = new Point3ControlB(11, 12, 13);
        var vControlC = new Point3ControlC(11, 12, 13);
        var sw = Stopwatch.StartNew();
        var n = 10000000;
        double acc = 0;
        sw = Stopwatch.StartNew();
        for (int i = 0; i < n; i++)
        {
            acc += Point3ControlA.Dot(vControlA, vControlA);
        }

        Console.WriteLine("ControlA " + sw.ElapsedMilliseconds);
        acc = 0;
        sw = Stopwatch.StartNew();
        for (int i = 0; i < n; i++)
        {
            acc += Point3ControlB.Dot(vControlB, vControlB);
        }

        Console.WriteLine("ControlB " + sw.ElapsedMilliseconds);
        acc = 0;
        sw = Stopwatch.StartNew();
        for (int i = 0; i < n; i++)
        {
            acc += Point3ControlC.Dot(vControlC, vControlC);
        }

        Console.WriteLine("ControlC " + sw.ElapsedMilliseconds);
    }

    public static void Main()
    {
        TestStructClass();
    }
}

Эта dotnet скрипка является доказательством компиляции. Он не показывает различий в производительности.

Я пытаюсь объяснить поставщику, почему их выбор использовать классы вместо структур для небольших числовых типов - это идея bad. У меня теперь есть тестовый пример, чтобы доказать это, но я не могу понять, почему.

ПРИМЕЧАНИЕ. Я попытался установить контрольную точку в отладчике с включенной оптимизацией JIT, но отладчик не сломается. Глядя на IL с оптимизацией JIT, я ничего не говорю.

ИЗМЕНИТЬ

После ответа @pkuderov я взял его код и сыграл с ним. Я изменил код и обнаружил, что, если я принудительно вставляю через

   [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static double Dot(Point3Class a)
    {
        return a.X * a.X + a.Y * a.Y + a.Z * a.Z;
    }

разница между структурой и классом для точечного произведения исчезла. Почему с некоторыми настройками атрибут не нужен, но для меня это было неясно. Однако я не сдавался. По-прежнему существует проблема производительности с кодом поставщика, и я думаю, что DotProduct не лучший пример.

Я изменил код @pkuderov для реализации Vector Add, который создаст новые экземпляры структур и классов. Результаты здесь

https://gist.github.com/bradphelan/9b383c8e99edc38068fcc0dccc8a7b48

В этом примере я также модифицировал код, чтобы выбрать псевдослучайный вектор из массива, чтобы избежать проблемы с экземплярами, хранящимися в регистрах (надеюсь).

Результаты показывают, что:

Производительность DotProduct идентична или, возможно, быстрее для классов
Vector Add, и я предполагаю, что все, что создает новый объект, медленнее.

Добавить класс/класс 2777ms Добавить struct/struct 2457ms

Класс/класс DotProd 1909ms DotProd struct/struct 2108ms

Полный код и результаты здесь, если кто-то хочет попробовать его.

Изменить снова

Для вектора add example, где массив векторов суммируется вместе, версия struct сохраняет аккумулятор в 3 регистрах

 var accStruct = new Point3Struct(0, 0, 0);
 for (int i = 0; i < n; i++)
     accStruct = Point3Struct.Add(accStruct, pointStruct[(i + 1) % m]);

тело asm

// load the next vector into a register
00007FFA3CA2240E  vmovsd      xmm3,qword ptr [rax]  
00007FFA3CA22413  vmovsd      xmm4,qword ptr [rax+8]  
00007FFA3CA22419  vmovsd      xmm5,qword ptr [rax+10h]  
// Sum the accumulator (the accumulator stays in the registers )
00007FFA3CA2241F  vaddsd      xmm0,xmm0,xmm3  
00007FFA3CA22424  vaddsd      xmm1,xmm1,xmm4  
00007FFA3CA22429  vaddsd      xmm2,xmm2,xmm5  

но для векторной версии на основе класса он каждый раз считывает и записывает аккумулятор в основную память, которая неэффективна

var accPC = new Point3Class(0, 0, 0);
for (int i = 0; i < n; i++)
    accPC = Point3Class.Add(accPC, pointClass[(i + 1) % m]);

тело asm

// Read and add both accumulator X and Xnext from main memory
00007FFA3CA2224A  vmovsd      xmm0,qword ptr [r14+8]     
00007FFA3CA22250  vmovaps     xmm7,xmm0                   
00007FFA3CA22255  vaddsd      xmm7,xmm7,mmword ptr [r12+8]  


// Read and add both accumulator Y and Ynext from main memory
00007FFA3CA2225C  vmovsd      xmm0,qword ptr [r14+10h]  
00007FFA3CA22262  vmovaps     xmm8,xmm0  
00007FFA3CA22267  vaddsd      xmm8,xmm8,mmword ptr [r12+10h] 

// Read and add both accumulator Z and Znext from main memory
00007FFA3CA2226E  vmovsd      xmm9,qword ptr [r14+18h]  
00007FFA3CA22283  vmovaps     xmm0,xmm9  
00007FFA3CA22288  vaddsd      xmm0,xmm0,mmword ptr [r12+18h]

// Move accumulator accumulator X,Y,Z back to main memory.
00007FFA3CA2228F  vmovsd      qword ptr [rax+8],xmm7  
00007FFA3CA22295  vmovsd      qword ptr [rax+10h],xmm8  
00007FFA3CA2229B  vmovsd      qword ptr [rax+18h],xmm0  

Ответ 1

Обновить

Проведя некоторое время, думая о проблеме, я думаю, что я aggree с @DavidHaim, что перерасход памяти не является причиной здесь из-за кэширования.

Также я добавил к вашим тестам больше параметров (и удалил первый с наследованием). Поэтому у меня есть:

  • cl = переменная класса с тремя точками:
    • Dot(cl, cl) - начальный метод
    • Dot(cl) - который является "квадратным произведением"
    • Dot(cl.X, cl.Y, cl.Z, cl.X, cl.Y, cl.Z) aka Dot (cl.xyz) - поля пропуска
  • st = переменная структуры с тремя точками:
    • Dot(st, st) - начальный
    • Dot(st) - квадратный продукт
    • Dot(st.X, st.Y, st.Z, st.X, st.Y, st.Z) aka Dot (st.xyz) - передать поля
  • st6 = vairable структуры с 6 точками:
    • Dot(st6) - нужно проверить, имеет ли значение размер структуры
  • Dot(x, y, z, x, y, z) aka Dot (xyz) - только локальные const двойные переменные.

Время выполнения:

  • Dot (cl.xyz) является наихудшим ~ 570 мс,
  • Dot (st6), Dot (st.xyz) - второй худший ~ 440 мс и ~ 480 мс
  • остальные ~ 325 мс

... И я не уверен, почему я вижу эти результаты.

Может быть, для простых примитивных типов компилятор делает более агрессивный проход по оптимизации регистров, возможно, он более уверен в границах продолжительности жизни или постоянной, а затем более агрессивные оптимизации снова. Может быть, какая-то петля раскручивается.

Я думаю, что моего опыта просто не хватает:) Но все же мои результаты противоречат вашим результатам.

Полный тестовый код с результатами на моей машине и сгенерированным кодом IL вы можете найти здесь.


В классах С# используются ссылочные типы, а structs - типы значений. Одним из основных эффектов является то, что типы значений могут быть (и большую часть времени!) распределены в стеке, а ссылочные типы всегда выделяются в куче.

Итак, каждый раз, когда вы получаете доступ к внутреннему состоянию переменной ссылочного типа, вам нужно разыменовать указатель на память в куче (это своего рода прыжок), тогда как для типов значений это уже в стеке или даже оптимизировано для регистрации.

Я думаю, вы видите разницу из-за этого.

P.S. Кстати, "большую часть времени" я имел в виду бокс; это метод, используемый для размещения объектов типа значений в куче (например, для создания типов значений для интерфейса или для привязки вызовов динамических методов).

Ответ 2

Как я и думал, этот тест не очень много.

TL;DR: компилятор полностью оптимизирует вызов Point3ControlC.Dot, сохраняя вызовы двух других. разница заключается не в том, что в этом случае структуры быстрее, а потому, что вы пропускаете всю часть вычисления.

Мои настройки:

  • Обновление Visual Studio 2015 3
  • .Net framework version 4.6.1
  • Режим выпуска, любой процессор (мой процессор - 64 бит)
  • Windows 10
  • Процессор: Процессор Intel (R) Core (TM) i5-5300U CPU @2.30GHz, 2295 Mhz, 2 Core (s), 4 Логический процессор (ы)

Сгенерированная сборка для

for (int i = 0; i < n; i++)
        {
            acc += Point3ControlA.Dot(vControlA, vControlA);
        }

является:

00DC0573  xor         edx,edx  // temp = 0
00DC0575  mov         dword ptr [ebp-10h],edx // i = temp  
00DC0578  mov         ecx,edi  // load vControlA as first parameter
00DC057A  mov         edx,edi  //load vControlA as second parameter
00DC057C  call        dword ptr ds:[0BA4F0Ch] //call Point3ControlA.Dot
00DC0582  fstp        st(0)  //store the result
00DC0584  inc         dword ptr [ebp-10h]  //i++
00DC0587  cmp         dword ptr [ebp-10h],989680h //does i == n?  
00DC058E  jl          00DC0578  //if not, jump to the begining of the loop

После мыслей:
Компилятор JIT по какой-то причине не использовал регистр для i, поэтому он увеличивал целое число в стеке (ebp-10h). как результат, этот тест имеет самую низкую производительность.

Переход ко второму тесту:

for (int i = 0; i < n; i++)
        {
            acc += Point3ControlC.Dot(vControlC, vControlC);
        }

Сгенерированная сборка:

00DC0612  xor         edi,edi  //i = 0
00DC0614  mov         ecx,esi  //load vControlB as the first argument
00DC0616  mov         edx,esi  //load vControlB as the second argument
00DC0618  call        dword ptr ds:[0BA4FD4h] // call Point3ControlB.Dot
00DC061E  fstp        st(0) //store the result  
00DC0620  inc         edi  //++i
00DC0621  cmp         edi,989680h //does i == n
00DC0627  jl          00DC0614  //if not, jump to the beginning of the loop     

После мыслей: эта сгенерированная сборка почти идентична первой, но на этот раз JIT использовал регистр для i, следовательно, незначительное повышение производительности по сравнению с первым тестом.

Перейдем к рассмотренному тесту:

for (int i = 0; i < n; i++)
        {
            acc += Point3ControlC.Dot(vControlC, vControlC);
        }

И для сгенерированной сборки:

00DC06A7  xor         eax,eax  //i = 0
00DC06A9  inc         eax  //++i
00DC06AA  cmp         eax,989680h //does i == n ?   
00DC06AF  jl          00DC06A9  //if not, jump to the beginning of the loop

Как мы видим, JIT полностью оптимизировал вызов для Point3ControlC.Dot, поэтому на самом деле вы платите только за цикл, а не за сам вызов. поэтому этот "тест" заканчивается первым, так как он не очень много начинал с.

Можем ли мы сказать что-то о структурах или классах только из этого теста? Ну нет. Я все еще не уверен, почему компилятор решил оптимизировать вызов функции struct, сохранив другие вызовы. я уверен, что в реальном коде компилятор не может оптимизировать вызов, если результат используется. в этом мини-контроле мы не очень много делаем с результатом, и даже если бы мы это сделали, компилятор может вычислить результат во время компиляции. поэтому компилятор может быть более агрессивным, чем мог бы быть, чем в реальном коде.