Многие люди справедливо критиковали чрезмерное использование в Yandere Simulator операторов if, но проблема гораздо шире, чем ее можно решить с помощью операторов switch.

Отказ от ответственности

Я собираюсь поговорить только об одном обычно предлагаемом решении для чрезмерного использования операторов if в коде Yandere Simulator и ничего больше о его разработке или каких-либо спорах вокруг него.

Контекст

Yandere Simulator заполнен до краев if else цепочками (участки кода, в которых компьютер просматривает список условий для проверки и выполняет код для первого удовлетворенного условия) и чрезвычайно вложенными if операторами (if операторы внутри if операторов внутри if операторов и т. д.), оба из которых являются плохой практикой.

За исключением одного или двух человек, которых я видел в Интернете, большинство критических замечаний, в которых упоминается чрезмерное использование операторов if в Yandere Simulator, основано на общей идее, что длинные if else цепочки - это основная проблема производительности и их следует заменить на switch высказывания для повышения производительности ». Честно говоря, некоторые из этих критиков предлагают гораздо лучшие решения, чем заявления switch (в частности, YouTuber DarkDax, которого я определенно могу порекомендовать, и сообщение Reddit), но я сосредоточусь конкретно на идее, что использование операторов switch значительно улучшит производительность. По той же причине я игнорирую субъективные аргументы о стиле и удобочитаемости.

Код

В коде функции UpdateRoutine, самой большой и сложной функции, которая будет выполняться примерно для сотни студентов в каждом кадре в самом большом файле проекта, StudentScript.cs, есть около

  • 6000 строк кода
  • 731 if операторов (не включая else ifи if операторов в циклах)
  • 220 else if заявлений
  • 124 else заявления
  • 4 цикла, каждый из которых содержит ровно одну ifstatement
  • Еще несколько циклов, которые не имеют значения для нашего анализа

В UpdateRoutine есть много экземпляров кода, которые выглядят как

if (this.EatingSnack)
{
    if (this.SnackPhase == 0)
    {
        this.CharacterAnimation.CrossFade(this.EatChipsAnim);
        this.SmartPhone.SetActive(false);
        this.Pathfinding.canSearch = false;
        this.Pathfinding.canMove = false;
        this.SnackTimer += Time.deltaTime;
        if (this.SnackTimer > 10f)
        {
            UnityEngine.Object.Destroy(this.BagOfChips);
            if (this.StudentID != this.StudentManager.RivalID)
            {
                this.StudentManager.GetNearestFountain(this);
                this.Pathfinding.target = this.DrinkingFountain.DrinkPosition;
                this.CurrentDestination = this.DrinkingFountain.DrinkPosition;
                this.Pathfinding.canSearch = true;
                this.Pathfinding.canMove = true;
                this.SnackTimer = 0f;
            }
            this.SnackPhase++;
        }
    }
    else if (this.SnackPhase == 1)
    {
        this.CharacterAnimation.CrossFade(this.WalkAnim);
        if (this.Persona == PersonaType.PhoneAddict && !this.Phoneless)
        {
            this.SmartPhone.SetActive(true);
        }
        if (this.DistanceToDestination < 1f)
        {
            this.SmartPhone.SetActive(false);
            this.Pathfinding.canSearch = false;
            this.Pathfinding.canMove = false;
            this.SnackPhase++;
        }
    }
    else if (this.SnackPhase == 2)
    {
        this.CharacterAnimation.cullingType = AnimationCullingType.AlwaysAnimate;
        this.CharacterAnimation.CrossFade(this.DrinkFountainAnim);
        this.MoveTowardsTarget(this.DrinkingFountain.DrinkPosition.position);
        base.transform.rotation = Quaternion.Slerp(base.transform.rotation, this.DrinkingFountain.DrinkPosition.rotation, 10f * Time.deltaTime);
        if (this.CharacterAnimation[this.DrinkFountainAnim].time >= this.CharacterAnimation[this.DrinkFountainAnim].length)
        {
            this.CharacterAnimation.cullingType = AnimationCullingType.BasedOnRenderers;
            this.DrinkingFountain.Occupied = false;
            this.EquipCleaningItems();
            this.EatingSnack = false;
            this.Private = false;
            this.Routine = true;
            this.StudentManager.UpdateMe(this.StudentID);
            this.CurrentDestination = this.Destinations[this.Phase];
            this.Pathfinding.target = this.Destinations[this.Phase];
        }
        else if (this.CharacterAnimation[this.DrinkFountainAnim].time > 0.5f && this.CharacterAnimation[this.DrinkFountainAnim].time < 1.5f)
        {
            this.DrinkingFountain.WaterStream.Play();
        }
    }
}

Используя только операторы if, else и else if, вы получаете структуру, которая выглядит как

