Оптимизация и Generics в CLR

в 8:35, , рубрики: .net, clr, Клиентская оптимизация, оптимизация, Серверная оптимизация, эти ваши Дженерики

Оптимизация и Generics в CLR В этой статье Джон Скит будет описывать как простейшие конструкции языка замедляют вашу программу и как их можно ускорить.

Как и в любой работе, сваязанной с производительностью приложений, результат может варьироваться в зависимости от условий (в частности, например, 64-разрядный JIT может работать несколько иначе), и в большинстве случаев это не должно вас волновать. Несмотря на это, относительно небольшое количество разработчиков пишут продакшен-код, состоящий из большого количества микрооптимизаций. Потому, пожалуйста, не принимайте этот пост как призыв к усложнению кода ради иррациональной оптимизации, которая якобы ускорит вашу программу. Используйте это только там, где это реально может понадобиться.

Ограничение new()

Пусть, нарпимер, у нас есть тип SteppedPattern (автор обсуждает оптимизацию на примере своей библиотеки, Noda Time, — прим. перев.), у которого есть generic-тип TBucket. Отмечу только, что важно, что перед тем, как я буду парсить value, я хочу создать новый объект класса TBucket. Идея состоит в том что крупицы информации складываются в Bucket, где они парсятся. И после окончания операции они складываюся в ParseResult. Так что каждая операция разбора строки требует создания экземпляра TBucket. Как мы можем создавать их в случае с Generic — типами?

Мы можем это сделать вызвав конструктор типа без параметров. Я не хочу задумываться, есть ли у передаваемых типов такой конструктор, потому я просто добавлю ограничение new() и вызову new TBucket().

// Somewhat simplified... 
internal sealed class SteppedPattern<TResult, TBucket> : IParsePattern<TResult> 
    where TBucket : new() 
{ 
    public ParseResult<TResult> Parse(string value) 
    { 
        TBucket bucket = new TBucket(); 

        // Rest of parsing goes here 
    } 
} 

Великолепно! Совсем просто. Однако, к сожалению, я упустил из виду, что эта единственная строка кода будет занимать у нас 75% времени выполнения разбора строки. А это всего лишь создание пустого Bucket — самого простого класса, который разбирает самую простую строчку! Когда я это понял, меня это потрясло.

Исправляем, используя провайдер

Наше исправление будет очень простым. Нам всего лишь надо сказать нашему типу, как создавать экземпляр объекта. Сделаем это при помощи делегата:

// Somewhat simplified... 
internal sealed class SteppedPattern<TResult, TBucket> : IParsePattern<TResult> 
{ 
    private readonly Func<TBucket> bucketProvider; 

    internal SteppedPattern(Func<TBucket> bucketProvider) 
    { 
        this.bucketProvider = bucketProvider; 
    } 

    public ParseResult<TResult> Parse(string value) 
    { 
        TBucket bucket = bucketProvider(); 

        // Rest of parsing goes here 
    } 
} 

Теперь я могу вызвать new StoppedPattern(() => new OffsetBucket()), или что-то в этом духе. Это также означает, что я могу оставить коструктор как internal и больше никогда о нем не заботиться. И, что еще более упростит написание последующего кода, я даже смог бы использовать старые Bucket для разбора последующих строк.

Хочу таблички!

Мне так кажется, что далеко не всем захочется прогонять тесты самостоятельно, а больше захочется посмотреть на готовые результаты. Потому я решил привести результаты benchmarks, которые я сделал чтобы проверить только время создания Generic-типов. Для того чтобы показать насколько незначительными будут эти результаты, я указжу, что значения, записанные в таблице, измеряются в миллисекундах. И за это время было выполнено 100 миллионов операций, которые мы будем тестировать. Потому если только ваш код не основан на частом обращении к операции создания generic-типов, это не должно вызвать в вас желание переписывать код. Однако, запомните это на будущее.

Так или иначе, наш код разработан для работы с четыремя типами: двумя классами и двумя структурами. И для каждого из них — с маленькой и большой версией (имеется в виду, видимо, маленькие и большие версии для GAC, меньшие и большие чем 85K), на 32-х и 64-разрядных машинах, для CLR v2, v4. 64-разрядная машина у меня сама по себе более быстрая, так что необходимо сравнивать результаты внутри одной машины.

CLR v4: 32-bit results (ms per 100 million iterations)

Test type new() constraint Provider delegate
Small struct 689 1225
Large struct 11188 7273
Small class 16307 1690
Large class 17471 3017

CLR v4: 64-bit results (ms per 100 million iterations)

Test type new() constraint Provider delegate
Small struct 473 868
Large struct 2670 2396
Small class 8366 1189
Large class 8805 1529

CLR v2: 32-bit results (ms per 100 million iterations)

Test type new() constraint Provider delegate
Small struct 703 1246
Large struct 11411 7392
Small class 143967 1791
Large class 143107 2581

CLR v2: 64-bit results (ms per 100 million iterations)

Test type new() constraint Provider delegate
Small struct 510 686
Large struct 2334 1731
Small class 81801 1539
Large class 83293 1896

