2023-12-05 07:48:20 UTC
HasFlag против битовых операций
Закрою для себя раз и навсегда споры о том, что быстрее — метод HasFlag
у перечислимого типа помеченного атрибутом [Flags]
или ручные битовые операции. Такие вопросы периодически возникают и споры могут быть жаркими.
Немного истории вопроса — ранее, в первых версиях .NET Framework, метод HasFlag
был существенно медленнее чем ручные битовые операции, просто по причине того, что Enum
это класс (хоть и производный от ValueType
, но тем не менее), при этом экземпляр конкретного перечислимого типа, является структурой. Метод HasFlag
определен именно в базовом классе (а не в наследуемой от него структуре). Далее, чтобы можно было вызвать метод класса Enum
, делается упаковка (boxing) конкретного экземпляра перечислимого типа, и делается вызов нужного метода, в нашем случае это HasFlag
. Кому интересно, можно почитать дискуссию на эту тему тут https://stackoverflow.com/questions/7368652/what-is-it-that-makes-enum-hasflag-so-slow
Справедливости ради, и сейчас, в IL коде происходит то же самое (boxing), но не спешим расстраиваться, — ситуация, как это обычно бывает, намного интереснее. Для начала напишем простенький бенчмарк. Например такой:
using System;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Diagnosers;
namespace vperf;
[Flags]
public enum Options : byte
{
Option1 = 1,
Option2 = 2,
Option3 = 4,
}
[DisassemblyDiagnoser(printInstructionAddresses: true, syntax: DisassemblySyntax.Intel)]
public class BenchmarkHasFlag
{
private readonly Options opt = Options.Option2;
[Benchmark(Baseline = true)]
public bool Plain() => (opt & Options.Option3) == Options.Option3;
[Benchmark]
public bool HasFlag() => opt.HasFlag(Options.Option3);
}
Перед запуском посмотрим в какой IL код компилируются оба метода. Сначала Plain
:
.method public hidebysig instance bool
Plain() cil managed
{
.custom instance void [BenchmarkDotNet.Annotations]BenchmarkDotNet.Attributes.BenchmarkAttribute::.ctor(int32, string)
= (
01 00 14 00 00 00 2e 2f 68 6f 6d 65 2f 65 67 72 // ......./home/egr
2f 63 6f 64 65 2f 76 70 65 72 66 2f 76 70 65 72 // /code/vperf/vper
66 2f 42 65 6e 63 68 6d 61 72 6b 48 61 73 46 6c // f/BenchmarkHasFl
61 67 2e 63 73 01 00 54 02 08 42 61 73 65 6c 69 // ag.cs..T..Baseli
6e 65 01 // ne.
)
// int32(20) // 0x00000014
// string('/home/egr/code/vperf/vperf/BenchmarkHasFlag.cs')
// property bool 'Baseline' = bool(true)
.maxstack 8
// [21 28 - 21 70]
IL_0000: ldarg.0 // this
IL_0001: ldfld valuetype vperf.Options vperf.BenchmarkHasFlag::'opt'
IL_0006: ldc.i4.4
IL_0007: and
IL_0008: ldc.i4.4
IL_0009: ceq
IL_000b: ret
} // end of method BenchmarkHasFlag::Plain
Далее HasFlag
:
.method public hidebysig instance bool
HasFlag() cil managed
{
.custom instance void [BenchmarkDotNet.Annotations]BenchmarkDotNet.Attributes.BenchmarkAttribute::.ctor(int32, string)
= (
01 00 17 00 00 00 2e 2f 68 6f 6d 65 2f 65 67 72 // ......./home/egr
2f 63 6f 64 65 2f 76 70 65 72 66 2f 76 70 65 72 // /code/vperf/vper
66 2f 42 65 6e 63 68 6d 61 72 6b 48 61 73 46 6c // f/BenchmarkHasFl
61 67 2e 63 73 00 00 // ag.cs..
)
// int32(23) // 0x00000017
// string('/home/egr/code/vperf/vperf/BenchmarkHasFlag.cs')
.maxstack 8
// [24 30 - 24 58]
IL_0000: ldarg.0 // this
IL_0001: ldfld valuetype vperf.Options vperf.BenchmarkHasFlag::'opt'
IL_0006: box vperf.Options
IL_000b: ldc.i4.4
IL_000c: box vperf.Options
IL_0011: call instance bool [System.Runtime]System.Enum::HasFlag(class [System.Runtime]System.Enum)
IL_0016: ret
} // end of method BenchmarkHasFlag::HasFlag
С виду у HasFlag
все плохо. Если в первом методе мы загружаем поле класса в стек, потом значение нужного флага, делаем побитовую операцию and
и сравниваем результат, то во втором мы также загружаем поле класса, делаем его упаковку (box), чтобы иметь возможность вызвать метод HasFlag
определенный в базовом классе Enum
(помним что Enum
это класс, а Options
это структура) и далее вызываем собственно нужный метод. Ужас — при боксинге по определению мы выделяем память (где — отдельный вопрос), что ужасно плохо для такой элементарной вещи.
запускаем бенчмарк.
BenchmarkDotNet v0.13.9+228a464e8be6c580ad9408e98f18813f6407fb5a, Manjaro Linux
Intel Core i9-9900K CPU 3.60GHz (Coffee Lake), 1 CPU, 16 logical and 8 physical cores
.NET SDK 7.0.403
[Host] : .NET 6.0.24 (6.0.2423.51814), X64 RyuJIT AVX2
DefaultJob : .NET 6.0.24 (6.0.2423.51814), X64 RyuJIT AVX2
Method | Mean | Error | StdDev | Median | Ratio | RatioSD | Code Size |
---|---|---|---|---|---|---|---|
Plain | 0.0000 ns | 0.0000 ns | 0.0000 ns | 0.0 ns | ? | ? | 11 B |
HasFlag | 0.0002 ns | 0.0006 ns | 0.0005 ns | 0.0 ns | ? | ? | 13 B |
Хмм … Что мы видим? Результат одинаковый! Т.е. кто-то, где-то мухлюет! А этот кто-то собственно наш JIT компилятор. У BenchmarkDotNet есть очень классная опция записывать реальный ассемблерный код, который выполняется на процессоре, т.е. результат работы JIT компилятора. Давайте его посмотрим:
.NET 6.0.24 (6.0.2423.51814), X64 RyuJIT AVX2
; vperf.BenchmarkHasFlag.Plain()
7F8B31DA3B10 test byte ptr [rdi+8],4
7F8B31DA3B14 setne al
7F8B31DA3B17 movzx eax,al
7F8B31DA3B1A ret
; Total bytes of code 11
.NET 6.0.24 (6.0.2423.51814), X64 RyuJIT AVX2
; vperf.BenchmarkHasFlag.HasFlag()
7FB1BC573CD0 movzx eax,byte ptr [rdi+8]
7FB1BC573CD4 test al,4
7FB1BC573CD6 setne al
7FB1BC573CD9 movzx eax,al
7FB1BC573CDC ret
; Total bytes of code 13
а мы видим что код одинаковый! Т.е. по большому счету, нам без разницы что использовать и мы можем выбирать что проще в чтении и понимании, т.е. в нашем случае это использование метода HasFlag
. Мораль сей истории — не спешите пользоваться низкоуровневыми (и плохо читаемыми) вещами, попробуйте использовать библиотечные вещи и доверьтесь JIT компилятору, он в .NET весьма продвинутый.