Скорость работы скомпилированного Linq Expression Tree

    Вот в этой статье в комментариях произошёл не то, чтобы спор, но некоторое «не схождение» в сравнении скорости IL Emit и скомпилированного Linq Expression Tree.

    Данная мини статья — код теста скорости + результаты прогона этого теста.

    Для теста был выбран код, который пересекается с содержимым изначальной статьи — сериализация в поток длинны строки, а затем — всех байтов строки в кодировке UTF-16 (Encoding.Unicode).

    Сам код сериализации, возможно, не самый оптимальный, но близок к тому, если не пользоваться unsafe конструкциями.

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

    Я не стал заморачиваться с генерацией IL через Emit — код, который должен быть «оптимальным» я просто написал на C# в статическом методе (на самом деле, все методы тестовой программы — статические, т.к. это очень простое консольное приложение) — этот метод далее назван Native.

    Второй метод для сравнения — сгенерированное Lambda выражение, скомилированное вызовом метода Compile (возможно, прирост скорости может дать использование CompileToMethod, но это — не точно) — этот метод далее назван Expresisons.

    БОНУС! По некоторому раздумью, был добавлен дополнительный тест — косвенный вызов метода, использованного в тесте Native — через присвоение метода в статическое поле-делегат — этот метод назван Native Dlgt.

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

    Итак, вот код приложения (весь сразу):

    Листинг
    using System;
    using System.Diagnostics;
    using System.IO;
    using System.Linq;
    using System.Linq.Expressions;
    using System.Reflection;
    using System.Text;
    
    namespace ExpressionSpeedTest {
    	class Program {
    		static void Main(string[] args) {
    			InitExpression();
    			InitDelegateNative();
    			var inst = new TestClass {
    				StringProp = "abcdefabcdef"
    			};
    			byte[] buff1, buff2;
    			using (var ms1 = new MemoryStream()) {
    				SaveString(ms1, inst);
    				buff1 = ms1.ToArray();
    			}
    
    			using (var ms2 = new MemoryStream()) {
    				DynamicMethod(ms2, inst);
    				buff2 = ms2.ToArray();
    			}
    
    			Console.WriteLine($"Native      string: {string.Join("", buff1.Select(b => Encoding.Default.GetString(new[] { b })))}");
    			Console.WriteLine($"Expressions string: {string.Join("", buff2.Select(b => Encoding.Default.GetString(new[] { b })))}");
    
    			GC.Collect(GC.MaxGeneration);
    			GC.WaitForPendingFinalizers();
    			GC.Collect(GC.MaxGeneration);
    			TestNative();
    
    			GC.Collect(GC.MaxGeneration);
    			GC.WaitForPendingFinalizers();
    			GC.Collect(GC.MaxGeneration);
    			TestDelegateNative();
    
    			GC.Collect(GC.MaxGeneration);
    			GC.WaitForPendingFinalizers();
    			GC.Collect(GC.MaxGeneration);
    			TestExpressions();
    
    			GC.Collect(GC.MaxGeneration);
    			GC.WaitForPendingFinalizers();
    			GC.Collect(GC.MaxGeneration);
    
    			Console.ReadLine();
    		}
    
    		private static void TestDelegateNative() {
    			var inst = new TestClass {
    				StringProp = "abcdefabcdef"
    			};
    			using (var ms = new MemoryStream()) {
    				var sw = new Stopwatch();
    				sw.Start();
    				for (var idx = 0; idx < loopLength; idx++) {
    					SaveString(ms, inst);
    				}
    				sw.Stop();
    				Console.WriteLine($"Native Dlgt test: {sw.Elapsed}, {sw.ElapsedTicks} ticks");
    			}
    		}
    
    		private static void InitDelegateNative() {
    			NativeDelegate = SaveString;
    		}
    
    		private static void InitExpression() {
    			var intGetBytes = typeof(BitConverter).GetMethods(BindingFlags.Static | BindingFlags.Public)
    				.Single(x => x.Name == nameof(BitConverter.GetBytes) && x.GetParameters()[0].ParameterType == typeof(int));
    			var stringGetBytes = typeof(Encoding).GetMethods(BindingFlags.Instance | BindingFlags.Public)
    				.Single(x => x.Name == nameof(Encoding.GetBytes) && x.GetParameters().Length == 1 && x.GetParameters()[0].ParameterType == typeof(string));
    			var unicodeProp = typeof(Encoding).GetProperty(nameof(Encoding.Unicode), BindingFlags.Static | BindingFlags.Public);
    			var streamWrite = typeof(Stream).GetMethod(nameof(Stream.Write));
    			var streamPar = Expression.Parameter(typeof(Stream), "stream");
    			var instPar = Expression.Parameter(typeof(TestClass), "inst");
    			var intBuffVar = Expression.Variable(typeof(byte[]), "intBuff");
    			var strBuffVar = Expression.Variable(typeof(byte[]), "strBuff");
    			var expressionBody = Expression.Block(
    				new[] { intBuffVar, strBuffVar },
    				Expression.Assign(intBuffVar,
    					Expression.Call(null,
    						intGetBytes,
    						Expression.Property(
    							Expression.Property(
    								instPar,
    								nameof(TestClass.StringProp)),
    							nameof(string.Length)))),
    				Expression.Assign(strBuffVar,
    					Expression.Call(Expression.Property(null, unicodeProp),
    						stringGetBytes,
    						Expression.Property(
    							instPar,
    							nameof(TestClass.StringProp)
    						))),
    				Expression.Call(streamPar, streamWrite, intBuffVar, Expression.Constant(0), Expression.Property(intBuffVar, nameof(Array.Length))),
    				Expression.Call(streamPar, streamWrite, strBuffVar, Expression.Constant(0), Expression.Property(strBuffVar, nameof(Array.Length)))
    				);
    
    			DynamicMethod = Expression.Lambda<Action<Stream, TestClass>>(expressionBody, streamPar, instPar).Compile();
    		}
    
    		private const int loopLength = 10000000;
    		private static Action<Stream, TestClass> DynamicMethod;
    		private static Action<Stream, TestClass> NativeDelegate;
    
    		private static void TestExpressions() {
    			var inst = new TestClass {
    				StringProp = "abcdefabcdef"
    			};
    			using (var ms = new MemoryStream()) {
    				var sw = new Stopwatch();
    				sw.Start();
    				for (var idx = 0; idx < loopLength; idx++) {
    					DynamicMethod(ms, inst);
    				}
    				sw.Stop();
    				Console.WriteLine($"Expressions test: {sw.Elapsed}, {sw.ElapsedTicks} ticks");
    			}
    		}
    
    		private static void TestNative() {
    			var inst = new TestClass {
    				StringProp = "abcdefabcdef"
    			};
    			using (var ms = new MemoryStream()) {
    				var sw = new Stopwatch();
    				sw.Start();
    				for (var idx = 0; idx < loopLength; idx++) {
    					SaveString(ms, inst);
    				}
    				sw.Stop();
    				Console.WriteLine($"Native      test: {sw.Elapsed}, {sw.ElapsedTicks} ticks");
    			}
    		}
    
    		public static void SaveString(Stream stream, TestClass instance) {
    			var intBuff = BitConverter.GetBytes(instance.StringProp.Length);
    			var strBuff = Encoding.Unicode.GetBytes(instance.StringProp);
    			stream.Write(intBuff, 0, intBuff.Length);
    			stream.Write(strBuff, 0, strBuff.Length);
    		}
    	}
    
    	class TestClass {
    		public string StringProp { get; set; }
    	}
    }
    


    А вот результаты теста на следующей конфигурации:

    CPU
    	Intel(R) Core(TM) i7-3770 CPU @ 3.40GHz
    
    	Base speed:	3,90 GHz
    	Sockets:	1
    	Cores:	4
    	Logical processors:	8
    	Virtualization:	Enabled
    	L1 cache:	256 KB
    	L2 cache:	1,0 MB
    	L3 cache:	8,0 MB
    
    	Utilization	8%
    	Speed	4,05 GHz
    	Up time	5:00:43:01
    	Processes	239
    	Threads	4092
    	Handles	168774
    
    

    Memory
    	32,0 GB DDR3
    
    	Speed:	1600 MHz
    	Slots used:	4 of 4
    	Form factor:	DIMM
    	Hardware reserved:	42,5 MB
    
    	Available	20,7 GB
    	Cached	20,1 GB
    	Committed	13,4/36,7 GB
    	Paged pool	855 MB
    	Non-paged pool	442 MB
    	In use (Compressed)	11,2 GB (48,6 MB)
    

    Target .Net Framework 4.7
    OS Windows 10 Pro x64 1803 build 17134.48

    Итак, обещанные результаты:

    Компиляция в Debug, без оптимизации, запуск без отладчика (Ctrl+F5):
    Test Time (timespan) Time (ticks) %time
    Native 00:00:01.5760651 15760651 101.935%
    Native Dlgt 00:00:01.5461478 15461478 100%
    Expressions 00:00:01.5835454 15835454 102.4188%

    Компиляция в Release, с опимизацией, запуск без отладчика (Ctrl+F5):
    Test Time (timespan) Time (ticks) %time
    Native 00:00:01.3182291
    13182291
    100%
    Native Dlgt 00:00:01.3300925
    13300925
    100.8999%
    Expressions 00:00:01.4871786
    14871786
    112.8164%

    Можно подвести некоторые итоги:

    • Скомпилированные Expression Tree работают на 1-2 % медленнее в Debug и на 10-12 в Release, что очень даже хорошо, с учётом, что генерировать код через Expression Tree в runtime в разы проще.
    • В Debug режиме, почему-то, косвенный вызов метода через делегат работает быстрее, чем прямой вызов.

    Бонус №2 под спойлером — Debug представление Lambda выражения до компиляции:

    Lambda
    .Lambda #Lambda1<System.Action`2[System.IO.Stream,ExpressionSpeedTest.TestClass]>(
        System.IO.Stream $stream,
        ExpressionSpeedTest.TestClass $inst) {
        .Block(
            System.Byte[] $intBuff,
            System.Byte[] $strBuff) {
            $intBuff = .Call System.BitConverter.GetBytes(($inst.StringProp).Length);
            $strBuff = .Call (System.Text.Encoding.Unicode).GetBytes($inst.StringProp);
            .Call $stream.Write(
                $intBuff,
                0,
                $intBuff.Length);
            .Call $stream.Write(
                $strBuff,
                0,
                $strBuff.Length)
        }
    }
    

    Similar posts

    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 8

      +10
      Попробуйте BenchmarkDotNet. Я уверен что он даст гораздо более точные результаты, к тому же он удобен и у него большой функционал. Ну и замерять в Debug режиме смысла много нету, так как финальное приложение в любом случае будет работать в Release.
        –4
        Я видел легаси которое не могло скомпилиться в релизе потому что когда-то что-то сломали и никто не помнит что и где. Так что, кто знает. Авось пригодится.
        +3
        О-о-о, можно очень жестко похоливарить.
        1. Статический метод — это не вполне тоже самое, что метод подгружаемый с Reflection.Emit. Например, Reflection.Emit метод может подгружаться через сохраненную локально сгенерированную dll, а может подгружаться в отдельную динамическую либу.
        2. Для замеров лучше использовать BenchmarkDotNet. Не то чтобы в замерах есть ошибки — просто меньше «сервисного» листинга.
        3. Скорее всего, большую часть времени замера сжирают BitConverter.GetBytes и stream.Write методы. По факту, вы замеряете (время исполнения методов фреймворка + время исполнения сравниваемого кода).

        Иными словами, в данном end-to-end сценарии можно использовать ExpressionTrees или Reflection.Emit — разница в производительности будет минимальна. В другом end-to-end сценарии, где меньше библиотечных вызовов и больше работы непосредственно с базовыми конструкциями C# — разница может быть существенной.

        Чтобы не ограничиваться лишь критикой: идея замерять именно полный сценарий исполнения вполне здравая. Так, по результатам вашего замера можно сразу сказать: производительность одинаковая, кодируем то, что проще.

          +2

          Есть еще такой проект интересный: FastExpressionCompiler, призванный как раз сократить разницу между Expression.Compile и Reflection.Emit.


          Цитата оттуда:


          The question is, why is the compiled delegate way slower than a manually-written delegate? Expression.Compile creates a DynamicMethod and associates it with an anonymous assembly to run it in a sandboxed environment. This makes it safe for a dynamic method to be emitted and executed by partially trusted code but adds some run-time overhead.
          +2
          Сделал аналогичный тест, только без тяжелых методов фреймворка и аллокаций.
          Тестовый метод считает побайтовый XOR от длинны строки и её символов.
          Мой вывод: разница на уровне погрешности, что удобнее для задачи, то и следует использовать.
          Код: gist.github.com

          Подробные результаты
          BenchmarkDotNet=v0.10.14, OS=Windows 7 SP1 (6.1.7601.0)
          Intel Core i5-2500 CPU 3.30GHz (Sandy Bridge), 1 CPU, 4 logical and 4 physical cores, Frequency=3232187 Hz, Resolution=309.3880 ns, Timer=TSC
            Clr: .NET Framework 4.6.2 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.6.1590.0
          
          Method                                    Mean      Error      StdDev
          Native                                  307.6 us   1.688 us   1.579 us
          NativeUnsafe                            292.5 us   2.178 us   2.037 us
          NativeDelegate                          307.8 us   1.743 us   1.631 us
          LinqExpressions                         308.0 us   2.393 us   2.239 us
          ReflectionEmitExpressionRunAndSave      307.4 us   1.437 us   1.344 us
          ReflectionEmitExpressionRunAndCollect   307.5 us   1.776 us   1.575 us
          ReflectionEmitExpressionRun             306.9 us   1.628 us   1.522 us
          ReflectionEmitNativeRunAndSave          307.2 us   1.185 us   1.108 us
          ReflectionEmitNativeRunAndCollect       308.8 us   2.162 us   2.022 us
          ReflectionEmitNativeRun                 307.0 us   1.474 us   1.378 us
          

            0
            У Вас ошибка в листинге кода: TestNative и TestDelegateNative используют метод SaveString, но не через вызов делегата NativeDelegate.
            Не знаю какой прирост даст в данном примере, но думаю статик филды делегатов лучше кешировать в локальную переменную, для чистоты теста!
              0
              Спасибо, тогда вообще непонятно, почему такие результаты :)
                +1
                Испоользуйте BenchmarkDotNet.
                Он более акуратно проведет тесты, плюс выдаст ошибку измерений!
                Нравится простота в использовании.

            Only users with full accounts can post comments. Log in, please.