В этой статье Джон Скит будет описывать как простейшие конструкции языка замедляют вашу программу и как их можно ускорить.
Как и в любой работе, сваязанной с производительностью приложений, результат может варьироваться в зависимости от условий (в частности, например, 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 conversionprivate static void GenericMethodGroup<T>() { Action foo = NoOp<T>; if (foo == null) { throw new Exception(); } } - То, что компилятор мог бы сделать: использовать отдельный не-
genericметод, чтобы впоследствии применитьmethod group conversionprivate 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
