C++ Для оптимизации циклов для виртуальной машины

Контекст


Мой вопрос двоякий (на самом деле два вопроса), но довольно простой *. Но сначала я покажу соответствующий код для некоторого контекста. Для TL; DR «мясо и картофель» перейдите к нижней части для фактических вопросов.

*(Я предполагаю, что отвечающие знают о том, что происходит/как работает виртуальная машина, прежде чем пытаться ответить).

Как уже упоминалось, я пишу (игрушечную) виртуальную машину, которая выполняет пользовательский набор инструкций байт-кода.

(многоточие здесь означает пропуск некоторых случаев)

Вот фрагмент моего кода:

for (ip = 0; (ip < _PROGRAM_SIZE || !cstackempty); ip++) {
        if (breakPending) { break; }

            switch (_instr) {

               case INST::PUSH: {
                   AssertAbort(wontoverflow(1), "Stack overflow (1 byte)");
                   cmd_ "PUSH";
                   push(_incbyte);
                   printStack();
                   break;
               }

        ...
               case INST::ADD: {
                   AssertAbort(stackhas(2), "Can't pop stack to add 2 bytes. Stack does not contain 2 bytes");
                   cmd_ "ADD";
                   byte popped8_a = pop();
                   byte popped8_b = pop();
                   byte result = popped8_a + popped8_b;
                   push(result);
                   cmd_ " "; cmd_(byte)result;
                   printStack();
                   break;
               }

               case INST::ADD16: {
                   AssertAbort(stackhas(4), "Can't pop stack to add 4 bytes. Stack does not contain 4 bytes");
                   cmd_ "ADD16";
                   u16 popped16_a = pop16();
                   u16 popped16_b = pop16();
                   u16 result = popped16_a + popped16_b;
                   push16(result);
                   cmd << " "; cmd << (u16)result;
                   printStack();
                   break;
               }
        ...
            }
}

Только потому, что это уместно, я упомяну, что _cstack это стек вызовов, отсюда и макрос !cstackempty , который проверяет, пуст ли вызов перед вызовом выхода (выход из цикл for) только потому, что это последняя выполняемая инструкция (потому что эта последняя инструкция может быть частью функции или даже возвратом). Кроме того, ip (указатель инструкции) — это просто unsigned long long (u64), как и _PROGRAM_SIZE (размер программы в байтах). instr является байтом и является ссылкой на текущую инструкцию (1 байт).


Мясо и картофель

Вопрос 1. Поскольку я инициализирую два новых целых числа переменного размера для каждого блока/кейса (сегментированных на блоки, чтобы избежать ошибок повторного объявления и т. д.), объявление их над циклом for будет полезно с точки зрения скорости , задержка назначения, размер программы и т. д.?

Вопрос 2. Будет ли continue быстрее, чем break в этом случае, и есть ли более быстрый способ выполнить такой условный цикл, например какой-нибудь переход от указателя к метке, как в этот пост, который не зависит от реализации, или как-то избежать затрат на continue или break?

Подводя итог, мои приоритеты: скорость, затем затраты на память (скорость, эффективность), затем размер файла (ВМ).


person SolaGratia    schedule 18.02.2016    source источник
comment
Для вашего Вопроса 1 вы должны хранить объявления в цикле for. Меньшая область действия позволяет более эффективно управлять регистрами.   -  person Lawrence Wu    schedule 18.02.2016
comment
@LawrenceWu Спасибо! Теперь я знаю, что нужно держать их там :), но я оставлю это в вопросе, чтобы узнать другие мнения и т. д.   -  person SolaGratia    schedule 18.02.2016
comment
Теперь я понимаю, что ответ, который я дал, на самом деле не имел ничего общего с вашим вопросом. Я, должно быть, совсем заснул. Извиняюсь!   -  person Paulo1205    schedule 18.02.2016
comment
@ Paulo1205 Вам не за что извиняться :) Спасибо за ваше время!   -  person SolaGratia    schedule 18.02.2016


Ответы (2)


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

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

Учитывая это, я выберу конкретный компилятор (gcc) и общую архитектуру (x86) и отвечу в этом контексте. Детали будут немного отличаться для других вариантов, но я ожидаю, что общие черты будут одинаковыми для любой приличной комбинации компилятора и оборудования.

