diff --git a/src/FastExpressionCompiler/FastExpressionCompiler.cs b/src/FastExpressionCompiler/FastExpressionCompiler.cs index 8ee51c68..6a1c0b69 100644 --- a/src/FastExpressionCompiler/FastExpressionCompiler.cs +++ b/src/FastExpressionCompiler/FastExpressionCompiler.cs @@ -649,6 +649,8 @@ public struct CompilerContext /// The map of inlined invocations collected in TryCollect and then used in TryEmit internal SmallMap4> InlinedLambdaInvocation; + internal ExpressionReductionCache ReducedExpressions; + /// New or Call expressions containing the complex expression, e.g. inlined Lambda Invoke or Try with Finally internal SmallSet4> ArgsContainingComplexExpression; @@ -1699,8 +1701,8 @@ public static Result TryCollectInfo(ref CompilerContext context, Expression expr return r; case ExpressionType.Extension: - var reduced = expr.Reduce(); - if (reduced == null || ReferenceEquals(reduced, expr)) + var reduced = TryReduceNode(expr, ref context); + if (reduced == null) return Result.NotSupported_UnknownExpression; expr = reduced; enterable = false; @@ -1752,11 +1754,55 @@ public static Result TryCollectInfo(ref CompilerContext context, Expression expr continue; } +#if !LIGHT_EXPRESSION + var customReduced = TryReduceNode(expr, ref context); + if (customReduced != null) + { + expr = customReduced; + enterable = false; + continue; + } +#endif return Result.NotSupported_UnknownExpression; } } } + internal sealed class ExpressionReductionCache + { + internal SmallMap4> Reductions; + } + + // Collection and emission must use the same reduced tree: Reduce may create fresh locals, + // constants, labels, or nested lambdas. Allocate the cache only when reducing a node. + [MethodImpl(MethodImplOptions.NoInlining)] + private static Expression TryReduceNode(Expression expression, ref CompilerContext context) + { + var cache = context.ReducedExpressions; + if (cache != null) + { + ref var cached = ref cache.Reductions.Map.TryGetValueRef(expression, out var found); + if (found) + return cached; + } + +#if !LIGHT_EXPRESSION + // System's unknown node kinds use its validation contract. Extension nodes keep + // the existing Reduce/null/self contract shared with LightExpression. + var reduced = expression.NodeType != ExpressionType.Extension + ? expression.CanReduce ? expression.ReduceAndCheck() : null + : expression.Reduce(); +#else + var reduced = expression.Reduce(); +#endif + if (reduced == null || ReferenceEquals(reduced, expression)) + return null; + if (cache == null) + context.ReducedExpressions = cache = new ExpressionReductionCache(); + cache.Reductions.Map.AddOrGetValueRef(expression, out _) = reduced; + return reduced; + } + private static Expression CreateInlinedLambdaInvocationExpression(ArgExprs invokeArgs, LambdaExpression lambdaExpr) { var invokeArgCount = invokeArgs.GetArgCount(); @@ -2681,8 +2727,8 @@ public static bool TryEmit(Expression expr, ILGenerator il, ref CompilerContext return TryEmitSwitch((SwitchExpression)expr, il, ref context, parent); case ExpressionType.Extension: - var reduced = expr.Reduce(); - if (reduced == null || ReferenceEquals(reduced, expr)) + var reduced = TryReduceNode(expr, ref context); + if (reduced == null) return false; expr = reduced; continue; @@ -2692,6 +2738,14 @@ public static bool TryEmit(Expression expr, ILGenerator il, ref CompilerContext case ExpressionType.Quote: // todo: @feature - is not supported yet default: +#if !LIGHT_EXPRESSION + var customReduced = TryReduceNode(expr, ref context); + if (customReduced != null) + { + expr = customReduced; + continue; + } +#endif return false; } diff --git a/test/FastExpressionCompiler.TestsRunner.Net472/Program.cs b/test/FastExpressionCompiler.TestsRunner.Net472/Program.cs index eaeefdf4..87cb23b2 100644 --- a/test/FastExpressionCompiler.TestsRunner.Net472/Program.cs +++ b/test/FastExpressionCompiler.TestsRunner.Net472/Program.cs @@ -44,6 +44,7 @@ static void RunLightExpressionTests(object state) t.Run(new LightExpression.IssueTests.Issue498_InvalidProgramException_when_using_loop()); t.Run(new LightExpression.IssueTests.Issue499_InvalidProgramException_for_Sorting_and_comparison_function()); t.Run(new LightExpression.UnitTests.RuntimeTypeConstantRegressionTests()); + t.Run(new LightExpression.UnitTests.ExtensionReductionIdentityRegressionTests()); t.Run(new LightExpression.UnitTests.ParityTests()); Console.WriteLine($"Just LightExpression tests are passing in {justLightTestsStopwatch.ElapsedMilliseconds} ms."); @@ -66,6 +67,8 @@ static void RunLightExpressionTests(object state) fecTests.Run(new Issue499_InvalidProgramException_for_Sorting_and_comparison_function()); fecTests.Run(new ReductionProgressRegressionTests()); fecTests.Run(new RuntimeTypeConstantRegressionTests()); + fecTests.Run(new CustomReducibleNodeRegressionTests()); + fecTests.Run(new ExtensionReductionIdentityRegressionTests()); fecTests.Run(new ParityTests()); diff --git a/test/FastExpressionCompiler.TestsRunner/Program.cs b/test/FastExpressionCompiler.TestsRunner/Program.cs index 7a867a16..d0f60382 100644 --- a/test/FastExpressionCompiler.TestsRunner/Program.cs +++ b/test/FastExpressionCompiler.TestsRunner/Program.cs @@ -76,6 +76,7 @@ public static void Main(string[] args) lt.Run(new LightExpression.IssueTests.Issue499_InvalidProgramException_for_Sorting_and_comparison_function()); lt.Run(new LightExpression.IssueTests.Issue561_NonBooleanEqualityOperator()); lt.Run(new LightExpression.UnitTests.RuntimeTypeConstantRegressionTests()); + lt.Run(new LightExpression.UnitTests.ExtensionReductionIdentityRegressionTests()); lt.Run(new LightExpression.UnitTests.ParityTests()); ltTime = ltStopwatch.ElapsedMilliseconds; }); @@ -85,6 +86,8 @@ public static void Main(string[] args) st.Run(new Issue561_NonBooleanEqualityOperator()); st.Run(new ReductionProgressRegressionTests()); st.Run(new RuntimeTypeConstantRegressionTests()); + st.Run(new CustomReducibleNodeRegressionTests()); + st.Run(new ExtensionReductionIdentityRegressionTests()); st.Run(new ParityTests()); st.Run(new Issue498_InvalidProgramException_when_using_loop()); st.Run(new Issue495_Incomplete_pattern_detection_for_NotSupported_1007_Return_goto_from_TryCatch_with_Assign_generates_invalid_IL()); diff --git a/test/FastExpressionCompiler.UnitTests/CustomReducibleNodeRegressionTests.cs b/test/FastExpressionCompiler.UnitTests/CustomReducibleNodeRegressionTests.cs new file mode 100644 index 00000000..3f8e5c3b --- /dev/null +++ b/test/FastExpressionCompiler.UnitTests/CustomReducibleNodeRegressionTests.cs @@ -0,0 +1,151 @@ +using System; +#if !LIGHT_EXPRESSION +using System.Linq.Expressions; +namespace FastExpressionCompiler.UnitTests; +public struct CustomReducibleNodeRegressionTests : ITestX +{ + public void Run(TestRun t) + { + Custom_node_type_reduces_before_emission(t); + Reduction_collects_captured_constants_and_chains(t); + Invalid_reductions_use_system_validation(t); + Fresh_block_locals_and_constants_are_shared_between_passes(t); + Fresh_nested_lambdas_are_shared_between_passes(t); + Shared_nodes_and_distinct_nodes_preserve_reference_identity(t); + Reduction_cache_is_local_to_each_compilation(t); + Emission_without_collection_can_reduce_custom_nodes(t); + } + public void Custom_node_type_reduces_before_emission(TestContext t) + { + var expression = Expression.Lambda>(new Reducible(Expression.Constant(3))); + var fast = expression.CompileFast(true); + if (t.IsNotNull(fast)) + { +#if NETFRAMEWORK + t.AreEqual(3, fast()); // Framework System does not reduce unknown node kinds. +#else + t.AreEqual(expression.Compile()(), fast()); +#endif + } + } + public void Reduction_collects_captured_constants_and_chains(TestContext t) + { + var marker = new object(); + var expression = Expression.Lambda>(new Reducible(new Reducible(Expression.Constant(marker)))); + var fast = expression.CompileFast(true); + if (t.IsNotNull(fast)) t.AreEqual(true, ReferenceEquals(marker, fast())); + } + public void Invalid_reductions_use_system_validation(TestContext t) + { + var source = Expression.Lambda>(new InvalidReduction()); + t.Throws(() => source.CompileFast(true)); + } + private static readonly CompilerFlags[] Flags = + { + CompilerFlags.Default, CompilerFlags.NoInvocationLambdaInlining, + CompilerFlags.EnableDelegateDebugInfo | CompilerFlags.ThrowOnNotSupportedExpression + }; + public void Fresh_block_locals_and_constants_are_shared_between_passes(TestContext t) + { + foreach (var flags in Flags) + { + var node = new FreshBlock(41); + var fast = Expression.Lambda>(node).CompileFast(true, flags); + if (t.IsNotNull(fast)) t.AreEqual(42, fast()); + t.AreEqual(1, node.Reductions); + } + } + public void Fresh_nested_lambdas_are_shared_between_passes(TestContext t) + { + foreach (var flags in Flags) + { + var node = new FreshNestedLambda(); + var fast = Expression.Lambda>>(node).CompileFast(true, flags); + if (t.IsNotNull(fast)) t.AreEqual(42, fast()()); + t.AreEqual(1, node.Reductions); + } + } + public void Shared_nodes_and_distinct_nodes_preserve_reference_identity(TestContext t) + { + foreach (var flags in Flags) + { + var shared = new FreshBlock(20); + var same = Expression.Lambda>(Expression.Add(shared, shared)).CompileFast(true, flags); + if (t.IsNotNull(same)) t.AreEqual(42, same()); + t.AreEqual(1, shared.Reductions); + // User equality must not make two different custom nodes share a reduction. + var left = new FreshBlock(20); var right = new FreshBlock(21); + var distinct = Expression.Lambda>(Expression.Add(left, right)).CompileFast(true, flags); + if (t.IsNotNull(distinct)) t.AreEqual(43, distinct()); + t.AreEqual(1, left.Reductions); t.AreEqual(1, right.Reductions); + } + } + public void Reduction_cache_is_local_to_each_compilation(TestContext t) + { + var node = new FreshBlock(41); + var source = Expression.Lambda>(node); + for (var i = 1; i <= 2; ++i) + { + var fast = source.CompileFast(true); + if (t.IsNotNull(fast)) t.AreEqual(42, fast()); + t.AreEqual(i, node.Reductions); + } + } + public void Emission_without_collection_can_reduce_custom_nodes(TestContext t) + { + var source = Expression.Lambda>(new Reducible(Expression.Constant(42))); + var fast = source.TryCompileWithoutClosure>(); + if (t.IsNotNull(fast)) t.AreEqual(42, fast()); + } + public sealed class Marker { public int Value; } + private sealed class FreshBlock : Expression + { + private readonly int _value; + public int Reductions; + public FreshBlock(int value) => _value = value; + public override Type Type => typeof(int); + public override ExpressionType NodeType => (ExpressionType)(-1); + public override bool CanReduce => true; + public override Expression Reduce() + { + ++Reductions; + var local = Expression.Variable(typeof(Marker), "fresh"); + return Expression.Block(new[] { local }, + Expression.Assign(local, Expression.Constant(new Marker { Value = _value })), + Expression.Add(Expression.Field(local, nameof(Marker.Value)), Expression.Constant(1))); + } + public override bool Equals(object obj) => obj is FreshBlock; + public override int GetHashCode() => 0; + } + private sealed class FreshNestedLambda : Expression + { + public int Reductions; + public override Type Type => typeof(Func); + public override ExpressionType NodeType => (ExpressionType)(-1); + public override bool CanReduce => true; + public override Expression Reduce() + { + ++Reductions; + var local = Expression.Variable(typeof(int), "fresh"); + return Expression.Block(new[] { local }, Expression.Assign(local, Expression.Constant(41)), + Expression.Lambda>(Expression.Add(local, Expression.Constant(1)))); + } + } + private sealed class InvalidReduction : Expression + { + public override Type Type => typeof(int); + public override ExpressionType NodeType => (ExpressionType)(-1); + public override bool CanReduce => true; + public override Expression Reduce() => Expression.Constant("wrong type"); + } + public class Reducible : Expression + { + private readonly Expression _reduced; + public Reducible(Expression reduced) => _reduced = reduced; + public override Type Type => _reduced.Type; + public override ExpressionType NodeType => (ExpressionType)(-1); + public override bool CanReduce => true; + public override Expression Reduce() => _reduced; + } +} +#endif diff --git a/test/FastExpressionCompiler.UnitTests/ExtensionReductionIdentityRegressionTests.cs b/test/FastExpressionCompiler.UnitTests/ExtensionReductionIdentityRegressionTests.cs new file mode 100644 index 00000000..76a526af --- /dev/null +++ b/test/FastExpressionCompiler.UnitTests/ExtensionReductionIdentityRegressionTests.cs @@ -0,0 +1,210 @@ +using System; +using System.Reflection.Emit; +#if LIGHT_EXPRESSION +using E = FastExpressionCompiler.LightExpression.Expression; +namespace FastExpressionCompiler.LightExpression.UnitTests; +#else +using E = System.Linq.Expressions.Expression; +namespace FastExpressionCompiler.UnitTests; +#endif + +public struct ExtensionReductionIdentityRegressionTests : ITestX +{ + private static readonly CompilerFlags[] Flags = + { + CompilerFlags.Default, CompilerFlags.NoInvocationLambdaInlining, + CompilerFlags.EnableDelegateDebugInfo | CompilerFlags.ThrowOnNotSupportedExpression + }; + + public void Run(TestRun t) + { + Fresh_blocks_and_captured_constants_share_the_collected_tree(t); + Fresh_nested_lambdas_share_the_collected_tree(t); + Fresh_labels_share_the_collected_tree(t); + Shared_and_distinct_nodes_use_reference_identity(t); + Reductions_are_local_to_each_compilation(t); + Public_emission_paths_reduce_once(t); + Invalid_first_reductions_remain_rejected(t); +#if LIGHT_EXPRESSION + Intrinsic_nodes_keep_their_collection_and_emission_hooks(t); +#endif + } + + public void Fresh_blocks_and_captured_constants_share_the_collected_tree(TestContext t) + { + foreach (var flags in Flags) + { + var node = IntBlock(41); + var fast = E.Lambda>(node).CompileFast(true, flags); + if (t.IsNotNull(fast)) t.AreEqual(42, fast()); + t.AreEqual(1, node.Reductions); + } + } + + public void Fresh_nested_lambdas_share_the_collected_tree(TestContext t) + { + foreach (var flags in Flags) + { + var node = new FreshReduction(typeof(Func), () => + { + var local = E.Variable(typeof(int), "fresh"); + return E.Block(new[] { local }, E.Assign(local, E.Constant(41)), + E.Lambda>(E.Add(local, E.Constant(1)))); + }); + var fast = E.Lambda>>(node).CompileFast(true, flags); + if (t.IsNotNull(fast)) t.AreEqual(42, fast()()); + t.AreEqual(1, node.Reductions); + } + } + + public void Fresh_labels_share_the_collected_tree(TestContext t) + { + FreshReduction Node() => new FreshReduction(typeof(int), () => + { + var target = E.Label(typeof(int), "fresh"); + return E.Block(E.Goto(target, E.Constant(42)), E.Label(target, E.Constant(0))); + }); + foreach (var flags in Flags) + { + var node = Node(); + var fast = E.Lambda>(node).CompileFast(true, flags); + if (t.IsNotNull(fast)) t.AreEqual(42, fast()); + t.AreEqual(1, node.Reductions); + var shared = Node(); + var repeated = E.Lambda>(E.Add(shared, shared)).CompileFast(true, flags); + if (t.IsNotNull(repeated)) t.AreEqual(84, repeated()); + t.AreEqual(1, shared.Reductions); + } + } + + public void Shared_and_distinct_nodes_use_reference_identity(TestContext t) + { + foreach (var flags in Flags) + { + var shared = IntBlock(20); + var repeated = E.Lambda>(E.Add(shared, shared)).CompileFast(true, flags); + if (t.IsNotNull(repeated)) t.AreEqual(42, repeated()); + t.AreEqual(1, shared.Reductions); + var left = IntBlock(20); + var right = IntBlock(21); + var distinct = E.Lambda>(E.Add(left, right)).CompileFast(true, flags); + if (t.IsNotNull(distinct)) t.AreEqual(43, distinct()); + t.AreEqual(1, left.Reductions); + t.AreEqual(1, right.Reductions); + var condition = E.Parameter(typeof(bool)); + var branches = E.Lambda>(E.Condition(condition, left, right), condition).CompileFast(true, flags); + if (t.IsNotNull(branches)) { t.AreEqual(21, branches(true)); t.AreEqual(22, branches(false)); } + t.AreEqual(2, left.Reductions); + t.AreEqual(2, right.Reductions); + } + } + + public void Reductions_are_local_to_each_compilation(TestContext t) + { + var node = new FreshReduction(typeof(Marker), () => E.Constant(new Marker { Value = 42 })); + var source = E.Lambda>(node); + var first = source.CompileFast(true); + var second = source.CompileFast(true); + if (t.IsNotNull(first) && t.IsNotNull(second)) + { + t.AreEqual(42, first().Value); + t.AreEqual(42, second().Value); + t.IsFalse(ReferenceEquals(first(), second())); + } + t.AreEqual(2, node.Reductions); + } + + public void Public_emission_paths_reduce_once(TestContext t) + { + FreshReduction Node() => new FreshReduction(typeof(int), () => + { + var local = E.Variable(typeof(int), "fresh"); + return E.Block(new[] { local }, E.Assign(local, E.Constant(42)), local); + }); + var direct = Node(); + var fast = E.Lambda>(direct).TryCompileWithoutClosure>(); + if (t.IsNotNull(fast)) t.AreEqual(42, fast()); + t.AreEqual(1, direct.Reductions); + var emitted = Node(); + var method = new DynamicMethod("FreshExtension", typeof(int), Type.EmptyTypes); + if (t.IsTrue(E.Lambda>(emitted).CompileFastToIL(method.GetILGenerator()))) + t.AreEqual(42, ((Func)method.CreateDelegate(typeof(Func)))()); + t.AreEqual(1, emitted.Reductions); + } + + public void Invalid_first_reductions_remain_rejected(TestContext t) + { + foreach (var self in new[] { false, true }) + { + FreshReduction node = null; + node = new FreshReduction(typeof(int), () => self ? node : null); + t.IsNull(E.Lambda>(node).CompileFast(true)); + t.AreEqual(1, node.Reductions); + t.IsNull(E.Lambda>(node).TryCompileWithoutClosure>()); + t.AreEqual(2, node.Reductions); + } + } + + private static FreshReduction IntBlock(int value) => new FreshReduction(typeof(int), () => + { + var local = E.Variable(typeof(Marker), "fresh"); + return E.Block(new[] { local }, E.Assign(local, E.Constant(new Marker { Value = value })), + E.Add(E.Field(local, nameof(Marker.Value)), E.Constant(1))); + }); + + public sealed class Marker { public int Value; } + +#if LIGHT_EXPRESSION + private sealed class FreshReduction : UnaryExpression +#else + private sealed class FreshReduction : E +#endif + { + private readonly Func _reduce; + public int Reductions; + public override System.Linq.Expressions.ExpressionType NodeType => System.Linq.Expressions.ExpressionType.Extension; +#if LIGHT_EXPRESSION + public FreshReduction(Type type, Func reduce) : base(E.Default(type)) => _reduce = reduce; +#else + public override Type Type { get; } + public override bool CanReduce => true; + public FreshReduction(Type type, Func reduce) { Type = type; _reduce = reduce; } +#endif + public override E Reduce() { ++Reductions; return _reduce(); } + public override bool Equals(object obj) => obj is FreshReduction; + public override int GetHashCode() => 0; + } + +#if LIGHT_EXPRESSION + public void Intrinsic_nodes_keep_their_collection_and_emission_hooks(TestContext t) + { + var node = new Intrinsic(); + var fast = E.Lambda>(node).CompileFast(true); + if (t.IsNotNull(fast)) t.AreEqual(42, fast()); + t.AreEqual(1, node.Collections); + t.AreEqual(1, node.Emissions); + } + + private sealed class Intrinsic : UnaryExpression + { + public int Collections, Emissions; + public Intrinsic() : base(E.Default(typeof(int))) { } + public override System.Linq.Expressions.ExpressionType NodeType => System.Linq.Expressions.ExpressionType.Extension; + public override bool IsIntrinsic => true; + public override E Reduce() => throw new InvalidOperationException("Intrinsic reduction should not be used."); + public override ExpressionCompiler.Result TryCollectInfo(ref ExpressionCompiler.CompilerContext context, + ExpressionCompiler.NestedLambdaInfo nestedLambda, + ref ImTools.SmallList rootNestedLambdas, short scopeIdx) + { + ++Collections; + return ExpressionCompiler.Result.OK; + } + public override bool TryEmit(ref ExpressionCompiler.CompilerContext context, ILGenerator il, ExpressionCompiler.ParentFlags parent, int byRefIndex = -1) + { + ++Emissions; + il.Emit(OpCodes.Ldc_I4, 42); + return true; + } + } +#endif +} diff --git a/test/FastExpressionCompiler.UnitTests/ReductionProgressRegressionTests.cs b/test/FastExpressionCompiler.UnitTests/ReductionProgressRegressionTests.cs index 28c10afd..024d3fae 100644 --- a/test/FastExpressionCompiler.UnitTests/ReductionProgressRegressionTests.cs +++ b/test/FastExpressionCompiler.UnitTests/ReductionProgressRegressionTests.cs @@ -9,7 +9,7 @@ public void Run(TestRun t) { Self_reductions_follow_the_strict_rejection_contract(t); Null_reductions_follow_the_strict_rejection_contract(t); - Emission_rejects_a_reduction_that_stops_progressing(t); + Emission_reuses_the_valid_collected_reduction(t); Valid_reduction_chains_keep_parameters(t); Compile_to_IL_rejects_nonprogressing_reductions(t); } @@ -32,11 +32,13 @@ public void Null_reductions_follow_the_strict_rejection_contract(TestContext t) .CompileFast(true, CompilerFlags.ThrowOnNotSupportedExpression)); t.Throws(() => Expression.Lambda>(new NullReduction()).CompileFast()); } - public void Emission_rejects_a_reduction_that_stops_progressing(TestContext t) + public void Emission_reuses_the_valid_collected_reduction(TestContext t) { var node = new BoundedSelf(true, firstValue: true); - t.IsNull(Expression.Lambda>(node).CompileFast(true)); - t.AreEqual(2, node.Count); + // Collection already supplied a valid tree; emission must reuse it. + var fast = Expression.Lambda>(node).CompileFast(true); + if (t.IsNotNull(fast)) t.AreEqual(42, fast()); + t.AreEqual(1, node.Count); } public void Valid_reduction_chains_keep_parameters(TestContext t) {