Сегодня я расскажу вам об одном из моих любимых бенчмарков (данный метод не возвращает ничего полезного, он нам нужен только в качестве примера):
[Benchmark]
public string Sum()
{
double a = 1, b = 1;
var sw = new Stopwatch();
for (int i = 0; i < 10001; i++)
a = a + b;
return string.Format("{0}{1}", a, sw.ElapsedMilliseconds);
}
Интересный факт: если вы вызовете Stopwatch.GetTimestamp()
перед первым вызовом метода Sum
, то это увеличит скорость работы метода в несколько раз (фокус работает только для LegacyJIT-x86).
Исходный код и ASM
Рассмотрим две следующие программы (работаем на x86
):
class ProgramA
{
static void Main()
{
Sum();
}
public static string Sum()
{
double a = 1, b = 1;
var sw = new Stopwatch();
for (int i = 0; i < 10001; i++)
a = a + b;
return string.Format("{0}{1}", a, sw.ElapsedMilliseconds);
}
}
class ProgramB
{
static void Main()
{
Stopwatch.GetTimestamp(); // !!!
Sum();
}
public static string Sum()
{
double a = 1, b = 1;
var sw = new Stopwatch();
for (int i = 0; i < 10001; i++)
a = a + b;
return string.Format("{0}{1}", a, sw.ElapsedMilliseconds);
}
}
Единственное отличие между этими программами состоит в лишнем вызове Stopwatch.GetTimestamp()
. А теперь взглянем на asm-код нашего цикла внутри метода Sum
:
; ProgramA
; for (int i = 0; i < 10001; i++)
xor eax,eax
; a = a + b;
fld1
fadd qword ptr [ebp-14h]
fstp qword ptr [ebp-14h]
; ProgramB
; for (int i = 0; i < 10001; i++)
xor eax,eax
; a = a + b;
fld1
faddp st(1),st
Оказывается, программа ProgramA
хранит данные на стеке, а ProgramB
хранит их в регистрах.
Как так?
На самом деле в программе ProgramB
мы можем вызвать Stopwatch.IsHighResolution
или Stopwatch.Frequency
вместо Stopwatch.GetTimestamp()
. Главный момент заключается в том, что для достижения нужного эффекта нам необходимо неявно вызвать статический конструктор класса Stopwatch
. Это повлияет на то, как экзеплярный конструктор Stopwatch
будет обработан JIT-компилятором:
; Program A
; var sw = new Stopwatch();
mov ecx,71CDF3D4h
call 005D30F4 ; базовая логика конструктора
mov ecx,5E5F60h ; !!! Тут мы должны проверить,
mov edx,4F6h ; !!! что статический конструктор
call 005D348C ; !!! был вызван
; // заинлайненный Stopwatch::.ctor
mov dword ptr [esi+4],0 ; elapsed = 0
mov dword ptr [esi+8],0 ; elapsed = 0
mov byte ptr [esi+14h],0 ; isRunning = false
mov dword ptr [esi+0Ch],0 ; startTimeStamp = 0
mov dword ptr [esi+10h],0 ; startTimeStamp = 0
; Program B
; var sw = new Stopwatch();
mov ecx,71CDF3D4h
call 005D30F4 ; базовая логика конструктора
; // заинлайненный Stopwatch::.ctor
mov dword ptr [esi+4],0 ; elapsed = 0
mov dword ptr [esi+8],0 ; elapsed = 0
mov byte ptr [esi+14h],0 ; isRunning = false
mov dword ptr [esi+0Ch],0 ; startTimeStamp = 0
mov dword ptr [esi+10h],0 ; startTimeStamp = 0
Как можно увидеть из листинга, у нас имеется два call
для ProgramA
и один call
для ProgramB
.
LegacyJIT-x86 использует количество call
-ов в качестве одного из факторов для того, чтобы решить использовать ли регистры для локальных floating point-переменных или не использовать. Таким образом, мы получили разный asm-код для ProgramA
и ProgramB
.
Бенчмарки
Но должна ли нас волновать эта разница? Как это влияет на производительность? Давайте забенчмаркаем! Я написал следующий бенчмарк для оценки ситуации (основано на BenchmarkDotNet v0.9.4):
[Config(typeof(Config))]
public class FirstCall
{
[Params(false, true)]
public bool CallTimestamp { get; set; }
[Setup]
public void Setup()
{
if (CallTimestamp)
Stopwatch.GetTimestamp();
}
[Benchmark]
public string Sum()
{
double a = 1, b = 1;
var sw = new Stopwatch();
for (int i = 0; i < 10001; i++)
a = a + b;
return string.Format("{0}{1}", a, sw.ElapsedMilliseconds);
}
private class Config : ManualConfig
{
public Config()
{
Add(Job.LegacyJitX86);
}
}
}
Результаты:
BenchmarkDotNet=v0.9.4.0
OS=Microsoft Windows NT 6.2.9200.0
Processor=Intel(R) Core(TM) i7-4810MQ CPU 2.80GHz, ProcessorCount=8
Frequency=2728072 ticks, Resolution=366.5592 ns, Timer=TSC
HostCLR=MS.NET 4.0.30319.42000, Arch=32-bit RELEASE
JitModules=clrjit-v4.6.1073.0
Type=FirstCall Mode=Throughput Platform=X86
Jit=LegacyJit
Method | Median | StdDev | CallTimestamp |
------- |----------- |---------- |-------------- |
Sum | 27.0464 us | 0.4958 us | False |
Sum | 8.3247 us | 0.0293 us | True |
Получается так, что вызов Stopwatch.GetTimestamp()
перед первым вызовом метода Sum
увеличил скорость работы в 3.5 раза!
Заключение
Производительность — тема сложная. Бенчмаркинг — тема очень сложная. Постараюсь сформулировать мораль данной истории:
- В общем случае мы не можем просто так взять метод без контекста и начать рассуждать о его производительности, ведь его asm-код может зависеть от состояния CLR в момент первого вызова (однако, на практике это редко имеет значение).
- Бенчмарки могут влиять друг на друга (не только из-за статических конструкторов; например, имеют большое значение самонастраиваемый сборщик мусора и диспатчинг интерфейсных методов). Поэтому хорошей практикой является запуск каждого бенчмарк-метода в отдельном процессе (BenchmarkDotNet так и делает) .
- Очень легко сделать ошибку в самописном бенчмарке. Если бы мы писали руками бенчмарк для метода
Sum
, то лишний случайный вызов метода классаStopwatch
в корне бы изменил результаты нашего маленького эксперимента.