Вопрос 1

Место декларации значения не имеет. Само объявление даже не превращается в код - это только определение и использование, которое генерирует код.

Например, рассмотрим два варианта простого цикла ниже (внешний метод sink() нужен только для того, чтобы избежать оптимизации способа присваивания a):

Объявление внутри цикла

int func(int* num) {
  for (unsigned int i=0; i<100; i++) {
    int a = *num + *num;
    sink(a);
    sink(a);
  }
}

Объявление вне цикла

int func(int* num) {
  int a;
  for (unsigned int i=0; i<100; i++) {
    a = *num + *num;
    sink(a);
    sink(a);
  }
}

Мы можем использовать проводник компилятора godbolt, чтобы легко проверить сборку, сгенерированную для first и второй вариант. Они идентичны - вот петля:

.L2:
        mov     ebp, DWORD PTR [r12]
        add     ebx, 1
        add     ebp, ebp
        mov     edi, ebp
        call    sink(int)
        mov     edi, ebp
        call    sink(int)
        cmp     ebx, 100
        jne     .L2

По сути, объявление не создает никакого кода — это делает только присваивание.

вопрос 2

Здесь важно отметить, что на аппаратном уровне нет таких инструкций, как «прервать» или «продолжить». На самом деле у вас есть только переходы, условные или нет, которые в основном являются переходами. И break, и continue будут преобразованы в прыжки. В вашем случае break внутри переключателя, где break является последним оператором в цикле, и continue внутри переключателя имеют точно такой же эффект, поэтому я ожидаю компилируются они одинаково, но давайте проверим.

Давайте используем этот тестовый пример:

int func(unsigned int num, int iters) {
  for (; iters > 0; iters--) {
    switch (num) {
      case 0:
        sinka();
        break;
      case 1:
        sinkb();
        break;
      case 2:
        sinkc();
        break;
      case 3:
        sinkd();
        break;
      case 4:
        sinkd();
        break;
    }
  }
}

Он использует разрыв для существования случая. Вот выходные данные Godbolt для gcc 4.4.7 для x86, игнорируя пролог функции:

.L13:
        cmp     ebp, 4
        ja      .L3
        jmp     [QWORD PTR [r13+r12*8]] # indirect jump
.L9:
        .quad   .L4
        .quad   .L5
        .quad   .L6
        .quad   .L7
        .quad   .L8
.L4:
        call    sinka()
        jmp     .L3
.L5:
        call    sinkb()
        jmp     .L3
.L6
        call    sinkc()
        jmp     .L3
.L7
        call    sinkd()
        jmp     .L3
.L8
        call    sinkd()
.L3:
        sub     ebx, 1
        test    ebx, ebx
        jg      .L13

Здесь компилятор выбрал подход таблицы переходов. Значение num используется для поиска адреса перехода (таблица представляет собой набор директив .quad), а затем выполняется непрямой переход к одной из меток с L4 по L8. Разрывы меняются на jmp .L3, которые выполняют логику цикла.

Обратите внимание, что таблица переходов — не единственный способ компилировать переключатель — если бы я использовал 4 или меньше операторов case, компиляция вместо этого выбирала серию ветвей.

Давайте попробуем тот же пример, но с заменой каждого break на continue:

int func(unsigned int num, int iters) {
  for (; iters > 0; iters--) {
    switch (num) {
      case 0:
        sinka();
        continue;
... [16 lines omitted] ...
    }
  }
}

Как вы уже могли догадаться, результаты идентичны - для этого конкретного компилятора и цель. Операторы continue и операторы break подразумевают один и тот же поток управления, поэтому я ожидаю, что это будет верно для большинства приличных компиляторов с включенной оптимизацией.

