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 макрос, который проверяет, является ли вызов пустым до вызова quits (выхода из цикла for) только потому, что это последняя выполняемая инструкция (потому что эта последняя инструкция может быть частью функции или даже возвращением). Также, ip (указатель инструкции) — это просто unsigned long long (u64), как _PROGRAM_SIZE (размер программы в байтах). instr является байтом и является ссылкой на текущую инструкцию (1 байт).


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

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

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

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

3

Решение

Прежде чем ответить на конкретные вопросы, обратите внимание: нет никакого процессора, который бы выполнял 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, чтобы легко проверить сборку, сгенерированную для первый а также второй варианты. Они идентичны — вот цикл:

.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

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

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

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;
}
}
}

Он использует разрыв, чтобы существовать дела. Вот Годболт выход на 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 указывают на один и тот же поток управления, поэтому я ожидаю, что это будет верно для большинства приличных компиляторов с включенной оптимизацией.

3

Другие решения

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

1

По вопросам рекламы [email protected]