Почему добавление DoubleStructs медленнее, чем добавление double, с гораздо большим отношением, чем в длинном эквиваленте? - PullRequest
3 голосов
/ 14 марта 2019

Гипотеза

A readonly struct, содержащая один примитив, должна быть более или менее быстрой для любой простой операции, чем сам примитив.

Тесты

Все тестыниже работают .NET Core 2.2 на Windows 7 x64, код оптимизирован.Я также получаю аналогичные результаты при тестировании на .NET 4.7.2.

Test: Longs

При тестировании этой предпосылки с типом long кажется, что это верно:

// =============== SETUP ===================

public readonly struct LongStruct
{
    public readonly long Primitive;

    public LongStruct(long value) => Primitive = value;

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static LongStruct Add(in LongStruct lhs, in LongStruct rhs)
        => new LongStruct(lhs.Primitive + rhs.Primitive);
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static long LongAdd(long lhs, long rhs) => lhs + rhs;

// =============== TESTS ===================

public static void TestLong(long a, long b, out long result)
{
    var sw = Stopwatch.StartNew();

    for (var i = 1000000000; i > 0; --i)
    {
        a = LongAdd(a, b);
    }

    sw.Stop();

    result = a;

    return sw.ElapsedMilliseconds;
}

public static void TestLongStruct(LongStruct a, LongStruct b, out LongStruct result)
{
    var sw = Stopwatch.StartNew();

    for (var i = 1000000000; i > 0; --i)
    {
        a = LongStruct.Add(a, b);
    }

    sw.Stop();

    result = a;

    return sw.ElapsedMilliseconds;
}

// ============= TEST LOOP =================

public static void RunTests()
{
    var longStruct = new LongStruct(1);

    var count = 0;
    var longTime = 0L;
    var longStructTime = 0L;

    while (true)
    {
        count++;
        Console.WriteLine("Test #" + count);

        longTime += TestLong(1, 1, out var longResult);
        var longMean = longTime / count;
        Console.WriteLine($"Long: value={longResult}, Mean Time elapsed: {longMean} ms");

        longStructTime += TestLongStruct(longStruct, longStruct, out var longStructResult);
        var longStructMean = longStructTime / count;
        Console.WriteLine($"LongStruct: value={longStructResult.Primitive}, Mean Time elapsed: {longStructMean} ms");

        Console.WriteLine();
    }
}

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

На моей машине,два раза располагаются с точностью до 2% друг от друга, достаточно близко, так что я убежден, что они были оптимизированы под практически одинаковый код.

Разница в IL довольно мала:

  • код цикла тестирования такой же, за исключением того, какой метод вызывается (LongAdd против LongStruct.Add).
  • LongStruct.Add имеет несколько дополнительных инструкций:
    • Пара ldfld инструкций для загрузки Primitive из struct
    • A newobj инструкции для упаковки нового long обратно в LongStruct

Таким образом, джиттер оптимизирует эти инструкции или они в основном бесплатны.

Тест: удваивает

Если я возьму приведенный выше код и заменю каждые long на double, я ожидаю такой же результат (медленнее в абсолютном выражении, поскольку инструкция добавления будет немного медленнее)., но оба с одинаковым запасом).

На самом деле я вижу, что версия DoubleStruct примерно в 4,8 раза (то есть 480%) медленнее, чем версия double.

IL идентичен случаю long (кроме замены int64 и LongStruct на float64 и DoubleStruct), но каким-то образом среда выполнения выполняет дополнительную работу для случая DoubleStruct, который не 't * в случае LongStruct или double.

Тест: другие типы

Тестируя несколько других примитивных типов, я вижу, что float (465%) ведет себяТак же, как double, short и int ведут себя так же, как long, так что, похоже, что-то с плавающей запятой приводит к тому, что некоторая оптимизация не принимается.

Вопрос

Почему DoubleStruct и FloatStruct намного медленнее, чем double и float, где long,int и short эквиваленты не испытывают такого замедления?

Ответы [ 2 ]

3 голосов
/ 14 марта 2019

