В предыдущих сериях
Пародия на замыкания #dotnet #methods #gc
ThreadPool.Intro #dotnet #threadpool
Инструменты анализа эффективности работы приложения. PerfView #performance_analysis #trace #perfview
Сказка про Method as Parameter #dotnet #methods #gc
Сказка про Guid.NewGuid() #os_specific #dotnet #microoptimization
А вы никогда не задумывались, что yield return
выглядит как-то инородно среди прочего C# кода? Больше нигде не встречается такого странного синтаксиса и такой инструкции, кроме как внутри методов, возвращающих перечисление.
А ещё интересно, сколько же на самом стоит перечислять элементы с помощью yield return
? И можно ли лучше?
Художественное отступление, шутка на текущую тему. Для того, чтобы узнать самое интересное, читать текст под катом совершенно не обязательно.
Послание от Магоса Техникуса Г из Дивизио Инвестигатус.
Направить на Марс, в Легио Титаникс.
Об оптимизациях процедуры литании.
Слава Омниссии, мы нашли ещё один способ усовершенствовать дух машины!
В литаниях управляющих модулей Титанов задействованы сотни техножрецов и целый хор рунических жрецов. Современный язык, на котором проводятся ритуалы, содержит некоторые инструкции, упрощающие процесс. Так, используя инструкцию yield, сокращается необходимое количество жрецов и время литаний над теми модулями, которые осуществляют перечисление чего-либо.
В ходе экспериментов было замечено, что если перестать использовать инструкцию yield, впервые инициированный модуль машины начинает эффективнее работать, словно вдохновлённый нашей молитвой. Особенно полезно это в командных или управляющих модулях, ответственных за перебор и выбор следующей операции. Вместо yield была использована значительно более длинная и сложная языковая конструкция из древних и священных Лингва-технис: энумераблио и энумераторус.
Опытным путём было подтверждено, что после инициации титана класса «Гончая» новым способом его скорость принятия нового решения повысилась на 11 процентов. К сожалению, сложность проведения ритуала инициации машины повышается в несколько раз. Это не зависит от опыта и технологической оснащенности хора техножрецов, но занимает много времени. Технология считается применимой в особых случаях и предлагается передать её лексмеханикам для включения в реестр используемых.
Поговорим о перечислениях
Пусть нам нужно как-то обработать перечисление элементов. И вернуть новое перечисление. Что-то такое:
IEnumerable<object> DoSmth(IEnumerable<object> source);
Для IEnumerable
есть специализированные методы: Where
, Select
, SelectMany
, и другие. Но бывает, что их не хватает, если требуется какая-то нетривиальная обработка каждого элемента из source. А ещё, LINQ обычно не самый производительный.
Есть удобный сахар для возвращения перечисления IEnumerable
— инструкция yield
. Давайте рассмотрим её на примере, реализуем что-нибудь тривиальное. Например, функцию, которая отфильтровывает все чётные числа из перечисления, и оставляет только нечётные.
На LINQ код бы выглядел так:
IEnumerable<int> FilterEvenLINQ(IEnumerable<int> source)
{
return source.Where(x => (x & 1) == 1);
}
А с инструкцией yield
так:
IEnumerable<int> FilterEvenYield(IEnumerable<int> source)
{
foreach (var variable in myEnumerable)
{
if ((variable & 1) == 1)
yield return variable;
}
}
Но вообще-то, yield
это… не совсем родная для перечислений штука — cахар, кстати, весьма вкусный (и почти диетический). Если подумать, инструкция yield
даже для обычных методов C# какая-то сильно выделяющаяся. Мы, вообще-то, возвращаем вполне конкретный интерфейс, IEnumerable<T>
. И в интерфейсе IEnumerable<T>
никаких yield'ов нет. За интерфейсом IEnumerable<T>
скрывается один вполне понятный метод:
public interface IEnumerable<out T>
{
IEnumerator<T> GetEnumerator();
}
IEnumerator
тоже вполне простая и понятная штука:
public interface IEnumerator<out T>
{
T Current { get; }
bool MoveNext();
void Reset();
}
То есть, перечисление — IEnumerable
— всего лишь отдает нам IEnumerator
. А IEnumerator
умеет двигаться вперед, пока не закончится, и отдавать текущий элемент.
Попробуем реализовать наш тренировочный пример по-честному. С помощью реализации интерфейса IEnumerable<int>
.
Начнём с реализации самого IEnumerable<int>
:
sealed class EvenFilterEnumerable : IEnumerable<int>
{
private readonly IEnumerable<int> source;
public EvenFilterEnumerable(IEnumerable<int> source)
{
this.source = source;
}
//Больше всего нас интересует именно этот метод
//Именно его требует интерфейс.
public IEnumerator<int> GetEnumerator()
{
//Мы должны вернуть IEnumerator.
//Давайте вернём свою реализацию, EvenFilterEnumerator.
//Которая будет брать элементы из IEnumerator'а из source.
//И отфильтровывать из них ненужные.
return new EvenFilterEnumerator(source.GetEnumerator());
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
А вот и EvenFilterEnumerator
, реализация IEnumerator<int>
:
sealed class EvenFilterEnumerator : IEnumerator<int>
{
private readonly IEnumerator<int> source;
private int current;
public EvenFilterEnumerator(IEnumerator<int> source)
{
this.source = source;
current = 0;
}
//MoveNext() и Current - части интерфейса, отвечающие за полезную работу
//Больше всего нас интересует именно этот метод
public bool MoveNext()
{
var localEnumerator = source;
//Перебираем источник, пока он не кончится
while (localEnumerator.MoveNext())
{
var value = localEnumerator.Current;
//Четные элементы пропускаем.
if ((value & 1) == 0)
continue;
//Нечетные готовимся отдавать через Current.
current = value;
return true;
}
//Если кончился источник, то и мы просигнализируем, что это конец.
return false;
}
public void Reset()
{
source.Reset();
current = 0;
}
public int Current => current;
object IEnumerator.Current => Current;
public void Dispose()
{
source.Dispose();
}
}
С использованием нашей реализации EvenFilterEnumerable
код, отфильтровывающий все чётные числа, выглядит так:
IEnumerable<int> FilterEvenEnumerable(IEnumerable<int> source)
{
return new EvenFilterEnumerable(source);
}
Наверное, понятно, почему так редко реализуют эти интерфейсы явно. Слишком много мороки. Куда проще написать yield return
.
Разбираем заклинение yield return
Но что вообще за yield return
такой? Не может же он работать в обход интерфейса IEnumerable
. Давайте выясним.
Воспользуемся ildasm
(доступен из консоли Visual Studio 2019 Developer Command Prompt, а в Rider его аналог доступен в Tools -> IL Viewer) и посмотрим на то, в какой il-код преобразовалось наше приложение.
Я замазал серым цветом разные вспомогательные штуки, которые присутствовали в моём коде для удобства разработки и бенчмаркинга и нам сейчас не интересны.
Вот мы видим наши собственные EvenFilterEnumerable
и EvenFilterEnumerator
. С методами, что мы сами написали:
А вот какая-то интересная штука, которую видно в нашей сборке помимо наших типов и методов. И мы её не писали!
Это автоматически сгенерированный класс. Сгенерировался он автоматически по методу FilterEvenYield
(и получил номер 6, номера поменьше уже оказались кем-то заняты). Метод FilterEvenYield
это именно тот метод, в котором мы воспользовались yield return
, взглянем на него ещё раз:
IEnumerable<int> FilterEvenYield(IEnumerable<int> source)
{
foreach (var variable in myEnumerable)
{
if ((variable & 1) == 1)
yield return variable;
}
}
То есть наш код с инструкцией yield return
превратился компилятором в другой код. В котором возникли типы, реализующие те же самые интерфейсы IEnumerable
и IEnumerator
, что и мы могли реализовать сами. Кстати, тут компилятор поступил хитро, и объединил реализации IEnumerable
и IEnumerator
под одним типом <FilterEvenYield>d_6
.
Так что никакой магии, и никакого yield return не существует. Это просто сахар. Компилятор позволяет вам писать код попроще, генерируя за вас всю эту лапшу с реализацией всех нужных интерфейсов.
Если задуматься, то задача автоматической генерации кода из yield return в реализацию IEnumerable
и IEnumerator
не так уж и проста. Код может быть слишком сложным и мудрёным, с кучей точек, откуда возвращается результат (yield return
) или завершения перечисления (yield break
). Наверняка, там строится какой-нибудь автомат!
А ещё можно предположить, что такой автосгенерированный код должен быть несколько перегружен. Он должен быть универсален. Он должен быть готов к куче всяких неожиданностей. Содержать в себе какую-то дополнительную работу ради поддержания самого себя и описания всех возможных пользовательских переходов между состояниями.
Проверим эффективность кодогенерации .NET
Давайте проверим, напишем бенчмарк. Сравним, как быстро получится перечислить все элементы из нашего EvenFilterEnumerator
и из этого автосгенерированного класса.
В бенчмарке будем просто перечислять до самого конца тот IEnumerable
, который получим в результате вызовов наших методов: FilterEvenEnumerable
, FilterEvenYield
, FilterEvenLINQ
. А в качестве IEnumerable<int> source
для них возьмём «все числа от 1 до 100_000».
Код слишком тривиальный, показывать его не буду. Вот результат:
| Method | Mean | Ratio |
|------------------------ |---------:|------:|
| BenchmarkEvenEnumerable | 589.6 us | 0.89 |
| BenchmarkYieldReturns | 666.1 us | 1.00 |
| BenchmarkLINQ | 791.1 us | 1.19 |
Вариант с LINQ, где мы воспользовались методом Where, оказался медленнее yield return'ов на 19%. Ничего удивительного. А вот вариант с нашей собственной реализацией IEnumerable
оказался быстрее yield return'ов (а значит быстрее автосгенерированного IEnumerable
) на 11%!
Очевидно, что если полезной работы будет существенно больше, чем проверка чётности числа, то эта разница поглотится полезной работой. Но результат всё равно интересный.
Можно даже попытаться выяснить, почему автосгенеририванный по yield return'ам IEnumerable
медленнее самодельного. Для этого снова обратимся к il коду, заглянем в реализации методов MoveNext()
с помощью того же ildasm.
Пожалуй, я просто покажу скриншот. Слева — MoveNext
из нашего EvenFilterEnumerator
. Справа — MoveNext
из автосгенерированного <FilterEvenYield>d_6
.
Видно, что в автосгенерированном коде намного больше различных инструкций, которые нужно выполнить. Желающие могут изучить этот код подробнее. Но можно кратко охарактеризовать его так — слишком много всякой возни. У него есть собственный state, к которому надо обращаться. Есть какие-то дополнительные проверки всяких условий. За все эти телодвижения мы и платим нашим CPU.
Возможно, что чем сложнее будет наш полезный код (не сама «функция проверки», а количество различных точек возврата результата, изменения каких-то внутренних состояний), тем сложнее будет и автосгенерированный. Увы, это плата за универсальность и простоту написания кода.
Вывод
Стоит ли вместо 10 строчек кода с yield return
писать 100 строчек со своими IEnumerator
'ами ради 11% производительности перечислений? Сомнительно. Но иногда можно. Ситуации, когда это оправданно, наверняка будут встречаться крайне редко.
Стоит ли знать, что из себя представляет yield return
? Стоит ли знать, что компилятор имеет право и умеет генерировать собственные типы и код? Стоит ли лучше чувствовать .Net и C#, понимать его принципы и философию, что никакой магии нет и всё подчиняется понятным и примитивным соглашениям? Стоит ли уметь заглядывать в результат сборки и il-код? Безусловно, да.