Почему скомпилированный делегат быстрее объявленного делегата? - PullRequest
0 голосов
/ 03 мая 2018

Начнем с того, что это не то же самое, что Почему Func <> создается из Expression> медленнее, чем Func <>, объявленный напрямую? и, на удивление, просто противоположен этому. Кроме того, все ссылки и вопросы, которые я обнаружил при исследовании этой проблемы, возникли в период 2010-2012 гг., Поэтому я решил открыть новый вопрос здесь, чтобы узнать, следует ли обсуждать текущее состояние делегата. поведение в экосистеме .NET.

Тем не менее, я использую .NET Core 2.0 и .NET 4.7.1 и вижу некоторые любопытные метрики производительности в отношении делегатов, которые создаются из скомпилированного выражения, по сравнению с делегатами, которые описаны и объявлены как объект CLR.

Для некоторого контекста того, как я наткнулся на эту проблему, я проводил тест, включающий выборку данных в массивах из 1000 и 10000 объектов, и заметил, что, если я использую скомпилированное выражение, оно получит более быстрые результаты по всем направлениям. Мне удалось свести это к очень простому проекту, который воспроизводит эту проблему, которую вы можете найти здесь:

https://github.com/Mike-EEE/StackOverflow.Performance.Delegates

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

Первый набор делегатов состоит из пустого делегата, который возвращает пустую строку. Второй набор - это делегат с простым выражением. Я хотел продемонстрировать, что эта проблема возникает с простыми делегатами, а также с делегатами с определенным телом внутри.

Затем эти тесты выполняются в среде CLR и среде .NET Core с помощью превосходного продукта производительности Benchmark.NET , в результате чего получается восемь тестов. Кроме того, я также использую столь же превосходный диагностический анализатор разборки Benchmark.NET , чтобы выдавать разборку, возникшую во время JIT эталонных измерений. Я делюсь результатами этого ниже.

Вот код, который запускает тесты. Вы можете видеть, что это очень просто:

[CoreJob, ClrJob, DisassemblyDiagnoser(true, printSource: true)]
public class Delegates
{
    readonly DelegatePair<string, string> _empty;
    readonly DelegatePair<string, int>    _expression;
    readonly string                       _message;

    public Delegates() : this(new DelegatePair<string, string>(_ => default, _ => default),
                              new DelegatePair<string, int>(x => x.Length, x => x.Length)) {}

    public Delegates(DelegatePair<string, string> empty, DelegatePair<string, int> expression,
                     string message = "Hello World!")
    {
        _empty      = empty;
        _expression = expression;
        _message    = message;
        EmptyDeclared();
        EmptyCompiled();
        ExpressionDeclared();
        ExpressionCompiled();
    }

    [Benchmark]
    public void EmptyDeclared() => _empty.Declared(default);

    [Benchmark]
    public void EmptyCompiled() => _empty.Compiled(default);

    [Benchmark]
    public void ExpressionDeclared() => _expression.Declared(_message);

    [Benchmark]
    public void ExpressionCompiled() => _expression.Compiled(_message);
}

Это результаты, которые я вижу в Benchmark.NET:

.
BenchmarkDotNet=v0.10.14, OS=Windows 10.0.16299.371 (1709/FallCreatorsUpdate/Redstone3)
Intel Core i7-4820K CPU 3.70GHz (Haswell), 1 CPU, 8 logical and 8 physical cores
.NET Core SDK=2.1.300-preview2-008533
  [Host] : .NET Core 2.0.7 (CoreCLR 4.6.26328.01, CoreFX 4.6.26403.03), 64bit RyuJIT
  Clr    : .NET Framework 4.7.1 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.2633.0
  Core   : .NET Core 2.0.7 (CoreCLR 4.6.26328.01, CoreFX 4.6.26403.03), 64bit RyuJIT


             Method |  Job | Runtime |      Mean |     Error |    StdDev |
------------------- |----- |-------- |----------:|----------:|----------:|
      EmptyDeclared |  Clr |     Clr | 1.3691 ns | 0.0302 ns | 0.0282 ns |
      EmptyCompiled |  Clr |     Clr | 1.1851 ns | 0.0381 ns | 0.0357 ns |
 ExpressionDeclared |  Clr |     Clr | 1.3805 ns | 0.0314 ns | 0.0294 ns |
 ExpressionCompiled |  Clr |     Clr | 1.1431 ns | 0.0396 ns | 0.0371 ns |
      EmptyDeclared | Core |    Core | 1.5733 ns | 0.0329 ns | 0.0308 ns |
      EmptyCompiled | Core |    Core | 0.9326 ns | 0.0275 ns | 0.0244 ns |
 ExpressionDeclared | Core |    Core | 1.6040 ns | 0.0394 ns | 0.0368 ns |
 ExpressionCompiled | Core |    Core | 0.9380 ns | 0.0485 ns | 0.0631 ns |