См. Ответ @ canton7 о некоторых результатах синхронизации и выводе x86 asm, на которых я основывал свои выводы.(У меня нет компилятора Windows или C #).

Аномалии: ассемблер "release" для циклов в SharpLab не соответствует номерам производительности @ canton7 BenchmarkDotNet для любых процессоров Intel или AMD.Asm показывает, что TestDouble действительно делает a+=b внутри цикла, но синхронизация показывает, что он работает так же быстро, как и целочисленный цикл 1 /. (задержка добавления FP составляет 3-5 циклов на всех AMD K8 / K10 /Семейство Bulldozer / Ryzen и Intel P6 через Skylake.)

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

НадеюсьЯ не понимаю, как TestDoubleStructWithIn может быть медленнее, чем целочисленный цикл, но только в два раза медленнее (не в 3 раза), если только цикл long не выполняется с 1 итерацией за такт.При таких высоких показателях накладные расходы при запуске должны быть незначительными.Счетчик циклов, хранящийся в памяти, мог бы объяснить это (наложение узкого места в ~ 6 циклов на каждую итерацию для всего, скрытие задержки всего, кроме очень медленных версий FP.) Но @ canton7 говорит, что они тестировали с помощью сборки Release.Но их i7-8650U могут не поддерживать max-turbo = 4,20 ГГц для всех контуров из-за ограничений мощности / температуры.(минимальная поддерживаемая частота для всех ядер = 1,90 ГГц), поэтому, глядя на время в секундах, а не на циклы, можно было бы отбросить нас к петлям без узкого места?Это по-прежнему не объясняет примитивного двойника с той же скоростью, что и long;они, должно быть, были оптимизированы.


Разумно ожидать, что этот класс будет встроен и оптимизирован так, как вы его используете .Хороший компилятор сделает это.Но JIT должен быстро компилироваться, так что это не всегда хорошо, и ясно, что в этом случае это не для double.

Для целочисленных циклов 64-разрядное целочисленное сложение на x86-64 имеет 1задержка цикла, и современные суперскалярные процессоры имеют достаточную пропускную способность для запуска цикла, содержащего сложение, с той же скоростью, что и в противном случае пустой цикл, который просто отсчитывает счетчик.Таким образом, мы не можем сказать по времени, что компилятор сделал a + b * 1000000000 вне цикла (но все еще выполнил пустой цикл), или что.

@ canton7 использовал SharpLab, чтобы посмотреть на JIT x86-64asm для автономной версии AddDoubleStructs и для цикла, который ее вызывает. standalone and loop, x86-64, режим выпуска .

Мы видим, что для примитива long c = a + b он полностью оптимизировал удаление (но сохранил пустой цикл обратного отсчета)!Если мы используем a = a+b;, мы получаем фактическую add инструкцию, даже если a не возвращается из функции.

loops.AddLongs(Int64, Int64)
    L0000: mov eax, 0x3b9aca00    # i = init
                                  # do {
                                  #   long c = a+b   optimized out
    L0005: dec eax                #   --i;
    L0007: test eax, eax
    L0009: jg L0005               # }while(i>0);

    L000b: ret

Но в версии struct есть действительная инструкция add отa = LongStruct.Add(a, b);.(Мы получаем то же самое с a = a+b; с примитивом long.)

loops.AddLongStructs(LongStruct a, LongStruct b)
    L0000: mov eax, 0x3b9aca00

    L0005: add rdx, r8            # a += b;  other insns are identical
    L0008: dec eax
    L000a: test eax, eax
    L000c: jg L0005

    L000e: ret

Но если мы изменим его на LongStruct.Add(a, b); (нигде не присваивая результат), мы получим L0006: add rdx, r8 внецикл (поднимая a + b), а затем L0009: mov rcx, rdx / L000c: mov [rsp], rcx внутри цикла.(зарегистрируйте копию и затем сохраните ее в абсолютно пустом месте, совершенно безумно.) В C # (в отличие от C / C ++) запись a+b; сама по себе в качестве оператора является ошибкой, поэтому мы не можем увидеть, будет ли примитивный эквивалентпо-прежнему приводить к глупым потерянным инструкциям.Only assignment, call, increment, decrement, await, and new object expressions can be used as a statement.

Я не думаю, что мы можем обвинить любую из этих пропущенных оптимизаций в структуре как таковой . Но даже если вы сравните это с / без add в цикле, это не приведет к реальному замедлению в этом цикле на современном x86. Пустой цикл достигает узкого места пропускной способности цикла 1 / тактового цикла с использованием всего 2 мопов в цикле (dec и слияния макросов test/jg), оставляя место для еще 2 мопов без замедления, если они не создают узкого места. хуже чем 1 / сутки. (https://agner.org/optimize/) например, imul edx, r8d с задержкой в ​​3 цикла замедлит цикл в 3 раза. Пропускная способность фронт-энда "4 мопа" предполагает недавнюю Intel. Семейство Bulldozer уже, Ryzen - 5 -широкий.

Это нестатические функции-члены класса (без причины, но я сразу не заметил, поэтому не изменяю их сейчас). В соглашении о вызовах asm первый аргумент (RCX) является указателем this, а аргументы 2 и 3 являются явными аргументами функции-члена (RDX и R8).

JIT code-gen добавляет test eax,eax после dec eax, который уже устанавливает FLAGS (кроме CF, который мы не тестируем) в соответствии с i - 1. Отправной точкой является положительная постоянная времени компиляции; любой компилятор C оптимизировал бы это до dec eax / jnz. Я думаю, что dec eax / jg также будет работать, проваливаясь, когда dec дает ноль, потому что 1 > 1 ложно.


DoubleStruct против соглашения о вызовах

Соглашение о вызовах, используемое C # на x86-64, передает 8-байтовые структуры в целочисленные регистры , что отстой для структуры, содержащей double (потому что она должна быть возвращена в регистры XMM для vaddsd или другие операции FP). Так что у вашей структуры есть неизбежный недостаток для вызовов не встроенных функций.

### stand-alone versions of functions: not inlined into a loop

# with primitive double, args are passed in XMM regs
standalone.AddDoubles(Double, Double)
    L0000: vzeroupper
    L0003: vmovaps xmm0, xmm1              # stupid missed optimization defeating the purpose of AVX 3-operand instructions
    L0008: vaddsd xmm0, xmm0, xmm2         # vaddsd xmm0,  xmm1, xmm2  would do retval = a + b
    L000d: ret

# without `in`.  Significantly less bad with `in`, see the link.
standalone.AddDoubleStructs(DoubleStruct a, DoubleStruct b)
    L0000: sub rsp, 0x18             # reserve 24 bytes of stack space
    L0004: vzeroupper                # Weird to use this in a function that doesn't have any YMM vectors...

    L0007: mov [rsp+0x28], rdx       # spill args 2 (rdx=double a) and 3 (r8=double b) to the stack.
    L000c: mov [rsp+0x30], r8        # (first arg = rcx = unused this pointer)

    L0011: mov rax, [rsp+0x28]
    L0016: mov [rsp+0x10], rax       # copy a to another place on the stack!

    L001b: mov rax, [rsp+0x30]
    L0020: mov [rsp+0x8], rax        # copy b to another place on the stack!

    L0025: vmovsd xmm0, qword [rsp+0x10]
    L002c: vaddsd xmm0, xmm0, [rsp+0x8]   # add a and b in the SSE/AVX FPU
    L0033: vmovsd [rsp], xmm0             # store the result to yet another stack location

    L0039: mov rax, [rsp]                 # reload it into RAX, the return value
    L003d: add rsp, 0x18
    L0041: ret

Это просто безумие. Этот является code-gen режима выпуска, но компилятор сохраняет структуры в памяти, затем перезагружает + сохраняет их снова перед их фактической загрузкой в ​​FPU. (Я предполагаю, что копия int-> int может быть конструктором, но я понятия не имею. Я обычно смотрю на вывод компилятора C / C ++, который обычно не такой тупой в оптимизированных сборках).

Использование in в функции arg позволяет избежать дополнительной копии каждого ввода во 2-е место в стеке , но все равно переносит их из целого числа в XMM с сохранением / перезагрузкой.

Это то, что делает gcc для int-> xmm с настройкой по умолчанию, но это пропущенная оптимизация. Агнер Фог говорит (в своем руководстве по микроархам), что руководство по оптимизации AMD предлагает сохранять / перезагружать при настройке на Bulldozer, но он обнаружил, что это не быстрее даже на AMD. (Там, где ALU int-> xmm имеет задержку ~ 10 циклов, в отличие от 2–3 циклов в Intel или Ryzen, с пропускной способностью 1 / такт такой же, как в хранилищах.)

Хорошей реализацией этой функции (если мы застряли в соглашении о вызовах) было бы vmovq xmm0, rdx / vmovq xmm1, r8, затем vaddsd, затем vmovq rax, xmm0 / ret.


После встраивания в цикл

Примитив double оптимизируется аналогично long:

  • Примитив: double c = a + b; полностью оптимизирует
  • a = a + b (как и используемый @ canton7) все еще делает не , хотя результат все еще не используется. Это будет узким местом с задержкой vaddsd (от 3 до 5 циклов в зависимости от Bulldozer против Ryzen против Intel до Skylake против Skylake.) Но это остается в регистрах.
loops.AddDoubles(Double, Double)
    L0000: vzeroupper
    L0003: mov eax, 0x3b9aca00
                                        # do {
    L0008: vaddsd xmm1, xmm1, xmm2        # a += b
    L000d: dec eax                        # --i
    L000f: test eax, eax
    L0011: jg L0008                     # }while(i>0);

    L0013: ret

Вставка версии структуры

Все накладные расходы на сохранение / перезагрузку должны исчезнуть после встраивания функции в цикл; это большая часть смысла Что ж, сюрприз, он не оптимизирует . 2x store / reload находится на критическом пути цепочки зависимостей данных (перенос FP) !!! Это огромная пропущенная оптимизация.

Задержка сохранения / перезагрузки на современном Intel составляет около 5 или 6 циклов, медленнее, чем добавление FP. a загружается / сохраняется по пути в XMM0, а затем снова по пути обратно.

loops.AddDoubleStructs(DoubleStruct, DoubleStruct)
    L0000: sub rsp, 0x18
    L0004: vzeroupper
    L0007: mov [rsp+0x28], rdx      # spill function args: a
    L000c: mov [rsp+0x30], r8       # and b
    L0011: mov eax, 0x3b9aca00      # i= init

                                    # do {
    L0016: mov rdx, [rsp+0x28]
    L001b: mov [rsp+0x10], rdx        # tmp_a = copy a to another local
    L0020: mov rdx, [rsp+0x30]
    L0025: mov [rsp+0x8], rdx         # tmp_b = copy b

    L002a: vmovsd xmm0, qword [rsp+0x10] # tmp_a
    L0031: vaddsd xmm0, xmm0, [rsp+0x8]  # + tmp_b
    L0038: vmovsd [rsp], xmm0            # tmp_a = sum

    L003e: mov rdx, [rsp]
    L0042: mov [rsp+0x28], rdx         # a = copy tmp_a

    L0047: dec eax                   #  --i;
    L0049: test eax, eax
    L004b: jg L0016                # }while(i>0)

    L004d: add rsp, 0x18
    L0051: ret

Примитивный цикл double оптимизирует процесс до простого цикла, сохраняя все в регистрах, без умной оптимизации, которая нарушала бы строгую FP. т.е. не превращая его в множитель, или используя несколько аккумуляторов, чтобы скрыть FP, добавляют задержку. (Но из версии long мы знаем, что компилятор не будет делать ничего лучше независимо.) Он делает все добавления в виде одной длинной цепочки зависимостей, поэтому один addsd на 3 (Broadwell или ранее, Ryzen) или 4 цикла (Skylake).

3 голосов
/ 14 марта 2019

Сам по себе это не ответ, но это немного более строгий тест, как для x86, так и для x64, так что, надеюсь, он предоставит больше информации кому-то еще, кто сможет это объяснить.

Я попытался повторить это с BenchmarkDotNet.Я также хотел посмотреть, какую разницу сделает удаление in.Я запускал его отдельно как x86 и x64.

x86 (LegacyJIT)

|                 Method |     Mean |    Error |   StdDev |
|----------------------- |---------:|---------:|---------:|
|               TestLong | 257.9 ms | 2.099 ms | 1.964 ms |
|         TestLongStruct | 529.3 ms | 4.977 ms | 4.412 ms |
|   TestLongStructWithIn | 526.2 ms | 6.722 ms | 6.288 ms |
|             TestDouble | 256.7 ms | 1.466 ms | 1.300 ms |
|       TestDoubleStruct | 342.5 ms | 5.189 ms | 4.600 ms |
| TestDoubleStructWithIn | 338.7 ms | 3.808 ms | 3.376 ms |

x64 (RyuJIT)

|                 Method |       Mean |     Error |    StdDev |
|----------------------- |-----------:|----------:|----------:|
|               TestLong |   269.8 ms |  5.359 ms |  9.099 ms |
|         TestLongStruct |   266.2 ms |  6.706 ms |  8.236 ms |
|   TestLongStructWithIn |   270.4 ms |  4.150 ms |  3.465 ms |
|             TestDouble |   270.4 ms |  5.336 ms |  6.748 ms |
|       TestDoubleStruct | 1,250.9 ms | 24.702 ms | 25.367 ms |
| TestDoubleStructWithIn |   577.1 ms | 12.159 ms | 16.644 ms |

Я могу повторить это на x64 с RyuJIT, но не на x86 с LegacyJIT.Похоже, что это артефакт RyuJIT, управляющего процессом оптимизации long, но не делом double - LegacyJIT тоже не может оптимизировать.

Я понятия не имею, почему TestDoubleStruct такой выбросна RyuJIT.

Код:

public readonly struct LongStruct
{
    public readonly long Primitive;

    public LongStruct(long value) => Primitive = value;

    public static LongStruct Add(LongStruct lhs, LongStruct rhs)
        => new LongStruct(lhs.Primitive + rhs.Primitive);
    public static LongStruct AddWithIn(in LongStruct lhs, in LongStruct rhs)
        => new LongStruct(lhs.Primitive + rhs.Primitive);
}

public readonly struct DoubleStruct
{
    public readonly double Primitive;

    public DoubleStruct(double value) => Primitive = value;

    public static DoubleStruct Add(DoubleStruct lhs, DoubleStruct rhs)
        => new DoubleStruct(lhs.Primitive + rhs.Primitive);
    public static DoubleStruct AddWithIn(in DoubleStruct lhs, in DoubleStruct rhs)
        => new DoubleStruct(lhs.Primitive + rhs.Primitive);
}


public class Benchmark
{
    [Benchmark]
    public void TestLong()
    {
        for (var i = 1000000000; i > 0; --i)
        {
            LongAdd(1, 2);
        }
    }

    [Benchmark]
    public void TestLongStruct()
    {
        var a = new LongStruct(1);
        var b = new LongStruct(2);

        for (var i = 1000000000; i > 0; --i)
        {
            LongStruct.Add(a, b);
        }
    }

    [Benchmark]
    public void TestLongStructWithIn()
    {
        var a = new LongStruct(1);
        var b = new LongStruct(2);

        for (var i = 1000000000; i > 0; --i)
        {
            LongStruct.AddWithIn(a, b);
        }
    }

    [Benchmark]
    public void TestDouble()
    {
        for (var i = 1000000000; i > 0; --i)
        {
            DoubleAdd(1, 2);
        }
    }

    [Benchmark]
    public void TestDoubleStruct()
    {
        var a = new DoubleStruct(1);
        var b = new DoubleStruct(2);

        for (var i = 1000000000; i > 0; --i)
        {
            DoubleStruct.Add(a, b);
        }
    }

    [Benchmark]
    public void TestDoubleStructWithIn()
    {
        var a = new DoubleStruct(1);
        var b = new DoubleStruct(2);

        for (var i = 1000000000; i > 0; --i)
        {
            DoubleStruct.AddWithIn(a, b);
        }
    }

    public static long LongAdd(long lhs, long rhs) => lhs + rhs;
    public static double DoubleAdd(double lhs, double rhs) => lhs + rhs;
}

class Program
{
    static void Main(string[] args)
    {
        var summary = BenchmarkRunner.Run<Benchmark>();
        Console.ReadLine();
    }
}

Для забавы, вот сборка x64 для обоих случаев:

Код

using System;

public class C {
    public long AddLongs(long a, long b) {
        return a + b;
    }

    public LongStruct AddLongStructs(LongStruct a, LongStruct b) {
        return LongStruct.Add(a, b);
    }

    public LongStruct AddLongStructsWithIn(LongStruct a, LongStruct b) {
        return LongStruct.AddWithIn(a, b);
    }

    public double AddDoubles(double a, double b) {
        return a + b;
    }

    public DoubleStruct AddDoubleStructs(DoubleStruct a, DoubleStruct b) {
        return DoubleStruct.Add(a, b);
    }

    public DoubleStruct AddDoubleStructsWithIn(DoubleStruct a, DoubleStruct b) {
        return DoubleStruct.AddWithIn(a, b);
    }
}

public readonly struct LongStruct
{
    public readonly long Primitive;

    public LongStruct(long value) => Primitive = value;

    public static LongStruct Add(LongStruct lhs, LongStruct rhs)
        => new LongStruct(lhs.Primitive + rhs.Primitive);
    public static LongStruct AddWithIn(in LongStruct lhs, in LongStruct rhs)
        => new LongStruct(lhs.Primitive + rhs.Primitive);
}   

public readonly struct DoubleStruct
{
    public readonly double Primitive;

    public DoubleStruct(double value) => Primitive = value;

    public static DoubleStruct Add(DoubleStruct lhs, DoubleStruct rhs)
        => new DoubleStruct(lhs.Primitive + rhs.Primitive);
    public static DoubleStruct AddWithIn(in DoubleStruct lhs, in DoubleStruct rhs)
        => new DoubleStruct(lhs.Primitive + rhs.Primitive);
}

x86 Сборка

C.AddLongs(Int64, Int64)
    L0000: mov eax, [esp+0xc]
    L0004: mov edx, [esp+0x10]
    L0008: add eax, [esp+0x4]
    L000c: adc edx, [esp+0x8]
    L0010: ret 0x10

C.AddLongStructs(LongStruct, LongStruct)
    L0000: push esi
    L0001: mov eax, [esp+0x10]
    L0005: mov esi, [esp+0x14]
    L0009: add eax, [esp+0x8]
    L000d: adc esi, [esp+0xc]
    L0011: mov [edx], eax
    L0013: mov [edx+0x4], esi
    L0016: pop esi
    L0017: ret 0x10

C.AddLongStructsWithIn(LongStruct, LongStruct)
    L0000: push esi
    L0001: mov eax, [esp+0x10]
    L0005: mov esi, [esp+0x14]
    L0009: add eax, [esp+0x8]
    L000d: adc esi, [esp+0xc]
    L0011: mov [edx], eax
    L0013: mov [edx+0x4], esi
    L0016: pop esi
    L0017: ret 0x10

C.AddDoubles(Double, Double)
    L0000: fld qword [esp+0xc]
    L0004: fadd qword [esp+0x4]
    L0008: ret 0x10

C.AddDoubleStructs(DoubleStruct, DoubleStruct)
    L0000: fld qword [esp+0xc]
    L0004: fld qword [esp+0x4]
    L0008: faddp st1, st0
    L000a: fstp qword [edx]
    L000c: ret 0x10

C.AddDoubleStructsWithIn(DoubleStruct, DoubleStruct)
    L0000: fld qword [esp+0xc]
    L0004: fadd qword [esp+0x4]
    L0008: fstp qword [edx]
    L000a: ret 0x10

x64 Сборка

C..ctor()
    L0000: ret

C.AddLongs(Int64, Int64)
    L0000: lea rax, [rdx+r8]
    L0004: ret

C.AddLongStructs(LongStruct, LongStruct)
    L0000: lea rax, [rdx+r8]
    L0004: ret

C.AddLongStructsWithIn(LongStruct, LongStruct)
    L0000: lea rax, [rdx+r8]
    L0004: ret

C.AddDoubles(Double, Double)
    L0000: vzeroupper
    L0003: vmovaps xmm0, xmm1
    L0008: vaddsd xmm0, xmm0, xmm2
    L000d: ret

C.AddDoubleStructs(DoubleStruct, DoubleStruct)
    L0000: sub rsp, 0x18
    L0004: vzeroupper
    L0007: mov [rsp+0x28], rdx
    L000c: mov [rsp+0x30], r8
    L0011: mov rax, [rsp+0x28]
    L0016: mov [rsp+0x10], rax
    L001b: mov rax, [rsp+0x30]
    L0020: mov [rsp+0x8], rax
    L0025: vmovsd xmm0, qword [rsp+0x10]
    L002c: vaddsd xmm0, xmm0, [rsp+0x8]
    L0033: vmovsd [rsp], xmm0
    L0039: mov rax, [rsp]
    L003d: add rsp, 0x18
    L0041: ret

C.AddDoubleStructsWithIn(DoubleStruct, DoubleStruct)
    L0000: push rax
    L0001: vzeroupper
    L0004: mov [rsp+0x18], rdx
    L0009: mov [rsp+0x20], r8
    L000e: vmovsd xmm0, qword [rsp+0x18]
    L0015: vaddsd xmm0, xmm0, [rsp+0x20]
    L001c: vmovsd [rsp], xmm0
    L0022: mov rax, [rsp]
    L0026: add rsp, 0x8
    L002a: ret

SharpLab


Если добавить в циклы:

Код

public class C {
    public void AddLongs(long a, long b) {
        for (var i = 1000000000; i > 0; --i) {
            long c = a + b;
        }
    }

    public void AddLongStructs(LongStruct a, LongStruct b) {
        for (var i = 1000000000; i > 0; --i) {
            a = LongStruct.Add(a, b);
        }
    }

    public void AddLongStructsWithIn(LongStruct a, LongStruct b) {
        for (var i = 1000000000; i > 0; --i) {
            a = LongStruct.AddWithIn(a, b);
        }
    }

    public void AddDoubles(double a, double b) {
        for (var i = 1000000000; i > 0; --i) {
            a = a + b;
        }
    }

    public void AddDoubleStructs(DoubleStruct a, DoubleStruct b) {
        for (var i = 1000000000; i > 0; --i) {
            a = DoubleStruct.Add(a, b);
        }
    }

    public void AddDoubleStructsWithIn(DoubleStruct a, DoubleStruct b) {
        for (var i = 1000000000; i > 0; --i) {
            a = DoubleStruct.AddWithIn(a, b);
        }
    }
}

public readonly struct LongStruct
{
    public readonly long Primitive;

    public LongStruct(long value) => Primitive = value;

    public static LongStruct Add(LongStruct lhs, LongStruct rhs)
        => new LongStruct(lhs.Primitive + rhs.Primitive);
    public static LongStruct AddWithIn(in LongStruct lhs, in LongStruct rhs)
        => new LongStruct(lhs.Primitive + rhs.Primitive);
}   

public readonly struct DoubleStruct
{
    public readonly double Primitive;

    public DoubleStruct(double value) => Primitive = value;

    public static DoubleStruct Add(DoubleStruct lhs, DoubleStruct rhs)
        => new DoubleStruct(lhs.Primitive + rhs.Primitive);
    public static DoubleStruct AddWithIn(in DoubleStruct lhs, in DoubleStruct rhs)
        => new DoubleStruct(lhs.Primitive + rhs.Primitive);
}

x86

C.AddLongs(Int64, Int64)
    L0000: push ebp
    L0001: mov ebp, esp
    L0003: mov eax, 0x3b9aca00
    L0008: dec eax
    L0009: test eax, eax
    L000b: jg L0008
    L000d: pop ebp
    L000e: ret 0x10

C.AddLongStructs(LongStruct, LongStruct)
    L0000: push ebp
    L0001: mov ebp, esp
    L0003: push esi
    L0004: mov esi, 0x3b9aca00
    L0009: mov eax, [ebp+0x10]
    L000c: mov edx, [ebp+0x14]
    L000f: add eax, [ebp+0x8]
    L0012: adc edx, [ebp+0xc]
    L0015: mov [ebp+0x10], eax
    L0018: mov [ebp+0x14], edx
    L001b: dec esi
    L001c: test esi, esi
    L001e: jg L0009
    L0020: pop esi
    L0021: pop ebp
    L0022: ret 0x10

C.AddLongStructsWithIn(LongStruct, LongStruct)
    L0000: push ebp
    L0001: mov ebp, esp
    L0003: push esi
    L0004: mov esi, 0x3b9aca00
    L0009: mov eax, [ebp+0x10]
    L000c: mov edx, [ebp+0x14]
    L000f: add eax, [ebp+0x8]
    L0012: adc edx, [ebp+0xc]
    L0015: mov [ebp+0x10], eax
    L0018: mov [ebp+0x14], edx
    L001b: dec esi
    L001c: test esi, esi
    L001e: jg L0009
    L0020: pop esi
    L0021: pop ebp
    L0022: ret 0x10

C.AddDoubles(Double, Double)
    L0000: push ebp
    L0001: mov ebp, esp
    L0003: mov eax, 0x3b9aca00
    L0008: dec eax
    L0009: test eax, eax
    L000b: jg L0008
    L000d: pop ebp
    L000e: ret 0x10

C.AddDoubleStructs(DoubleStruct, DoubleStruct)
    L0000: push ebp
    L0001: mov ebp, esp
    L0003: mov eax, 0x3b9aca00
    L0008: fld qword [ebp+0x10]
    L000b: fld qword [ebp+0x8]
    L000e: faddp st1, st0
    L0010: fstp qword [ebp+0x10]
    L0013: dec eax
    L0014: test eax, eax
    L0016: jg L0008
    L0018: pop ebp
    L0019: ret 0x10

C.AddDoubleStructsWithIn(DoubleStruct, DoubleStruct)
    L0000: push ebp
    L0001: mov ebp, esp
    L0003: mov eax, 0x3b9aca00
    L0008: fld qword [ebp+0x10]
    L000b: fadd qword [ebp+0x8]
    L000e: fstp qword [ebp+0x10]
    L0011: dec eax
    L0012: test eax, eax
    L0014: jg L0008
    L0016: pop ebp
    L0017: ret 0x10

x64

C.AddLongs(Int64, Int64)
    L0000: mov eax, 0x3b9aca00
    L0005: dec eax
    L0007: test eax, eax
    L0009: jg L0005
    L000b: ret

C.AddLongStructs(LongStruct, LongStruct)
    L0000: mov eax, 0x3b9aca00
    L0005: add rdx, r8
    L0008: dec eax
    L000a: test eax, eax
    L000c: jg L0005
    L000e: ret

C.AddLongStructsWithIn(LongStruct, LongStruct)
    L0000: mov eax, 0x3b9aca00
    L0005: add rdx, r8
    L0008: dec eax
    L000a: test eax, eax
    L000c: jg L0005
    L000e: ret

C.AddDoubles(Double, Double)
    L0000: vzeroupper
    L0003: mov eax, 0x3b9aca00
    L0008: vaddsd xmm1, xmm1, xmm2
    L000d: dec eax
    L000f: test eax, eax
    L0011: jg L0008
    L0013: ret

C.AddDoubleStructs(DoubleStruct, DoubleStruct)
    L0000: sub rsp, 0x18
    L0004: vzeroupper
    L0007: mov [rsp+0x28], rdx
    L000c: mov [rsp+0x30], r8
    L0011: mov eax, 0x3b9aca00
    L0016: mov rdx, [rsp+0x28]
    L001b: mov [rsp+0x10], rdx
    L0020: mov rdx, [rsp+0x30]
    L0025: mov [rsp+0x8], rdx
    L002a: vmovsd xmm0, qword [rsp+0x10]
    L0031: vaddsd xmm0, xmm0, [rsp+0x8]
    L0038: vmovsd [rsp], xmm0
    L003e: mov rdx, [rsp]
    L0042: mov [rsp+0x28], rdx
    L0047: dec eax
    L0049: test eax, eax
    L004b: jg L0016
    L004d: add rsp, 0x18
    L0051: ret

C.AddDoubleStructsWithIn(DoubleStruct, DoubleStruct)
    L0000: push rax
    L0001: vzeroupper
    L0004: mov [rsp+0x18], rdx
    L0009: mov [rsp+0x20], r8
    L000e: mov eax, 0x3b9aca00
    L0013: vmovsd xmm0, qword [rsp+0x20]
    L001a: vmovaps xmm1, xmm0
    L001f: vaddsd xmm1, xmm1, [rsp+0x18]
    L0026: vmovsd [rsp], xmm1
    L002c: mov rdx, [rsp]
    L0030: mov [rsp+0x18], rdx
    L0035: dec eax
    L0037: test eax, eax
    L0039: jg L001a
    L003b: add rsp, 0x8
    L003f: ret

SharpLab

Я недостаточно знаком со сборкой, чтобы объяснить, что именно она делает, но ясно, что болееработа продолжается в AddDoubleStructs, а не AddLongStructs.

...