This compiled expression tree...
var param = Expression.Parameter(typeof(int));
var innerParam = Expression.Parameter(typeof(Action<int>));
var inner = Expression.Lambda(innerParam.Type, Expression.Block(), "test", new[] { param });
var outer = Expression.Lambda<Action<int>>(Expression.Block(new[] { innerParam }, Expression.Assign(innerParam, inner), Expression.Invoke(innerParam, param)), param).Compile();
Appears to lead to the creation of a new delegate on every single invocation of outer
- observable if eg setting a breakpoint in Delegate.CreateDelegateNoSecurityCheck
.
In contrast, the non-expression based equivalent of this function
Action<int> outer = x =>
{
Action<int> innerParam = _ => { };
innerParam(x);
};
does not appear to do this; repeated invocations of outer
do not need to any new delegate allocations.
I struggle do understand why. Is this intended? Is there any nifty trick to have the expression-based version's delegates cached?
For context: This came up when using an external Deserialization library that seemed to allocate an unreasonable amount of memory through Delegate creation in our process. In effect, it does something very similar - it creates deserializers via Expression trees, and assigns delegates to local variables to support recursive & circular deserialization.
I struggle do understand why. Is this intended? Is there any nifty trick to have the expression-based version's delegates cached?
It's because you don't explicitly cache the result of the inner delegate expression compilation.
In the case with the non-expresion based solution the outerLambda
is actually turned into a class with a field that can hold onto the innerLambda
when the class is instantiated. Then calls to outer will be instance method invocations that make use of already created action delegate in the field:
private sealed class <>c__DisplayClass0_0
{
public Action<int> inner;
internal void <Main>b__1(int x)
{
Action<int> innerParam = inner;
innerParam(x);
}
}
Fortunately, if we make use of Expression.Constant
we can have the same functionality with expressions.
Looking at what Compile
does in the source code - it creates a Delegate
at the end by calling this method:
private Delegate CreateDelegate() {
Debug.Assert(_method is DynamicMethod);
return _method.CreateDelegate(_lambda.Type, new Closure(_boundConstants.ToArray(), null));
}
This Closure
instance will serve as the object Target
of the Action<int>
outer Delegate
. That way the delegate can pass it as argument when invoking the actual compiled outer
method:
Void lambda_method205(System.Runtime.CompilerServices.Closure, Int32)
If we have a look at Closure
's source code we see that it has:
/// <summary>
/// Represents the non-trivial constants and locally executable expressions that are referenced by a dynamically generated method.
/// </summary>
public readonly object[] Constants;
which is populated by VariableBinder.VisitConstant
during the traversing stage before the actual compilation of the delegate:
...
// Constants that can be emitted into IL don't need to be stored on
// the delegate
if (ILGen.CanEmitConstant(node.Value, node.Type)) {
return node;
}
_constants.Peek().AddReference(node.Value!, node.Type);
...
So to modify the original example (with testing code credits to the other answer by Guru Stron) we would have:
static void Main() {
var outerOriginal = CreateOuter(useConstantExpression: false);
//outerOriginal.Target.Dump();
MeasureAllocationsForInvocations(outerOriginal); // 8168 on my pc
var outerWithConstantExpression = CreateOuter(useConstantExpression: true);
// the Target of the delegate has the cached value of the inner Action<int>
////outerWithConstantExpression.Dump();
//outerWithConstantExpression.Dump();
MeasureAllocationsForInvocations(outerWithConstantExpression); // 0
dynamic del = outerWithConstantExpression;
// del.Target is Closure (using dynamic because I cannot reference the type even though it's public.. would edit if somebody assists)
Console.WriteLine(del.Target.Constants[0].Method.ToString());
// Void lambda_method204(System.Runtime.CompilerServices.Closure, Int32)
Console.WriteLine(del.Method.ToString());
//Void lambda_method205(System.Runtime.CompilerServices.Closure, Int32)
}
static void MeasureAllocationsForInvocations(Action<int> action) {
var totalAllocatedBytes = GC.GetTotalAllocatedBytes();
for (int i = 0; i < 100; i++) {
action(1);
}
Console.WriteLine();
Console.WriteLine(GC.GetTotalAllocatedBytes() - totalAllocatedBytes);
}
static Action<int> CreateOuter(bool useConstantExpression, bool useConsoleWriteLineInnerBody = false) {
var intParamInnerAndOuter = Expression.Parameter(typeof(int));
var innerBlock = Expression.Block();
// for debugging
if (useConsoleWriteLineInnerBody) {
MethodInfo writeLineMethod = typeof(Console).GetMethod("Write",
new Type[] { typeof(int) });
var writeLineCall = Expression.Call(writeLineMethod, intParamInnerAndOuter);
innerBlock = Expression.Block(writeLineCall);
}
var innerParam = Expression.Parameter(typeof(Action<int>));
var inner = Expression.Lambda(innerParam.Type,
innerBlock, intParamInnerAndOuter);
Expression expressionAssignment = useConstantExpression ?
Expression.Constant(inner.Compile()) : inner;
var outerBlock = Expression.Block(
new[] { innerParam }, // Action<int> a;
Expression.Assign(innerParam, expressionAssignment),
// a = closureParam.Constants[0]
// or
// a = new Action<int>(innerExpressionCompiledMethod)
Expression.Invoke(innerParam, intParamInnerAndOuter)); // a(intParam);
var outerExpression = Expression.Lambda<Action<int>>(outerBlock,
intParamInnerAndOuter);
var outer = outerExpression.Compile();
// if constant it will inside do -> new Closure(new object[]{inner.Compile});
return outer;
}
The only difference is in:
Expression expressionAssignment = useConstantExpression ?
Expression.Constant(inner.Compile()) : inner;
This assignment to the local variable in the outer expression either uses the "cached" compiled delegate from closureParam.Constants[0]
or will create each time new delegate based on the inner expression.
In the testing code (for my pc) the memory allocated is 8168
bytes for the original case and 0
for the second that relies on Expression.Constant