if (this.EatingSnack)
{
    if (this.SnackPhase == 0)
    {
        // Code
        if (/*time in range*/)
        {
            // Code
            if (/*check if student is rival*/)
            {
                // Code
            }
            // Code
        }
    }
    else if (this.SnackPhase == 1)
    {
        // Code
        if (/*Check details of current object*/)
        {
            // Code
        }
        if (/*Check distance in range*/)
        {
            // Code
        }
    }
    else if (this.SnackPhase == 2)
    {
        // Code
        if (/*time in range*/)
        {
            // Code
        }
        else if (/*time in range*/)
        {
            // Code
        }
    }
}

Предлагаемое решение: переключение операторов

Проблема с приведенным выше кодом может броситься в глаза: если this.SnackPhase равно 2, то он также не может быть равен 0 или 1, но вы должны проверить их все. Вы можете подумать, что имеет смысл получить значение this.SnackPhase, а затем выполнить код, который соответствует случаю, когда this.SnackPhase равно 2. Вы можете сделать это, используя титульный оператор switch, например

if (this.EatingSnack)
{
    switch (this.SnackPhase)
    {
    case 0:
        // Code
        if (/*time in range*/)
        {
            // Code
            if (/*check if student is rival*/)
            {
                // Code
            }
            // Code
        }
        break;
    case 1:
        // Code
        if (/*Check details of current object*/)
        {
            // Code
        }
        if (/*Check distance in range*/)
        {
            // Code
        }
        break;
    case 2:
        // Code
        if (/*time in range*/)
        {
            // Code
        }
        else if (/*time in range*/)
        {
            // Code
        }
        break;
    default:
        break;
    }
}

Оператор switch сообщает компьютеру о необходимости сопоставить свой ввод с соответствующими ячейками (адресами памяти конкретных инструкций) в коде с помощью таблицы переходов. Например, компьютер видит, что this.SnackPhase равно 2, смотрит на третью запись в таблице переходов (0 - первая запись) и перемещается в указанное место (то есть туда, где находится строка case 2:, хотя я немного упрощаю) .

В этом конкретном случае вы можете не увидеть большой выгоды, потому что операторы switch должны заранее выполнить немного больше работы, чтобы сделать то, что им нужно, но они будут выполняться в течение постоянного промежутка времени. С другой стороны, if else цепочки имеют небольшое время запуска (инструкция для сравнения и инструкция для перехода на if оператор), но время, необходимое для выполнения, растет линейно с количеством наблюдений (удвоение числа случаев означает примерно двойное увеличение время).

Насколько быстрее работают операторы переключения?

Насколько я могу судить, никто из людей, которые сказали использовать switch операторы вместо if else цепочек, не проверял, насколько быстрее switch операторы, поэтому я взял на себя ответственность получить достойную оценку.

Я хотел посмотреть, какую производительность я могу получить от оператора switch по сравнению с цепочкой if else, поэтому я написал некоторый C код (у меня уже установлен C на моем компьютере, и компиляторам не нужно много делать для преобразования операторов if и switch в оптимальный машинный код, поэтому это должна быть хорошая оценка.), который выполнял большое количество итераций, в которых программа выбирала случайное число из списка из 16 заданных чисел (отчасти для того, чтобы избежать использования оператора if с помощью прогнозирования ветвления), где код выглядел как

int array[] = { 1, 3, 5, 6, 8, 12, 23, 30, 44, 56, 78, 88, 94, 97, 98, 99 };
int dummy_variable = 0;
// start timer
for (unsigned long long i = 0; i < num_iterations; i++) {
    value = array[rand() & 0b1111];
    if (value == 1) {
        dummy_variable = 1;
    } else if (value == 3) {
        dummy_variable = 3;
    }
...
    } else if (value == 99) {
        dummy_variable = 99;
    }
}
// end timer
// Do something with dummy_variable so it doesn't optimize the for loop out entirely
// start timer
for (unsigned long long i = 0; i < num_iterations; i++) {
    value = array[rand() & 0b1111];
    switch (value) {
    case 1:
        dummy_variable = 1;
        break;
    case 3:
        dummy_variable = 3;
        break;
    case 5:
...
    case 99:
        dummy_variable = 99;
    default:
        break;
    }
}
// end timer
// Do something with dummy_variable so it doesn't optimize the for loop out entirely
// start timer
for (unsigned long long i = 0; i < num_iterations; i++) {
    dummy_variable = rand() & 0b1111;
}
// end timer
// Do something with dummy_variable so it doesn't optimize the for loop out entirely

Я также добавил еще один синхронизированный цикл, который просто запускал генератор случайных чисел в цикле, чтобы я мог вычесть стоимость цикла и генерации случайных чисел. После выполнения теста я обнаружил, что оператор switch постоянно выполняется примерно в 20 раз быстрее.

Проблема с операторами переключения