person BeeOnRope    schedule 18.02.2016
comment
Фантастический! Именно то, что я был после. Ваши усилия не остались незамеченными. Вот вам печенье с шоколадной крошкой :) +1 и за разбивку ассемблера. Большое спасибо. - person SolaGratia; 19.02.2016
comment
Без проблем. Godbolt — отличный инструмент, который делает такие исследования очень простыми и позволяет вам делать это с помощью множества различных компиляторов (с любыми флагами, которые вы хотите) и архитектур, а затем позволяет вам ссылаться прямо на них! - person BeeOnRope; 19.02.2016
comment
Потрясающие вещи! Я должен проверить это (уверен, это поможет мне с моей собственной конструкцией байт-кода). Вы рекомендуете использовать встроенный ассемблер для любого из этих циклов, даже для частей, критически важных для производительности? - person SolaGratia; 19.02.2016
comment
Вы можете работать намного быстрее со встроенным ассемблером или, возможно, с нестандартными расширениями компилятора, такими как метки gcc со значениями. Без бенчмаркинга я собираюсь предположить, что общим узким местом вашего интерпретатора на настольных/серверных процессорах (т. е. с длинным конвейером, процессорами OoO, которые страдают от больших штрафов за неправильное предсказание) будут сбои прогнозирования ветвлений. В частности, непрямой переход в переключателе (или, что еще хуже, каскад условных переходов, если компилятор выбрал это) по своей сути непредсказуем. - person BeeOnRope; 19.02.2016
comment
См., например, здесь для обсуждения различных интерпретаторов дизайна и компромиссов, а также здесь для сравнения подходов переключения и вычисляемого перехода и некоторого обсуждения поведения прогнозирования. - person BeeOnRope; 19.02.2016
comment
Спасибо за ссылки. Согласно этой ссылке, я думаю, что я выберу «Реплицированную диспетчеризацию на основе коммутатора». Это уменьшает частоту ошибочных прогнозов вдвое и кажется на полпути между расширением gcc «значных переходов» и полным циклом for. Спасибо большое за вашу помощь. - person SolaGratia; 19.02.2016

Что касается вопроса 2, процессор должен достаточно хорошо справляться с разрывом, поскольку фактически это ветвь, которая всегда будет происходить в сборке, поэтому она не должна вызывать особых проблем. Это должно означать, что нет сброса конвейера по указанной причине, так как блок прогнозирования ветвлений должен понять это правильно. Я считаю, что на вопрос 1 был дан ответ выше.

person Careful Now    schedule 18.02.2016
comment
Привет, спасибо за ваш ответ. Не могли бы вы немного рассказать о том, почему процессор должен достаточно хорошо справляться с прерыванием, потому что это немного неоднозначно и на самом деле не дает мне никакой информации о его эффективности по сравнению с моей текущей реализацией? Переводится ли break в ту же инструкцию, что и continue в моем случае, так как это позволит избежать двух инструкций (переключатель выхода, затем цикл continue)? Спасибо. - person SolaGratia; 19.02.2016
comment
Конечно. Разрыв преобразуется в безусловную ветвь в сборке независимо от сборки (x64..) или, по крайней мере, должен. В большинстве процессоров сейчас есть часть аппаратного обеспечения, называемая блоком предсказания переходов. Они довольно сложны в процессорах настольных/ноутбуков/серверов и намного проще во встроенных системах, если они вообще там есть. Это загрузит конвейер процессора в зависимости от того, какая ветвь, по его мнению, может быть выбрана. Поскольку разрыв должен переводиться в безусловный переход в сборке, очень легко предсказать, что будет выполнено. en.wikipedia.org/wiki/Branch_predictor - person Careful Now; 19.02.2016
comment
Эта вики-страница будет охватывать то, что я не могу вставить в комментарий, а это, вероятно, много. Если вы хотите больше, я сделаю отдельный ответ и сделаю это правильно. - person Careful Now; 19.02.2016
comment
Привет, спасибо за ответ, но мне придется вернуться к вам, так как уже довольно поздно. Я только что «отлаживал» выпускную версию моей виртуальной машины для довольно многих сборок и, ну, о боже. О Боже. Переливается везде. РЖУ НЕ МОГУ. Я свяжусь с вами на этой вики-странице. Спасибо еще раз. - person SolaGratia; 19.02.2016
comment
Как правило, предсказатель ветвления даже не используется для безусловных ветвлений, поскольку не требуется никакого предсказания. Предсказание ветвления предназначено для условных ветвей. Тип предиктора — предсказатель ветвления target — может использоваться для безусловных ветвлений с непостоянными целями (так называемые косвенные ветвления), но здесь это не так. - person BeeOnRope; 19.02.2016