В этой статье Джон Скит будет описывать как простейшие конструкции языка замедляют вашу программу и как их можно ускорить.
Как и в любой работе, сваязанной с производительностью приложений, результат может варьироваться в зависимости от условий (в частности, например, 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
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