if else chain выполнялось в среднем около 24,5 наносекунд на каждую итерацию, и в среднем на каждую итерацию выполнялось около 8 операторов if (это эквивалентно линейному поиску), поэтому каждый отдельный оператор if занимал около 3 наносекунд на каждый случай, в то время как операторы switch занял около 1,25 наносекунды для любого количества случаев. Чтобы игра работала со скоростью 120 кадров в секунду, она должна сделать все необходимое для следующего кадра за 8,3 миллисекунды. Чтобы операторы if занимали значительную часть этого времени (скажем, 5%), более 130 000 операторов if должны выполняться по крайней мере в одном потоке в каждом кадре.

Как я сказал ранее, в UpdateRoutine около 1000 if заявлений и около 100 студентов, поэтому вы можете подумать, что мы составили около 100 000 if заявлений. Это рассуждение не учитывает, что компьютер будет выполнять относительно мало if операторов в функции. Например, если мы посмотрим на пример кода из UpdateRoutine, вы должны заметить, что внутренние ifstatements не будут выполняться, если this.EatingSnack не истинно. Если учащиеся перекусывают только 5% времени, мы удалили около 1000 if заявлений за кадр.

Предполагая, что каждое выражение if и else if имеет 50% истинности (довольно хорошая оценка для верхней границы, поскольку у меня нет никаких предварительных знаний о вероятности, а отдельные утверждения if в цепочке if else имеют более низкий шанс быть истинным), Я получаю около 63 if и else if операторов, оцениваемых в среднем для всей функции, то есть компьютер будет оценивать только 6300 if операторов за кадр в UpdateRoutine для всех студентов. Учитывая, чтоUpdateRoutine Вероятно, имеет наибольшее if операторов из всех часто выполняемых функций и гарантированно работает примерно для 100 различных объектов, я сомневаюсь, что большинство других часто выполняемых функций даже приблизятся к верхней границе 6300 if заявлений UpdateRoutine, что означает, что Замена всех if statements операторами switch не приблизилась к увеличению производительности на 5% даже при 120 FPS. При текущих ~ 20-50 FPS Yandere Simulator работает, он даже не увеличит FPS на один кадр.

Но операторы переключения сделают ваш код быстрым!

Если ваша программа представляет собой всего лишь длинную цепочку if else операторов в цикле, тогда конечно, но большую часть времени ваша программа не будет тратить на оценку if операторов. Если вы хотите, чтобы ваш код работал быстро, вам нужно сначала оптимизировать самые медленные части вашей программы. Например, если у вас есть одна функция, которая занимает 1 секунду, а другая функция - час, ускорение на 50% в первой задаче сэкономит вам всего полсекунды, а ускорение на 50% во второй задаче спасет вас. полчаса.

Я бы честно зашел так далеко, что предположил, что использование switch statements для повышения производительности является преждевременной оптимизацией, если вы не докажете, что получите значительный выигрыш в производительности, и даже тогда вы можете в большинстве случаев заменять их массивом или картой / словарем. В моей тестовой программе switch vs if else у меня был еще один тест, в котором я просто получаю значение непосредственно из массива, например

// start timer
for (unsigned long long i = 0; i < num_iterations; i++) {
    dummy_variable = array[rand() & 0b1111];
}
// end timer
// Do something with dummy_variable so it doesn't optimize the for loop out entirely

Приведенный выше код экономит мне 50 строк кода, а прямой доступ к массиву выполняется примерно в 2,5 раза быстрее, чем оператор switch.

Если вам нужно больше доказательств, YouTuber dyc3 профилировал код (и провел полный обзор кода с более глубоким анализом и предложениями по архитектуре кодирования) и доказал, что вся функция StudentScript.Update() (включая функцию, которую мы рассмотрели, UpdateRoutine()o и все остальные функции обновления ) заняло менее миллисекунды, что составило бы около 5% времени выполнения при 50 FPS. Рендеринг плохо оптимизированных ресурсов занимал гораздо больше времени, чем что-либо еще, из-за плохо оптимизированной физики, поиска пути и взаимодействия с пользовательским интерфейсом. Однако dyc3 на этом не остановился. Он фактически прошел и заменил как можно больше if elsechains операторами switch и уменьшил время только на 80 микросекунд. Игра должна работать со скоростью около 600 кадров в секунду, чтобы это улучшение было значительным.

Настоящая проблема

Чтобы было ясно, чрезмерное использование if операторов является серьезной проблемой для ремонтопригодности и архитектуры (в частности, ненужного связывания данных и кода), не обязательно для производительности. Например, давайте проанализируем главного конкурента для самого печально известного раздела кода в Yandere Simulator, основной части public SubtitleType TaskLineResponseType()in StudentScript.cs.