Посмотрите на результаты для классов. Это реальные результаты — они занимают около 2-х минут на моем ноутбуке при использовании ограничения new() и всего пару секунд при использовании провайдера. И, что очень важно отметить, эти результаты актуальны для .Net 2.0 (имеется в виду CLR, а версия 2.0 скорее написана для того чтобы удивить читателя тем что вплоть до .Net 3.5 все работает на CLR v2, для .Net 2.0).

И, конечно же, вы можете скачать benchmark чтобы посмотреть и убедиться как это отработает на вашей машине.

Что же происходит «под капотом»?

Насколько я понимаю, не существует никакой IL инструкции чтобы поддержать ограничение new(). Вместо этого компилятор вставляет инструкции вызова Activator.CreateInstance[T]. Очевидно, это в любом случае медленнее вызова делегата, т.к. В этом случае мы пытаемся найти подходящий для нас конструктор через рефлексию и вызываем его. Меня по-настоящему удивило точто это не было соптимизировано. Ведь очевидное решение — это использование делегатов и их кэширование для дальшнейшего использования. Я не буду разводить дебатов по вопросам принятого ими решения, ведь в конечном итоге их решение не расходует дополнительной памяти, которую будет занимать кэш.

Хочу больше бенчмарков!!

(взято из второй части статьи)

Здесь мы посмотрим на производительность работы с делегатами. А также попробуем их ускорить.
Полный исходный код тестирования производительности вы можете скачать у меня с сайта. Здесь, по сути, я делаю аналогичные действия каждый раз, когда пишу тест. Создаю делегат типа Action, который ничего не делает и проверяю что ссылка на него не является обнуленной. Это я делаю только чтобы избежать оптимизаций JIT. Каждый тест выполнен в виде generic-метода, который принимает один Generic-параметр. Я вызываю каждый метод два раза: в первый раз я передаю в качестве аргумента Int32, а во второй — String. Также а включил несколько кейсов:

  • Я использую лямбда-выражения: Action foo = () => ();
        private static void Lambda<T>()
        {
            Action foo = () => {};
            if (foo == null)
            {
                throw new Exception();
            }
        }
    

  • То, что я хотел бы, чтобы делал за меня компилятор: отдельный кэш, хранящий делегат создания экземпляра класса.
        private static void FakeCachedLambda<T>()
        {
            if (FakeLambdaCache<T>.CachedAction == null)
            {
                FakeLambdaCache<T>.CachedAction = FakeLambdaCache<T>.NoOp;
            }
            Action foo = FakeLambdaCache<T>.CachedAction;
            if (foo == null)
            {
                throw new Exception();
            }
        }
    
        private static class FakeLambdaCache<T>
        {
            internal static Action CachedAction;
            internal static void NoOp() {}
        }
    

  • То, что компилятор делает в реальности с лямбда-выражением: мы напишем отдельный generic-метод, и будем делать method group conversion
        private static void GenericMethodGroup<T>()
        {
            Action foo = NoOp<T>;
            if (foo == null)
            {
                throw new Exception();
            }
        }
    
    

  • То, что компилятор мог бы сделать: использовать отдельный не-generic метод, чтобы впоследствии применить method group conversion
        private static void NonGenericMethodGroup<T>()
        {
            Action foo = NoOp;
            if (foo == null)
            {
                throw new Exception();
            }
        }
    
    

  • Использование method group conversion в статическом не-generic методе generic-типа;
        private static void StaticMethodOnGenericType<T>()
        {
            Action foo = SampleGenericClass<T>.NoOpStatic;
            if (foo == null)
            {
                throw new Exception();
            }
        }
    

  • Использование method group conversion в не статическом не-generic методе generic-типа, с использованием generic-классом кэша с единственным полем, указывающем на экземпляр generic-класса.
    Да, последнее выглядит несколько замысловатым, однако это выглядит намного проще:

        private static void InstanceMethodOnGenericType<T>()
        {
            Action foo = ClassHolder<T>.SampleInstance.NoOpInstance;
            if (foo == null)
            {
                throw new Exception();
            }
        }
    
    

Также раскрою все нераскытые определения:

    private static void NoOp() {}
    private static void NoOp<T>() {}
    
    private class ClassHolder<T>
    {
        internal static SampleGenericClass<T> SampleInstance = new SampleGenericClass<T>();
    }
    
    private class SampleGenericClass<T>
    {
        internal static void NoOpStatic()
        {
        }
        internal void NoOpInstance()
        {
        }
    }

Заметьте, что все это мы делаем в generic-методе, и вызываем его для каждого типа: Int32 и String. И, что важно заметить, мы не захватываем никаких переменных, и generic-параметр не участвует ни в какой части реализации тела метода.

Результаты тестирования

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

если ваша цель — производительность приложения и если ваш код опирается на большое количество операций выделения новых объектов в Generic-типах.

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

Test TestCase[int] TestCase[string]
Lambda expression 180 29684
Generic cache class 90 288
Generic method group conversion 184 30017
Non-generic method group conversion 178 189
Static method on generic type 180 29276
Instance method on generic type 202 299

Автор: SunexDevelopment


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js