Почему кэширование не захватывает деревья выражений, которые инициализируются с использованием лямбда-выражений?

Рассмотрим следующий класс:

class Program
{
    static void Test()
    {
        TestDelegate<string, int>(s => s.Length);

        TestExpressionTree<string, int>(s => s.Length);
    }

    static void TestDelegate<T1, T2>(Func<T1, T2> del) { /*...*/ }

    static void TestExpressionTree<T1, T2>(Expression<Func<T1, T2>> exp) { /*...*/ }
}

Это то, что генерирует компилятор (в несколько менее читаемом виде):

class Program
{
    static void Test()
    {
        // The delegate call:
        TestDelegate(Cache.Func ?? (Cache.Func = Cache.Instance.FuncImpl));

        // The expression call:
        var paramExp = Expression.Parameter(typeof(string), "s");
        var propExp = Expression.Property(paramExp, "Length");
        var lambdaExp = Expression.Lambda<Func<string, int>>(propExp, paramExp);
        TestExpressionTree(lambdaExp);
    }

    static void TestDelegate<T1, T2>(Func<T1, T2> del) { /*...*/ }

    static void TestExpressionTree<T1, T2>(Expression<Func<T1, T2>> exp) { /*...*/ }

    sealed class Cache
    {
        public static readonly Cache Instance = new Cache();

        public static Func<string, int> Func;

        internal int FuncImpl(string s) => s.Length;
    }
}

Таким образом, делегат, прошедший с первым вызовом, инициализируется один раз и повторно используется для нескольких Test вызовов.

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

Если он не фиксирует что-либо, и деревья выражений являются неизменяемыми, какова будет проблема с кешированием дерева выражений?

редактировать

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

  1. Полученное дерево выражений известно во время компиляции (ну, оно создается компилятором).
  2. Они неизменны. Таким образом, в отличие от примера массива, указанного в X39 ниже, дерево выражений не может быть изменено после его инициализации и, следовательно, безопасно кэшироваться.
  3. В базе кода может быть только так много деревьев выражений. Опять же, я говорю о тех, которые могут быть кэшированы, то есть те, которые инициализируются с помощью лямбда-выражений (а не тех, которые создаются вручную), не захватывая никаких внешних состояние/переменная. Аналогичным примером будет автоинтерминирование строковых литералов.
  4. Они предназначены для прохождения - их можно скомпилировать для создания делегата, но это не их основная функция. Если кто-то хочет скомпилированный делегат, он может просто принять один (Func<T> вместо Expression<Func<T>>). Принятие дерева выражений означает, что оно будет использоваться в качестве структуры данных. Итак, "они должны быть скомпилированы в первую очередь" не является разумным аргументом против кеширования деревьев выражений.

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

Ответ 1

Почему кэширование не захватывает деревья выражений, которые инициализируются с использованием лямбда-выражений?

Я написал этот код в компиляторе, как в исходной реализации С# 3, так и в редакторе Roslyn.

Как я всегда говорю, когда задается вопрос "почему бы нет": писатели-компиляторы не обязаны указывать причину, по которой они ничего не сделали. Выполнение чего-то требует работы, требует усилий и стоит денег. Поэтому позиция по умолчанию всегда не должна делать что-то, когда работа не нужна.

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

Теперь, когда я даю это объяснение, я получаю pushback, говорящий: "Microsoft богата, бла-бла-бла". Наличие большого количества ресурсов - это не то же самое, что иметь бесконечные ресурсы, а компилятор уже очень дорог. Я также получаю pushback, говорящий, что "с открытым исходным кодом делает рабочую силу свободным", чего абсолютно нет.

Я отметил, что время было фактором. Возможно, это будет полезно продолжить.

Когда разрабатывался С# 3.0, в Visual Studio была определенная дата, на которой она была бы "выпущена для производства", это был причудливый термин с момента распространения программного обеспечения в основном на CDROM, которые не могли быть изменены после их печати. Эта дата не была произвольной; скорее, за ним последовала целая цепочка зависимостей. Если, скажем, у SQL Server была функция, зависящая от LINQ, не было бы смысла задерживать выпуск VS до тех пор, пока SQL Server не выпустит этот выпуск, и поэтому расписание VS повлияло на расписание SQL Server, что, в свою очередь, повлияло на другую команду графики и т.д.

Поэтому каждая команда в организации VS представила расписание, и команда с большинством дней работы над этим графиком была "длинным полюсом". Команда С# была длинным полюсом для VS, и я был длинным полюсом для команды компилятора С#, поэтому каждый день, когда я опоздал с поставкой моих функций компилятора, был тот день, когда Visual Studio и каждый продукт с нисходящим потоком сократили свое расписание и разочаровать своих клиентов.

Это является мощным препятствием для выполнения ненужной работы с производительностью, особенно для работы с производительностью, которая может ухудшить ситуацию, а не лучше. Кэш без политики истечения срока действия имеет имя: это утечка памяти.

