yield return

в 10:51, , рубрики: .net, .net core, C#, c#.net, performance, performance optimization, Блог компании Контур, высокая производительность, Программирование
В предыдущих сериях

А вы никогда не задумывались, что yield return выглядит как-то инородно среди прочего C# кода? Больше нигде не встречается такого странного синтаксиса и такой инструкции, кроме как внутри методов, возвращающих перечисление.

А ещё интересно, сколько же на самом стоит перечислять элементы с помощью yield return? И можно ли лучше?

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

Послание от Магоса Техникуса Г из Дивизио Инвестигатус.

Направить на Марс, в Легио Титаникс.

Об оптимизациях процедуры литании.

Слава Омниссии, мы нашли ещё один способ усовершенствовать дух машины!

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

В ходе экспериментов было замечено, что если перестать использовать инструкцию yield, впервые инициированный модуль машины начинает эффективнее работать, словно вдохновлённый нашей молитвой. Особенно полезно это в командных или управляющих модулях, ответственных за перебор и выбор следующей операции. Вместо yield была использована значительно более длинная и сложная языковая конструкция из древних и священных Лингва-технис: энумераблио и энумераторус.

Опытным путём было подтверждено, что после инициации титана класса «Гончая» новым способом его скорость принятия нового решения повысилась на 11 процентов. К сожалению, сложность проведения ритуала инициации машины повышается в несколько раз. Это не зависит от опыта и технологической оснащенности хора техножрецов, но занимает много времени. Технология считается применимой в особых случаях и предлагается передать её лексмеханикам для включения в реестр используемых.

Поговорим о перечислениях

Пусть нам нужно как-то обработать перечисление элементов. И вернуть новое перечисление. Что-то такое:

IEnumerable<object> DoSmth(IEnumerable<object> source);

Для IEnumerable есть специализированные методы: WhereSelectSelectMany, и другие. Но бывает, что их не хватает, если требуется какая-то нетривиальная обработка каждого элемента из 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-код преобразовалось наше приложение.

yield return - 1

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

Вот мы видим наши собственные EvenFilterEnumerable и EvenFilterEnumerator. С методами, что мы сами написали:

yield return - 2

А вот какая-то интересная штука, которую видно в нашей сборке помимо наших типов и методов. И мы её не писали!

yield return - 3

Это автоматически сгенерированный класс. Сгенерировался он автоматически по методу 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, который получим в результате вызовов наших методов: FilterEvenEnumerableFilterEvenYieldFilterEvenLINQ. А в качестве 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.

yield return - 4

Видно, что в автосгенерированном коде намного больше различных инструкций, которые нужно выполнить. Желающие могут изучить этот код подробнее. Но можно кратко охарактеризовать его так — слишком много всякой возни. У него есть собственный state, к которому надо обращаться. Есть какие-то дополнительные проверки всяких условий. За все эти телодвижения мы и платим нашим CPU.

Возможно, что чем сложнее будет наш полезный код (не сама «функция проверки», а количество различных точек возврата результата, изменения каких-то внутренних состояний), тем сложнее будет и автосгенерированный. Увы, это плата за универсальность и простоту написания кода.

Вывод

Стоит ли вместо 10 строчек кода с yield return писать 100 строчек со своими IEnumerator'ами ради 11% производительности перечислений? Сомнительно. Но иногда можно. Ситуации, когда это оправданно, наверняка будут встречаться крайне редко.

Стоит ли знать, что из себя представляет yield return? Стоит ли знать, что компилятор имеет право и умеет генерировать собственные типы и код? Стоит ли лучше чувствовать .Net и C#, понимать его принципы и философию, что никакой магии нет и всё подчиняется понятным и примитивным соглашениям? Стоит ли уметь заглядывать в результат сборки и il-код? Безусловно, да.

Автор: Александр

Источник

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


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