Скомпилированное лямбда-выражение, приводящее к выделению новых делегатов, тогда как версия без выражения этого не делает

Это скомпилированное дерево выражений...

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();

По-видимому, это приводит к созданию нового делегата при каждом вызове outer — это заметно, если, например, установить точку останова в Delegate.CreateDelegateNoSecurityCheck.

Напротив, эквивалент этой функции, не основанный на выражениях

Action<int> outer = x =>
{
  Action<int> innerParam = _ => { };
  innerParam(x);
};

похоже, не делает этого; повторные вызовы outer не требуют выделения новых делегатов.

Я изо всех сил пытаюсь понять, почему. Это предназначено? Есть ли какой-нибудь изящный трюк для кэширования делегатов версии на основе выражений?


Для контекста: это возникло при использовании внешней библиотеки десериализации, которая, казалось, выделяла необоснованный объем памяти посредством создания делегата в нашем процессе. По сути, он делает нечто очень похожее — создает десериализаторы через деревья выражений и назначает делегатов локальным переменным для поддержки рекурсивной и циклической десериализации.

Если лямбда-выражение не ссылается ни на один член экземпляра, объект делегата будет кэширован: Sharplab.io/…

shingo 13.07.2024 13:32

@shingo Спасибо, это соответствует моему пониманию захвата замыканий - хотя версии, основанные на выражениях, также не захватывают, поэтому мне до сих пор не совсем понятно, почему они не кэшируются

Bogey 13.07.2024 14:25
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
1
2
109
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Один из вариантов здесь — скомпилировать внутреннюю лямбду и вызвать ее:

var compile = inner.Compile();
var method = compile.GetType().GetMethod(nameof(Action<int>.Invoke));

var outerExpression = Expression.Lambda<Action<int>>(
    Expression.Block(
        new[] { innerParam },
        Expression.Assign(innerParam, Expression.Constant(compile)),
        Expression.Call(innerParam, method, param))
    , param);

var outer = outerExpression.Compile();

var totalAllocatedBytes = GC.GetTotalAllocatedBytes();

for (int i = 0; i < 1000_000; i++)
{
    outer(1);
}

Console.WriteLine(GC.GetTotalAllocatedBytes() - totalAllocatedBytes);

Что должно быть полным аналогом второго фрагмента.

Спасибо! Я тоже об этом думал, и хотя я думаю, что это может быть отличным решением для некоторых случаев, оно, вероятно, не сработает для основного варианта использования (десериализации), который должен поддерживать ссылки на себя и циклические ссылки. Исходный код присваивает каждому десериализатору локальную переменную, чтобы таким образом он мог имитировать рекурсивные вызовы - может быть сложно, если вообще возможно, найти подход, при котором каждая лямбда-десериализация могла бы быть скомпилирована изолированно от всех остальных.

Bogey 13.07.2024 14:59

@Bogey Возможно, в общем случае это было бы не так уж невозможно - вынесите эту логику за пределы компиляции деревьев выражений. В основном поддерживайте словарь типа -> десериализатор и ищите/используйте его оттуда. Вы можете использовать этот подход для каждого типа или десериализовать «простые» типы на месте и выполнять поиск только для сложных.

Guru Stron 13.07.2024 15:05
Ответ принят как подходящий

Я изо всех сил пытаюсь понять, почему. Это предназначено? Есть ли что-нибудь изящное трюк с кэшированием делегатов версии на основе выражений?

Это потому, что вы явно не кэшируете результат компиляции выражения внутреннего делегата.

В случае с решением, не основанным на выражениях, outerLambda фактически превращается в класс с полем, которое может удерживать innerLambda при создании экземпляра класса. Тогда вызовы внешнего будут вызовами метода экземпляра, которые используют уже созданный делегат действия в поле:

private sealed class <>c__DisplayClass0_0
{
    public Action<int> inner;

    internal void <Main>b__1(int x)
    {
        Action<int> innerParam = inner;
        innerParam(x);
    }
}

К счастью, если мы воспользуемся Expression.Constant, мы сможем иметь ту же функциональность с выражениями.

Глядя на то, что Compile делает в исходном коде — он создает Delegate в конце, вызывая этот метод:

private Delegate CreateDelegate() {
    Debug.Assert(_method is DynamicMethod);

    return _method.CreateDelegate(_lambda.Type, new Closure(_boundConstants.ToArray(), null));
}

Этот экземпляр Closure будет служить object Target внешнего Action<int>. Таким образом, делегат сможет передать его в качестве аргумента при вызове фактического скомпилированного метода Delegate:

Void lambda_method205(System.Runtime.CompilerServices.Closure, Int32)

Если мы посмотрим на исходный код outer , мы увидим, что он имеет:

/// <summary>
/// Represents the non-trivial constants and locally executable expressions that are referenced by a dynamically generated method.
/// </summary>
public readonly object[] Constants;

который заполняется VariableBinder.VisitConstant на этапе обхода перед фактической компиляцией делегата:

...
// 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);
...

Итак, чтобы изменить исходный пример (с указанием кода тестирования на другой ответ Гуру Строна), нам нужно будет:

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;
}

Единственная разница заключается в:

Expression expressionAssignment = useConstantExpression ?
        Expression.Constant(inner.Compile()) : inner;

Это присвоение локальной переменной во внешнем выражении либо использует «кэшированный» скомпилированный делегат из Closure, либо каждый раз создает новый делегат на основе внутреннего выражения.

В тестовом коде (для моего компьютера) выделенная память составляет closureParam.Constants[0] байт для исходного случая и 8168 для второго, который зависит от 0

Хм, насколько я понимаю, Expression.Compile() эффективно компилируется в DynamicAssembly -> DynamicModule -> DynamicType -> DynamicMethod? Разве здесь нельзя было бы просто добавить кэшированное состояние к динамическому типу?

Bogey 13.07.2024 15:10

Я хотел устранить возможное недоразумение, заключающееся в том, что в случае невыражения не происходит никакого «захвата». Есть. Возможно, так и было бы, но почему это будет значением по умолчанию для Expression.Assign по сравнению с менее предполагаемым поведением без кэширования.

Ivan Petrov 13.07.2024 15:21

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

Bogey 13.07.2024 15:30

@Bogey, так что тестирование версии без выражения теперь будет таким же, верно? Декомпилированный код измененного невыражения: Action<int> innerParam = <>9__4_1 ?? (<>9__4_1 = new Action<int>(<>9.<Main>b__4_1)); innerParam(x); Итак, новое выделение при каждом вызове.

Ivan Petrov 13.07.2024 15:46

который, я думаю, отвечает на исходный вопрос. Что касается «хитрости», другой ответ представляет собой хорошее решение.

Ivan Petrov 13.07.2024 15:50

Может быть, я сейчас упускаю что-то глупое, но - ваш самый вставленный фрагмент из Sharplab предполагает, что делегат каждый раз кэшируется, а не воссоздается заново? <>9__4_1 — это статический член, который содержит функцию и назначается только один раз, если значение равно нулю, в противном случае он используется повторно. Это связано с кэшированием этой версии, тогда как версия на основе выражений, к сожалению, не кэшируется.

Bogey 13.07.2024 18:05

в теле метода new ничего не кэшируется с outer. Это не csharplab, а ILSpy, хотя то же самое можно наблюдать и с первой ссылкой - ссылка -> сравните ее с вашей исходной версией, где поле используется повторно.

Ivan Petrov 13.07.2024 18:43

и, кстати, редактирование вашего вопроса таким образом полностью искажает заголовок. Скомпилированное лямбда-выражение, приводящее к новым распределениям делегатов, тогда как версия без выражения не делает этого, поскольку больше не приводит к какой-либо разнице...

Ivan Petrov 13.07.2024 22:38

Спасибо @Ivan, я, наверное, совсем тупой, но до сих пор не уверен, что понимаю. Текущая версия имеет два статических поля, каждое из которых назначается только один раз, если не равно нулю, — все кэшируется, тогда как...

Bogey 14.07.2024 20:52

... моя версия с предварительным редактированием выделяет новый <>c__DisplayClass0_0() при каждом вызове, т. е. не кэшируется. Итак, на мой взгляд, вопрос правильный в его нынешнем виде: текущий код без выражений после редактирования использует все кэшированные лямбда-выражения? Где вы видите там какие-либо некешированные вызовы?

Bogey 14.07.2024 20:52

@Богги, мой плохой Богги, должно быть, я опоздал, когда оставил комментарий. Я сосредоточился на new()... Вы правы насчет статических полей. Нет необходимости модифицировать вопрос. И мне не нужно изменять свой ответ, потому что на этот раз компилятор использует классы со статическими полями вместо полей экземпляра в первой версии для кэширования делегата. Когда я смотрю на код Lambda.Combile, мы просто компилируем его в методы, а не в типы, поэтому кэширование действительно невозможно. Возможно, если бы вы объединили какой-нибудь Reflection.Emit для имитации поведения компилятора C#, но я считаю, что это становится слишком сложным.

Ivan Petrov 14.07.2024 23:11

@Bogey Я покопался немного глубже в исходном коде - кажется, я не знал об удобном Closure классе, который делает то, что вы хотели. Нет необходимости в Expression.Call или получении метода в качестве другого ответа. Мы просто делаем Expression.Invoke

Ivan Petrov 15.07.2024 15:54

Другие вопросы по теме