Обратите внимание, что тесты, использующие скомпилированный делегат, всегда быстрее.

Наконец, вот результаты разборки для каждого теста:

<style type="text/css">
	table { border-collapse: collapse; display: block; width: 100%; overflow: auto; }
	td, th { padding: 6px 13px; border: 1px solid #ddd; }
	tr { background-color: #fff; border-top: 1px solid #ccc; }
	tr:nth-child(even) { background: #f8f8f8; }
</style>
</head>
<body>
<table>
<thead>
<tr><th colspan="2">Delegates.EmptyDeclared</th></tr>
<tr>
<th>.NET Framework 4.7.1 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.2633.0</th>
<th>.NET Core 2.0.7 (CoreCLR 4.6.26328.01, CoreFX 4.6.26403.03), 64bit RyuJIT</th>
</tr>
</thead>
<tbody>
<tr>
<td style="vertical-align:top;"><pre><code>
00007ffd`4f8f0ea0 StackOverflow.Performance.Delegates.Delegates.EmptyDeclared()
		public void EmptyDeclared() => _empty.Declared(default);
                                 ^^^^^^^^^^^^^^^^^^^^^^^^
00007ffd`4f8f0ea4 4883c110        add     rcx,10h
00007ffd`4f8f0ea8 488b01          mov     rax,qword ptr [rcx]
00007ffd`4f8f0eab 488b4808        mov     rcx,qword ptr [rax+8]
00007ffd`4f8f0eaf 33d2            xor     edx,edx
00007ffd`4f8f0eb1 ff5018          call    qword ptr [rax+18h]
00007ffd`4f8f0eb4 90              nop


00007ffd`39c8d8b0 StackOverflow.Performance.Delegates.Delegates.EmptyDeclared()
		public void EmptyDeclared() => _empty.Declared(default);
                                 ^^^^^^^^^^^^^^^^^^^^^^^^
00007ffd`39c8d8b4 4883c110        add     rcx,10h
00007ffd`39c8d8b8 488b01          mov     rax,qword ptr [rcx]
00007ffd`39c8d8bb 488b4808        mov     rcx,qword ptr [rax+8]
00007ffd`39c8d8bf 33d2            xor     edx,edx
00007ffd`39c8d8c1 ff5018          call    qword ptr [rax+18h]
00007ffd`39c8d8c4 90              nop

Delegates.EmptyCompiled
.NET Framework 4.7.1 (CLR 4.0.30319.42000), 64-разрядная версия RyuJIT-v4.7.2633.0 .NET Core 2.0.7 (CoreCLR 4.6.26328.01, CoreFX 4.6.26403.03), 64-разрядная версия RyuJIT

00007ffd`4f8e0ef0 StackOverflow.Performance.Delegates.Delegates.EmptyCompiled()
		public void EmptyCompiled() => _empty.Compiled(default);
                                 ^^^^^^^^^^^^^^^^^^^^^^^^
00007ffd`4f8e0ef4 4883c110        add     rcx,10h
00007ffd`4f8e0ef8 488b4108        mov     rax,qword ptr [rcx+8]
00007ffd`4f8e0efc 488b4808        mov     rcx,qword ptr [rax+8]
00007ffd`4f8e0f00 33d2            xor     edx,edx
00007ffd`4f8e0f02 ff5018          call    qword ptr [rax+18h]
00007ffd`4f8e0f05 90              nop


00007ffd`39c8d900 StackOverflow.Performance.Delegates.Delegates.EmptyCompiled()
		public void EmptyCompiled() => _empty.Compiled(default);
                                 ^^^^^^^^^^^^^^^^^^^^^^^^
00007ffd`39c8d904 4883c110        add     rcx,10h
00007ffd`39c8d908 488b4108        mov     rax,qword ptr [rcx+8]
00007ffd`39c8d90c 488b4808        mov     rcx,qword ptr [rax+8]
00007ffd`39c8d910 33d2            xor     edx,edx
00007ffd`39c8d912 ff5018          call    qword ptr [rax+18h]
00007ffd`39c8d915 90              nop

Delegates.ExpressionDeclared
.NET Framework 4.7.1 (CLR 4.0.30319.42000), 64-разрядная версия RyuJIT-v4.7.2633.0 .NET Core 2.0.7 (CoreCLR 4.6.26328.01, CoreFX 4.6.26403.03), 64-битная версия RyuJIT

00007ffd`4f8e0f20 StackOverflow.Performance.Delegates.Delegates.ExpressionDeclared()
		public void ExpressionDeclared() => _expression.Declared(_message);
                                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
00007ffd`4f8e0f24 488d5120        lea     rdx,[rcx+20h]
00007ffd`4f8e0f28 488b02          mov     rax,qword ptr [rdx]
00007ffd`4f8e0f2b 488b5108        mov     rdx,qword ptr [rcx+8]
00007ffd`4f8e0f2f 488b4808        mov     rcx,qword ptr [rax+8]
00007ffd`4f8e0f33 ff5018          call    qword ptr [rax+18h]
00007ffd`4f8e0f36 90              nop


00007ffd`39c9d930 StackOverflow.Performance.Delegates.Delegates.ExpressionDeclared()
		public void ExpressionDeclared() => _expression.Declared(_message);
                                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
00007ffd`39c9d934 488d5120        lea     rdx,[rcx+20h]
00007ffd`39c9d938 488b02          mov     rax,qword ptr [rdx]
00007ffd`39c9d93b 488b5108        mov     rdx,qword ptr [rcx+8]
00007ffd`39c9d93f 488b4808        mov     rcx,qword ptr [rax+8]
00007ffd`39c9d943 ff5018          call    qword ptr [rax+18h]
00007ffd`39c9d946 90              nop

Delegates.ExpressionCompiled
.NET Framework 4.7.1 (CLR 4.0.30319.42000), 64-разрядная версия RyuJIT-v4.7.2633.0 .NET Core 2.0.7 (CoreCLR 4.6.26328.01, CoreFX 4.6.26403.03), 64-разрядная версия RyuJIT

00007ffd`4f8f0f70 StackOverflow.Performance.Delegates.Delegates.ExpressionCompiled()
		public void ExpressionCompiled() => _expression.Compiled(_message);
                                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
00007ffd`4f8f0f74 488d5120        lea     rdx,[rcx+20h]
00007ffd`4f8f0f78 488b4208        mov     rax,qword ptr [rdx+8]
00007ffd`4f8f0f7c 488b5108        mov     rdx,qword ptr [rcx+8]
00007ffd`4f8f0f80 488b4808        mov     rcx,qword ptr [rax+8]
00007ffd`4f8f0f84 ff5018          call    qword ptr [rax+18h]
00007ffd`4f8f0f87 90              nop


00007ffd`39c9d980 StackOverflow.Performance.Delegates.Delegates.ExpressionCompiled()
		public void ExpressionCompiled() => _expression.Compiled(_message);
                                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
00007ffd`39c9d984 488d5120        lea     rdx,[rcx+20h]
00007ffd`39c9d988 488b4208        mov     rax,qword ptr [rdx+8]
00007ffd`39c9d98c 488b5108        mov     rdx,qword ptr [rcx+8]
00007ffd`39c9d990 488b4808        mov     rcx,qword ptr [rax+8]
00007ffd`39c9d994 ff5018          call    qword ptr [rax+18h]
00007ffd`39c9d997 90              nop

Казалось бы, единственная разница между объявленной и скомпилированной разборкой делегата - это rcx для объявленной и rcx+8 для скомпилированной, используемой в их соответствующих первых mov операциях. Я еще не очень хорошо разбираюсь в разборке, поэтому очень важно получить представление об этом. На первый взгляд может показаться, что это не вызовет различий / улучшений, и если это так, то делегат, объявленный в нативе, должен также включить его (иными словами, ошибка).

С учетом всего сказанного, очевидные вопросы для меня:

  1. Это известная проблема и / или ошибка?
  2. Я делаю что-то совершенно не по месту? (Угадай, это должен быть первый вопрос.:))
  3. Является ли тогда руководство использовать скомпилированные делегаты всегда, где это возможно? Как я упоминал ранее, может показаться, что магия, которая случается с скомпилированными делегатами, уже выпечена в объявленных делегатах, поэтому это немного сбивает с толку.

Для полноты вот весь код, использованный в примере здесь:

sealed class Program
{
    static void Main()
    {
        BenchmarkRunner.Run<Delegates>();
    }
}

[CoreJob, ClrJob, DisassemblyDiagnoser(true, printSource: true)]
public class Delegates
{
    readonly DelegatePair<string, string> _empty;
    readonly DelegatePair<string, int>    _expression;
    readonly string                       _message;

    public Delegates() : this(new DelegatePair<string, string>(_ => default, _ => default),
                              new DelegatePair<string, int>(x => x.Length, x => x.Length)) {}

    public Delegates(DelegatePair<string, string> empty, DelegatePair<string, int> expression,
                     string message = "Hello World!")
    {
        _empty      = empty;
        _expression = expression;
        _message    = message;
        EmptyDeclared();
        EmptyCompiled();
        ExpressionDeclared();
        ExpressionCompiled();
    }

    [Benchmark]
    public void EmptyDeclared() => _empty.Declared(default);

    [Benchmark]
    public void EmptyCompiled() => _empty.Compiled(default);

    [Benchmark]
    public void ExpressionDeclared() => _expression.Declared(_message);

    [Benchmark]
    public void ExpressionCompiled() => _expression.Compiled(_message);
}

public struct DelegatePair<TFrom, TTo>
{
    DelegatePair(Func<TFrom, TTo> declared, Func<TFrom, TTo> compiled)
    {
        Declared = declared;
        Compiled = compiled;
    }

    public DelegatePair(Func<TFrom, TTo> declared, Expression<Func<TFrom, TTo>> expression) :
        this(declared, expression.Compile()) {}

    public Func<TFrom, TTo> Declared { get; }

    public Func<TFrom, TTo> Compiled { get; }
}

Заранее благодарим за любую помощь, которую вы можете оказать!

1 Ответ

0 голосов
/ 09 мая 2018

Я что-то здесь делаю не по назначению? (Угадай, это должен быть первый вопрос.:))

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

Именно поэтому единственное отличие - это относительное смещение в одной из инструкций mov: один из делегатов живет со смещением 0 в структуре, а другой - со смещением 8. Поменяйте местами порядок объявления Compiled и Declared и посмотрите, как меняется разборка.

Я не знаю ни одного способа заставить Benchmark.NET выплевывать разборку для вызовов глубже в дереве вызовов. Документация предполагает, что установка recursiveDepth в какое-то значение n > 1 на [DisassemblyDiagnoser] должна сделать это, но в этом случае это не работает.


Вы говорите, что есть дополнительные разборки, которых мы не видим?

Правильно, вы не видите разборки для делегатов. Если есть разница в том, как они компилируются, это то, где это будет видно.

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

Тела не обязательно одинаковы. Для Expression lambas компилятор C # не выдает IL для описанного выражения ; скорее он генерирует серию Expression фабричных вызовов для построения дерева выражений во время выполнения. Это дерево выражений описывает код, который должен функционально эквивалентно выражению C #, из которого оно было сгенерировано, но оно компилируется LambdaCompiler во время выполнения при вызове Compile(). Деревья выражений LINQ подразумевают независимость от языка и не обязательно имеют точную четность с выражениями, сгенерированными компилятором C #. Поскольку лямбда-выражения компилируются другим (и менее изощренным) компилятором, результирующий IL может немного отличаться от того, что испускал бы компилятор C #. Например, лямбда-компилятор имеет тенденцию испускать больше временных локальных объектов, чем компилятор C #, или, по крайней мере, это было в последний раз, когда я копался в исходном коде.

Лучшим выбором для определения фактической разборки для каждого делегата может быть загрузка SOS.dll в отладчик. Я пытался сделать это сам, но я не могу понять, как заставить это работать в VS2017. У меня никогда не было проблем в прошлом. Я еще не совсем смирился с новой моделью проекта в VS2017 и не могу понять, как включить неуправляемую отладку.


ОК, я загрузил SOS.dll с WinDbg, и после небольшого поиска я могу просматривать IL и разборку. Сначала давайте взглянем на дескрипторы методов для лямбда-тел. Это Заявленная версия:

0:000> !DumpMD 000007fe97686148

Method Name:  StackOverflow.Performance.Delegates.Delegates+<>c.<.ctor>b__3_2(System.String)
Class:        000007fe977d14d0
MethodTable:  000007fe97686158
mdToken:      000000000600000e
Module:       000007fe976840c0
IsJitted:     yes
CodeAddr:     000007fe977912b0
Transparency: Critical

А это Скомпилированная версия:

0:000> !DumpMD 000007fe97689390

Method Name:  DynamicClass.lambda_method(System.Runtime.CompilerServices.Closure, System.String)
Class:        000007fe97689270
MethodTable:  000007fe976892e8
mdToken:      0000000006000000
Module:       000007fe97688af8
IsJitted:     yes
CodeAddr:     000007fe977e0150
Transparency: Transparent

Мы можем сбросить IL и увидеть, что на самом деле это то же самое:

0:000> !DumpIL 000007fe97686148

IL_0000: ldarg.1 
IL_0001: callvirt 6000002 System.String.get_Length()
IL_0006: ret 

0:000> !DumpIL 000007fe97689390

IL_0000: ldarg.1 
IL_0001: callvirt System.String::get_Length 
IL_0006: ret

Так же, как разборка:

0:000> !U 000007fe977912b0

Normal JIT generated code
StackOverflow.Performance.Delegates.Delegates+<>c.<.ctor>b__3_2(System.String)
Begin 000007fe977912b0, size 4
W:\dump\DelegateBenchmark\StackOverflow.Performance.Delegates\Delegates.cs @ 14:

000007fe`977912b0 8b4208          mov     eax,dword ptr [rdx+8]
000007fe`977912b3 c3              ret

0:000> !U 000007fe977e0150

Normal JIT generated code
DynamicClass.lambda_method(System.Runtime.CompilerServices.Closure, System.String)
Begin 000007fe977e0150, size 4

000007fe`977e0150 8b4208          mov     eax,dword ptr [rdx+8]
000007fe`977e0153 c3              ret

Итак, у нас один и тот же ИЛ и одна и та же сборка. Откуда эта разница? Давайте посмотрим на фактические экземпляры делегатов. Под этим я подразумеваю не лямбда-тела, а объекты Delegate, которые мы используем для вызова лямбд.

0:000> !DumpVC /d 000007fe97686040 0000000002a84410

Name:        StackOverflow.Performance.Delegates.DelegatePair`2[[System.String, mscorlib],[System.Int32, mscorlib]]
MethodTable: 000007fe97686040
EEClass:     000007fe977d12d0
Size:        32(0x20) bytes
File:        W:\dump\DelegateBenchmark\StackOverflow.Performance.Delegates\bin\Release\net461\StackOverflow.Performance.Delegates.exe
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
000007fef692e400  4000001        0 ...Int32, mscorlib]]  0 instance 0000000002a8b4d8 <Declared>k__BackingField
000007fef692e400  4000002        8 ...Int32, mscorlib]]  0 instance 0000000002a8d3f8 <Compiled>k__BackingField

У нас есть два значения делегата: в моем случае Declared живет на 02a8b4d8, тогда как Compiled живет на 02a8d3f8 (эти адреса уникальны для моего процесса). Если мы сбрасываем каждый из этих адресов с помощью !DumpObject и ищем значение _methodPtr, мы можем видеть адреса скомпилированных методов. Затем мы можем сбросить сборку с помощью !U:

0:000> !U 7fe977e0150 

Normal JIT generated code
DynamicClass.lambda_method(System.Runtime.CompilerServices.Closure, System.String)
Begin 000007fe977e0150, size 4

000007fe`977e0150 8b4208          mov     eax,dword ptr [rdx+8]
000007fe`977e0153 c3              ret

Хорошо, для Compiled мы можем видеть, что мы вызываем прямо в лямбда-тело. Ницца. Но когда мы выкидываем разборку для версии Declared, мы видим что-то другое:

0:000> !U 7fe977901d8 

Unmanaged code

000007fe`977901d8 e8f326635f      call    clr!PrecodeFixupThunk (000007fe`f6dc28d0)
000007fe`977901dd 5e              pop     rsi
000007fe`977901de 0400            add     al,0
000007fe`977901e0 286168          sub     byte ptr [rcx+68h],ah
000007fe`977901e3 97              xchg    eax,edi
000007fe`977901e4 fe07            inc     byte ptr [rdi]
000007fe`977901e6 0000            add     byte ptr [rax],al
000007fe`977901e8 0000            add     byte ptr [rax],al
000007fe`977901ea 0000            add     byte ptr [rax],al
000007fe`977901ec 0000            add     byte ptr [rax],al

Привет. Я помню, как видел ссылки на clr!PrecodeFixupThunk в блоге Мэтта Уоррена . Насколько я понимаю, точка входа для нормального IL-метода (в отличие от динамического метода, такого как наш метод на основе LINQ) вызывает метод fixup, который вызывает JIT при первом вызов, затем вызывает метод JITed при последующих вызовах. Причиной могут быть дополнительные издержки этого «thunk» при вызове «объявленного» делегата. «Скомпилированный» делегат не имеет такого толка; делегат указывает прямо на скомпилированное лямбда-тело.

...