diff --git a/.gitignore b/.gitignore index 9997611..ec89885 100644 --- a/.gitignore +++ b/.gitignore @@ -109,6 +109,7 @@ stylecop.* ~$* *.dbmdl Generated_Code #added for RIA/Silverlight projects +BenchmarkDotNet.Artifacts/ # Backup & report files from converting an old project file to a newer # Visual Studio version. Backup files are not needed, because we have git ;-) diff --git a/DynamicExpresso.sln b/DynamicExpresso.sln index 52825f2..1a4e450 100644 --- a/DynamicExpresso.sln +++ b/DynamicExpresso.sln @@ -12,24 +12,63 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{4088369E-A EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DynamicExpresso.Core", "src\DynamicExpresso.Core\DynamicExpresso.Core.csproj", "{C6B7C0D2-B84A-4307-9C61-D95613DB564D}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmark", "benchmark", "{09EED85C-BE3C-7566-DC0E-2E8E43466740}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DynamicExpresso.Benchmarks", "benchmark\DynamicExpresso.Benchmarks\DynamicExpresso.Benchmarks.csproj", "{394496C4-B878-4F0C-8471-2417F3205FC8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {33157A92-C6B2-4A51-8262-1FEBFD6558BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {33157A92-C6B2-4A51-8262-1FEBFD6558BE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {33157A92-C6B2-4A51-8262-1FEBFD6558BE}.Debug|x64.ActiveCfg = Debug|Any CPU + {33157A92-C6B2-4A51-8262-1FEBFD6558BE}.Debug|x64.Build.0 = Debug|Any CPU + {33157A92-C6B2-4A51-8262-1FEBFD6558BE}.Debug|x86.ActiveCfg = Debug|Any CPU + {33157A92-C6B2-4A51-8262-1FEBFD6558BE}.Debug|x86.Build.0 = Debug|Any CPU {33157A92-C6B2-4A51-8262-1FEBFD6558BE}.Release|Any CPU.ActiveCfg = Release|Any CPU {33157A92-C6B2-4A51-8262-1FEBFD6558BE}.Release|Any CPU.Build.0 = Release|Any CPU + {33157A92-C6B2-4A51-8262-1FEBFD6558BE}.Release|x64.ActiveCfg = Release|Any CPU + {33157A92-C6B2-4A51-8262-1FEBFD6558BE}.Release|x64.Build.0 = Release|Any CPU + {33157A92-C6B2-4A51-8262-1FEBFD6558BE}.Release|x86.ActiveCfg = Release|Any CPU + {33157A92-C6B2-4A51-8262-1FEBFD6558BE}.Release|x86.Build.0 = Release|Any CPU {C6B7C0D2-B84A-4307-9C61-D95613DB564D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C6B7C0D2-B84A-4307-9C61-D95613DB564D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C6B7C0D2-B84A-4307-9C61-D95613DB564D}.Debug|x64.ActiveCfg = Debug|Any CPU + {C6B7C0D2-B84A-4307-9C61-D95613DB564D}.Debug|x64.Build.0 = Debug|Any CPU + {C6B7C0D2-B84A-4307-9C61-D95613DB564D}.Debug|x86.ActiveCfg = Debug|Any CPU + {C6B7C0D2-B84A-4307-9C61-D95613DB564D}.Debug|x86.Build.0 = Debug|Any CPU {C6B7C0D2-B84A-4307-9C61-D95613DB564D}.Release|Any CPU.ActiveCfg = Release|Any CPU {C6B7C0D2-B84A-4307-9C61-D95613DB564D}.Release|Any CPU.Build.0 = Release|Any CPU + {C6B7C0D2-B84A-4307-9C61-D95613DB564D}.Release|x64.ActiveCfg = Release|Any CPU + {C6B7C0D2-B84A-4307-9C61-D95613DB564D}.Release|x64.Build.0 = Release|Any CPU + {C6B7C0D2-B84A-4307-9C61-D95613DB564D}.Release|x86.ActiveCfg = Release|Any CPU + {C6B7C0D2-B84A-4307-9C61-D95613DB564D}.Release|x86.Build.0 = Release|Any CPU + {394496C4-B878-4F0C-8471-2417F3205FC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {394496C4-B878-4F0C-8471-2417F3205FC8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {394496C4-B878-4F0C-8471-2417F3205FC8}.Debug|x64.ActiveCfg = Debug|Any CPU + {394496C4-B878-4F0C-8471-2417F3205FC8}.Debug|x64.Build.0 = Debug|Any CPU + {394496C4-B878-4F0C-8471-2417F3205FC8}.Debug|x86.ActiveCfg = Debug|Any CPU + {394496C4-B878-4F0C-8471-2417F3205FC8}.Debug|x86.Build.0 = Debug|Any CPU + {394496C4-B878-4F0C-8471-2417F3205FC8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {394496C4-B878-4F0C-8471-2417F3205FC8}.Release|Any CPU.Build.0 = Release|Any CPU + {394496C4-B878-4F0C-8471-2417F3205FC8}.Release|x64.ActiveCfg = Release|Any CPU + {394496C4-B878-4F0C-8471-2417F3205FC8}.Release|x64.Build.0 = Release|Any CPU + {394496C4-B878-4F0C-8471-2417F3205FC8}.Release|x86.ActiveCfg = Release|Any CPU + {394496C4-B878-4F0C-8471-2417F3205FC8}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {394496C4-B878-4F0C-8471-2417F3205FC8} = {09EED85C-BE3C-7566-DC0E-2E8E43466740} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A36C3463-448E-4051-AE87-A2994E36C1EC} EndGlobalSection diff --git a/README.md b/README.md index 1533f6c..ba05cac 100644 --- a/README.md +++ b/README.md @@ -560,6 +560,12 @@ or run unit tests for a specific project with a specific framework: Add `--logger:trx` to generate test results for VSTS. +## Benchmarks + +This repository includes a BenchmarkDotNet project under `benchmark/DynamicExpresso.Benchmarks` to measure interpreter hot-paths. + + dotnet run -c Release --project benchmark/DynamicExpresso.Benchmarks + ## Release notes See [releases page](https://github.com/dynamicexpresso/DynamicExpresso/releases). diff --git a/benchmark/DynamicExpresso.Benchmarks/DynamicExpresso.Benchmarks.csproj b/benchmark/DynamicExpresso.Benchmarks/DynamicExpresso.Benchmarks.csproj new file mode 100644 index 0000000..c2afd82 --- /dev/null +++ b/benchmark/DynamicExpresso.Benchmarks/DynamicExpresso.Benchmarks.csproj @@ -0,0 +1,12 @@ + + + Exe + net8.0 + enable + enable + + + + + + diff --git a/benchmark/DynamicExpresso.Benchmarks/LambdaBenchmarks.cs b/benchmark/DynamicExpresso.Benchmarks/LambdaBenchmarks.cs new file mode 100644 index 0000000..ab4e079 --- /dev/null +++ b/benchmark/DynamicExpresso.Benchmarks/LambdaBenchmarks.cs @@ -0,0 +1,94 @@ +using BenchmarkDotNet.Attributes; + +namespace DynamicExpresso.Benchmarks; + +[MemoryDiagnoser] +public class LambdaBenchmarks +{ + private const string ExpressionText = + "((a + b) * c - (double)d / (e + f + 1)) + Math.Max(a, b)"; + + private Interpreter _interpreter = null!; + + private Lambda _lambda = null!; + + private object[] _args = null!; + private Parameter[] _declared = null!; + private Parameter[] _parameterValues = null!; + + private const int A = 1; + private const int B = 2; + private const int C = 3; + private const int D = 4; + private const int E = 5; + private const int F = 6; + + [GlobalSetup] + public void Setup() + { + _interpreter = new Interpreter(); + + _declared = new[] + { + new Parameter("a", typeof(int)), + new Parameter("b", typeof(int)), + new Parameter("c", typeof(int)), + new Parameter("d", typeof(int)), + new Parameter("e", typeof(int)), + new Parameter("f", typeof(int)) + }; + + _lambda = _interpreter.Parse( + ExpressionText, + typeof(double), + _declared); + + _args = new object[] { A, B, C, D, E, F }; + + _parameterValues = new[] + { + new Parameter("a", typeof(int), A), + new Parameter("b", typeof(int), B), + new Parameter("c", typeof(int), C), + new Parameter("d", typeof(int), D), + new Parameter("e", typeof(int), E), + new Parameter("f", typeof(int), F) + }; + } + + [Benchmark(Description = "Invoke cached lambda (object[])")] + public double Invoke_ObjectArray() + { + double sum = 0; + for (var i = 0; i < 100_000; i++) + { + sum += (double)_lambda.Invoke(_args); + } + + return sum; + } + + [Benchmark(Description = "Invoke cached lambda (IEnumerable)")] + public double Invoke_ParametersEnumerable() + { + double sum = 0; + for (var i = 0; i < 100_000; i++) + { + sum += (double)_lambda.Invoke(_parameterValues); + } + + return sum; + } + + [Benchmark(Description = "Eval (IEnumerable)")] + public double Eval_ParametersEnumerable() + { + double sum = 0; + for (var i = 0; i < 100_000; i++) + { + sum += _interpreter.Eval(ExpressionText, _parameterValues); + } + + return sum; + } +} diff --git a/benchmark/DynamicExpresso.Benchmarks/Program.cs b/benchmark/DynamicExpresso.Benchmarks/Program.cs new file mode 100644 index 0000000..c9a0467 --- /dev/null +++ b/benchmark/DynamicExpresso.Benchmarks/Program.cs @@ -0,0 +1,3 @@ +using BenchmarkDotNet.Running; + +BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); diff --git a/src/DynamicExpresso.Core/Interpreter.cs b/src/DynamicExpresso.Core/Interpreter.cs index ce84fc0..f7f49f0 100644 --- a/src/DynamicExpresso.Core/Interpreter.cs +++ b/src/DynamicExpresso.Core/Interpreter.cs @@ -523,7 +523,9 @@ public T Eval(string expressionText, params Parameter[] parameters) /// public object Eval(string expressionText, Type expressionType, params Parameter[] parameters) { - return Parse(expressionText, expressionType, parameters).Invoke(parameters); + // Eval is intended for one-off expressions: prefer interpretation to avoid IL generation cost. + var lambda = ParseAsLambda(expressionText, expressionType, parameters, preferInterpretation: true); + return lambda.Invoke(parameters); } #endregion @@ -548,7 +550,7 @@ public IdentifiersInfo DetectIdentifiers(string expression, DetectorOptions opti #region Private methods - private Lambda ParseAsLambda(string expressionText, Type expressionType, Parameter[] parameters) + private Lambda ParseAsLambda(string expressionText, Type expressionType, Parameter[] parameters, bool preferInterpretation = false) { var arguments = new ParserArguments( expressionText, @@ -561,7 +563,7 @@ private Lambda ParseAsLambda(string expressionText, Type expressionType, Paramet foreach (var visitor in Visitors) expression = visitor.Visit(expression); - var lambda = new Lambda(expression, arguments); + var lambda = new Lambda(expression, arguments, preferInterpretation); #if TEST_DetectIdentifiers AssertDetectIdentifiers(lambda); diff --git a/src/DynamicExpresso.Core/Lambda.cs b/src/DynamicExpresso.Core/Lambda.cs index 23b8f91..4f7c3a7 100644 --- a/src/DynamicExpresso.Core/Lambda.cs +++ b/src/DynamicExpresso.Core/Lambda.cs @@ -16,16 +16,21 @@ public class Lambda { private readonly Expression _expression; private readonly ParserArguments _parserArguments; - private readonly Lazy _delegate; + private readonly InvocationContext _invocation; - internal Lambda(Expression expression, ParserArguments parserArguments) + internal Lambda(Expression expression, ParserArguments parserArguments, bool preferInterpretation = false) { _expression = expression ?? throw new ArgumentNullException(nameof(expression)); _parserArguments = parserArguments ?? throw new ArgumentNullException(nameof(parserArguments)); - // Note: I always lazy compile the generic lambda. Maybe in the future this can be a setting because if I generate a typed delegate this compilation is not required. - _delegate = new Lazy(() => - Expression.Lambda(_expression, _parserArguments.UsedParameters.Select(p => p.Expression).ToArray()).Compile()); + var settings = _parserArguments.Settings; + _invocation = new InvocationContext( + expression, + _parserArguments.DeclaredParameters.ToArray(), + _parserArguments.UsedParameters.ToArray(), + settings.KeyComparison, + settings.KeyComparer, + preferInterpretation); } public Expression Expression { get { return _expression; } } @@ -38,25 +43,26 @@ internal Lambda(Expression expression, ParserArguments parserArguments) /// /// The used parameters. [Obsolete("Use UsedParameters or DeclaredParameters")] - public IEnumerable Parameters { get { return _parserArguments.UsedParameters; } } + public IEnumerable Parameters { get { return _invocation.UsedParameters; } } /// /// Gets the parameters actually used in the expression parsed. /// /// The used parameters. - public IEnumerable UsedParameters { get { return _parserArguments.UsedParameters; } } + public IEnumerable UsedParameters { get { return _invocation.UsedParameters; } } + /// /// Gets the parameters declared when parsing the expression. /// /// The declared parameters. - public IEnumerable DeclaredParameters { get { return _parserArguments.DeclaredParameters; } } + public IEnumerable DeclaredParameters { get { return _invocation.DeclaredParameters; } } public IEnumerable Types { get { return _parserArguments.UsedTypes; } } public IEnumerable Identifiers { get { return _parserArguments.UsedIdentifiers; } } public object Invoke() { - return InvokeWithUsedParameters(new object[0]); + return _invocation.InvokeNoArgs(); } public object Invoke(params Parameter[] parameters) @@ -64,59 +70,24 @@ public object Invoke(params Parameter[] parameters) return Invoke((IEnumerable)parameters); } + /// + /// Invoke the expression with the given named parameters. + /// Parameters are matched by name against the parameters actually used in the expression. + /// public object Invoke(IEnumerable parameters) { - var args = (from usedParameter in UsedParameters - from actualParameter in parameters - where usedParameter.Name.Equals(actualParameter.Name, _parserArguments.Settings.KeyComparison) - select actualParameter.Value) - .ToArray(); - - return InvokeWithUsedParameters(args); + return _invocation.InvokeFromNamed(parameters); } /// - /// Invoke the expression with the given parameters values. + /// Invoke the expression with the given parameter values. + /// The values are in the same order as the parameters declared when parsing (DeclaredParameters). + /// Only the parameters actually used in the expression are passed to the underlying delegate. /// - /// Order of parameters must be the same of the parameters used during parse (DeclaredParameters). - /// + /// Values for declared parameters, in declared order. public object Invoke(params object[] args) { - var parameters = new List(); - var declaredParameters = DeclaredParameters.ToArray(); - - if (args != null) - { - if (declaredParameters.Length != args.Length) - throw new InvalidOperationException(ErrorMessages.ArgumentCountMismatch); - - for (var i = 0; i < args.Length; i++) - { - var parameter = new Parameter( - declaredParameters[i].Name, - declaredParameters[i].Type, - args[i]); - - parameters.Add(parameter); - } - } - - return Invoke(parameters); - } - - private object InvokeWithUsedParameters(object[] orderedArgs) - { - try - { - return _delegate.Value.DynamicInvoke(orderedArgs); - } - catch (TargetInvocationException exc) - { - if (exc.InnerException != null) - ExceptionDispatchInfo.Capture(exc.InnerException).Throw(); - - throw; - } + return _invocation.InvokeFromDeclared(args); } public override string ToString() @@ -127,7 +98,10 @@ public override string ToString() /// /// Generate the given delegate by compiling the lambda expression. /// - /// The delegate to generate. Delegate parameters must match the one defined when creating the expression, see UsedParameters. + /// + /// The delegate to generate. Delegate parameters must match the ones defined + /// when creating the expression, see DeclaredParameters. + /// public TDelegate Compile() { var lambdaExpression = LambdaExpression(); @@ -145,24 +119,179 @@ public TDelegate Compile(IEnumerable parameters) /// Generate a lambda expression. /// /// The lambda expression. - /// The delegate to generate. Delegate parameters must match the one defined when creating the expression, see UsedParameters. + /// + /// The delegate to generate. Delegate parameters must match the ones defined + /// when creating the expression, see DeclaredParameters. + /// public Expression LambdaExpression() { - return Expression.Lambda(_expression, DeclaredParameters.Select(p => p.Expression).ToArray()); + return Expression.Lambda(_expression, _invocation.DeclaredParameterExpressions); } internal LambdaExpression LambdaExpression(Type delegateType) { - var parameterExpressions = DeclaredParameters.Select(p => p.Expression).ToArray(); + var parameterExpressions = _invocation.DeclaredParameterExpressions; var types = delegateType.GetGenericArguments(); // return type - if (delegateType.GetGenericTypeDefinition() == ReflectionExtensions.GetFuncType(parameterExpressions.Length)) + var genericType = delegateType.GetGenericTypeDefinition(); + if (genericType == ReflectionExtensions.GetFuncType(parameterExpressions.Length)) types[types.Length - 1] = _expression.Type; - var genericType = delegateType.GetGenericTypeDefinition(); var inferredDelegateType = genericType.MakeGenericType(types); return Expression.Lambda(inferredDelegateType, _expression, parameterExpressions); } + + private sealed class InvocationContext + { + private readonly Expression _expression; + private readonly Parameter[] _declaredParameters; + private readonly Parameter[] _usedParameters; + private readonly StringComparison _keyComparison; + private readonly IEqualityComparer _keyComparer; + private readonly bool _preferInterpretation; + + // used index -> declared index + private readonly int[] _usedToDeclaredIndex; + + // Delegate with parameters in UsedParameters order. + private readonly Lazy _delegate; + + public InvocationContext( + Expression expression, + Parameter[] declaredParameters, + Parameter[] usedParameters, + StringComparison keyComparison, + IEqualityComparer keyComparer, + bool preferInterpretation) + { + _expression = expression ?? throw new ArgumentNullException(nameof(expression)); + _declaredParameters = declaredParameters ?? Array.Empty(); + _usedParameters = usedParameters ?? Array.Empty(); + _keyComparison = keyComparison; + _keyComparer = keyComparer ?? StringComparer.InvariantCulture; + _preferInterpretation = preferInterpretation; + + DeclaredParameterExpressions = _declaredParameters.Select(p => p.Expression).ToArray(); + + _delegate = new Lazy(() => + Expression.Lambda(_expression, _usedParameters.Select(p => p.Expression).ToArray()) + .Compile(_preferInterpretation)); + + _usedToDeclaredIndex = BuildUsedToDeclaredIndex(_declaredParameters, _usedParameters, _keyComparer); + } + + public Parameter[] DeclaredParameters => _declaredParameters; + public Parameter[] UsedParameters => _usedParameters; + public ParameterExpression[] DeclaredParameterExpressions { get; } + + public object InvokeNoArgs() + { + return InvokeWithUsedParameters(Array.Empty()); + } + + public object InvokeFromDeclared(object[] args) + { + if (args == null) + return InvokeNoArgs(); + + if (_declaredParameters.Length != args.Length) + throw new InvalidOperationException(ErrorMessages.ArgumentCountMismatch); + + if (_usedParameters.Length == 0) + return InvokeWithUsedParameters(Array.Empty()); + + var usedArgs = BuildUsedArgsFromDeclared(args); + return InvokeWithUsedParameters(usedArgs); + } + + public object InvokeFromNamed(IEnumerable parameters) + { + if (parameters == null) + throw new ArgumentNullException(nameof(parameters)); + + if (_usedParameters.Length == 0) + return InvokeWithUsedParameters(Array.Empty()); + + var paramList = parameters as IList ?? parameters.ToArray(); + var matchedValues = new List(_usedParameters.Length); + + foreach (var used in _usedParameters) + { + foreach (var actual in paramList) + { + if (actual != null && + string.Equals(used.Name, actual.Name, _keyComparison)) + { + matchedValues.Add(actual.Value); + } + } + } + + return InvokeWithUsedParameters(matchedValues.ToArray()); + } + + private object InvokeWithUsedParameters(object[] orderedArgs) + { + try + { + return _delegate.Value.DynamicInvoke(orderedArgs); + } + catch (TargetInvocationException exc) + { + if (exc.InnerException != null) + ExceptionDispatchInfo.Capture(exc.InnerException).Throw(); + + throw; + } + } + + private static int[] BuildUsedToDeclaredIndex( + IReadOnlyList declaredParameters, + IReadOnlyList usedParameters, + IEqualityComparer keyComparer) + { + if (usedParameters.Count == 0) + return Array.Empty(); + + if (declaredParameters.Count == 0) + throw new InvalidOperationException("Used parameters exist but there are no declared parameters."); + + var nameToDeclaredIndex = new Dictionary(declaredParameters.Count, keyComparer); + for (var i = 0; i < declaredParameters.Count; i++) + { + nameToDeclaredIndex[declaredParameters[i].Name] = i; + } + + var map = new int[usedParameters.Count]; + for (var i = 0; i < usedParameters.Count; i++) + { + var usedName = usedParameters[i].Name; + if (!nameToDeclaredIndex.TryGetValue(usedName, out var declaredIndex)) + throw new InvalidOperationException( + $"Used parameter '{usedName}' was not found in declared parameters."); + + map[i] = declaredIndex; + } + + return map; + } + + private object[] BuildUsedArgsFromDeclared(object[] declaredArgs) + { + if (_usedParameters.Length == 0) + return Array.Empty(); + + var used = new object[_usedParameters.Length]; + for (var i = 0; i < _usedParameters.Length; i++) + { + var declaredIndex = _usedToDeclaredIndex[i]; + used[i] = declaredArgs[declaredIndex]; + } + + return used; + } + + } } }