Как вы заметили, анонимные функции кэшируются. Когда я реализовал lambdas, я использовал тот же код инфраструктуры, что и анонимные функции, поэтому кеш был (1) "потоплен" - работа была уже выполнена, и было бы больше работать, чтобы отключить ее, чем оставить ее, и (2) уже были проверены и проверены моими предшественниками.

Я подумал о том, чтобы реализовать аналогичный кеш на деревьях выражений, используя ту же логику, но понял, что это будет (1) работать, что требует времени, которое у меня уже было мало, и (2) я понятия не имел, быть кешированием такого объекта. Делегаты действительно маленькие. Делегаты - это один объект; если делегат логически статичен, то, что С# кэширует агрессивно, в нем даже нет ссылки на приемник. Деревья выражений, напротив, потенциально огромные деревья. Они представляют собой график небольших объектов, но этот график потенциально большой. Графики объектов делают больше работы для сборщика мусора дольше, чем они живут!

Поэтому любые тесты производительности и показатели, используемые для обоснования решения о кешировании делегатов, не были бы применимы к деревьям выражений, поскольку бремя памяти было совершенно иным. Я не хотел создавать новый источник утечек памяти в нашей самой важной новой языковой функции. Риск был слишком высоким.

Но риск может стоить того, если выгода будет большой. Так какая польза? Начните с вопроса: "Где используются деревья выражений?" В запросах LINQ, которые будут удалены в базы данных. Это очень дорогостоящая операция как во времени, так и в памяти. Добавление кеша не дает вам большой победы, потому что работа, которую вы собираетесь сделать, в миллионы раз дороже выигрыша; победа - это шум.

Сравните это с выигрышем производительности для делегатов. Разница между "allocate x => x + 1, затем вызовите ее" миллион раз "и" проверьте кеш, если он не кэширован, выделите его, назовите его ", торгует выделение для проверки, что может сэкономить вам целые наносекунды, Это кажется неважным, но звонок также займет наносекунды, поэтому на процентной основе это значимо. Кэширование делегатов - явная победа. Кэширование деревьев выражений нигде не приближается к явной победе; нам нужны данные, что это преимущество, которое оправдывает риск.

Поэтому было принято решение не тратить время на эту ненужную, вероятно, незаметную, неважную оптимизацию на С# 3.

Во время С# 4 у нас было гораздо больше важных дел, чем повторение этого решения.

После С# 4 команда разделилась на две подгруппы, одна из которых переписала компилятор "Roslyn", а другая для реализации async-ожидания в исходной кодовой базе компилятора. Команда асинхронного ожидания была полностью поглощена внедрением этой сложной и сложной функции, и, конечно же, команда была меньше обычной. И они знали, что все их работы в конечном итоге будут воспроизведены в Рослине, а затем выброшены; что компилятор был в конце своей жизни. Поэтому не было стимула тратить время или усилия на добавление оптимизаций.

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

Что касается того, почему ни один из моих коллег не повторил этот вопрос после того, как я ушел, вам придется их спросить, но я уверен, что они очень заняты, делая настоящую работу над реальными функциями, которые запрашивали настоящие клиенты, или по оптимизации производительности, которые большие победы за меньшую стоимость. Эта работа включала open-sourcing компилятор, который не дешев.

Итак, если вы хотите, чтобы эта работа была выполнена, у вас есть выбор.

  • Компилятор является открытым исходным кодом; вы могли бы сделать это сами. Если это звучит как большая работа, для вас очень мало пользы, у вас теперь есть более интуитивное понимание того, почему никто не проделал эту работу, поскольку эта функция была реализована в 2005 году.

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

  • Процесс разработки открыт. Введите вопрос и в этом выпуске, дайте вескую причину, почему вы думаете, что это повышение стоит того. С данными.

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

Фактическая победа в предотвращении повторных распределений сложных деревьев выражений позволяет избежать давления на сбор, и это вызывает серьезную озабоченность. Многие функции в С# предназначены для предотвращения сбора давления, и деревья выражений НЕ являются одним из них. Мой совет вам, если вы хотите, чтобы эта оптимизация была сосредоточена на его влиянии на давление, потому что там, где вы найдете самую большую победу и сможете сделать самый убедительный аргумент.

Ответ 2

Компилятор делает то, что он всегда делает, а не кеширует все, что вы им кормите.

Чтобы понять, что это всегда происходит, обратите внимание на передачу нового массива в ваш метод.

this.DoSomethingWithArray(new string[] {"foo","bar" });

доберется до

IL_0001: ldarg.0
IL_0002: ldc.i4.2
IL_0003: newarr    [mscorlib]System.String
IL_0008: dup
IL_0009: ldc.i4.0
IL_000A: ldstr     "foo"
IL_000F: stelem.ref
IL_0010: dup
IL_0011: ldc.i4.1
IL_0012: ldstr     "bar"
IL_0017: stelem.ref
IL_0018: call      instance void Test::DoSomethingWithArray(string[])

вместо того, чтобы кешировать массив один раз и повторно использовать его каждый раз.

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

Чтобы получить кешированную версию, используйте что-то вроде этого:

private static System.Linq.Expressions.Expression<Func<object, string>> Exp = (obj) => obj.ToString();