if (this.StudentID == 6)
{
    if (!false)
    {
        return SubtitleType.TaskGenericLine;
    }
    return SubtitleType.Task6Line;
}
else
{
    if (this.StudentID == 8)
    {
        return SubtitleType.Task8Line;
    }
    if (this.StudentID == 11)
    {
        return SubtitleType.Task11Line;
    }
    if (this.StudentID == 13)
    {
        return SubtitleType.Task13Line;
    }
    if (this.StudentID == 14)
    {
        return SubtitleType.Task14Line;
    }
    if (this.StudentID == 15)
    {
        return SubtitleType.Task15Line;
    }
    if (this.StudentID == 25)
    {
        return SubtitleType.Task25Line;
    }
    if (this.StudentID == 28)
    {
        return SubtitleType.Task28Line;
    }
    if (this.StudentID == 30)
    {
        return SubtitleType.Task30Line;
    }
    if (this.StudentID == 36)
    {
        return SubtitleType.Task36Line;
    }
    if (this.StudentID == 37)
    {
        return SubtitleType.Task37Line;
    }
    if (this.StudentID == 38)
    {
        return SubtitleType.Task38Line;
    }
    if (this.StudentID == 52)
    {
        return SubtitleType.Task52Line;
    }
    if (this.StudentID == 76)
    {
        return SubtitleType.Task76Line;
    }
    if (this.StudentID == 77)
    {
        return SubtitleType.Task77Line;
    }
    if (this.StudentID == 78)
    {
        return SubtitleType.Task78Line;
    }
    if (this.StudentID == 79)
    {
        return SubtitleType.Task79Line;
    }
    if (this.StudentID == 80)
    {
        return SubtitleType.Task80Line;
    }
    if (this.StudentID == 81)
    {
        return SubtitleType.Task81Line;
    }
    return SubtitleType.TaskGenericLine;
}

Помните, что длинная цепочка if statements эквивалентна цепочке if else операторов, поскольку return statements завершится, как только будет удовлетворено одно из if операторов.

Было бы лучше разделить наш анализ на разные уровни, которые будут учитывать все больше и больше информации.

Анализ первого уровня

Ищите конкретную особенность языка, которая могла бы сделать этот код быстрее, не учитывая ничего другого. В этом случае замените цепочку if else оператором switch, что сэкономит вам несколько микросекунд (что сделает эту замену буквальной микрооптимизацией) примерно на 30 секунд вашего времени. Люди, которые предлагали использовать switch statements, не предлагая предварительно архитектурных проблем, застряли здесь.

Анализ второго уровня

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

Анализ третьего уровня

Добавьте намеченную цель этого кода в свой анализ. Поскольку вы просто хотите, чтобы у каждого ученика был правильный SubtitleType, полностью избавьтесь от идентификаторов и создайте класс с именем Student с полем task_line со значением по умолчанию SubtitleType.TaskGenericLine.. Если вы хотите узнать SubtitleType для ученика, спросите ученика с student.getSubtitleType(). Это устраняет ненужную связь между task_line data для всех учащихся и некоторыми внешними функциями и избавляется от любых операций, кроме получения значения из известного адреса памяти. В качестве альтернативы вы также можете использовать тот факт, что перечисления могут быть преобразованы в целые числа и наоборот, чтобы сделать всю эту функцию ненужной.

Анализ четвертого уровня

Добавьте в свой анализ работы других людей. Для TaskLineResponseType() достаточно анализа третьего уровня, но для кода Yandere Simulator требуется небольшой анализ четвертого уровня, который избавит от большинства операторов if и быстро и легко очистит код. . В частности, он реализует конечный автомат, который был реализован бесчисленное количество раз почти на каждом языке (все три ссылки находятся в C# или работают с C# и существуют годами) и объяснены в нескольких учебных пособиях. В отличие от этих других конечных автоматов, которые обычно используют словари / массивы, которые отображают состояния в функции и другие состояния, конечный автомат в Yandere Simulator построен неявно с помощью операторов if, которые могут легко стать неподдерживаемыми, поскольку он теряет смысл регулярность и часто может привести к огромному количеству дублирования кода.

Резюме

  • Несмотря на то, что вы, возможно, читали в пыльном фолианте, написание хорошего кода - это не попытка заставить компьютер работать быстро с помощью волшебных слов, таких как switch, или вручную разворачивая циклы. Вместо этого хороший код состоит из использования правильных инструментов для работы, поддержки модульной архитектуры, разделения кода и данных, присвоения вещам описательных имен и т. Д.
  • if else chains - это обычно проблема архитектуры, но редко проблема производительности. Заявления switch обычно незначительно улучшают производительность без исправления основной архитектурной проблемы, хотя они могут потребоваться на языках без объектов, таких как C.
  • Нет лучших экспертов в том, почему ваш код медленный, чем профилировщик. Профилируйте свой код, чтобы найти самые медленные части и сделать их быстрее.

Первоначально опубликовано на https://tuacm.com.