Передайте if-выражение только один раз - PullRequest
0 голосов
/ 03 мая 2018

Я сейчас собираю ядро, и у меня есть оператор if, который может (в худшем случае) выполняться несколько миллионов раз. Тем не менее, результат очевиден после первого запуска. Зная, что результат cmp хранится в регистре, есть ли способ запомнить результат вышеупомянутого оператора, чтобы не запускать его чаще? acpi_version равно ГАРАНТИРУЕТСЯ никогда не меняется.

SDT::generic_sdt* sdt_wrapper::get_table (size_t index) { //function is run many times with varying index
    if (index >= number_tables) { //no such index
        return NULL;
    }

    if (acpi_version == 0) [[unlikely]] {
        return (SDT::generic_sdt*) (rsdt_ptr -> PointerToOtherSDT [index]);
    } else [[likely]] {
        return (SDT::generic_sdt*) (xsdt_ptr -> PointerToOtherSDT [index]);
    }
}

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

Попытка, которую я попробовал, использовала следующий ASM- "HACK":

static inline uint32_t store_cmp_result (uint32_t value1, uint32_t value2) {
    uint32_t zf;
    asm volatile ( "cmp %0, %1" :: "a" (value1), "a" (value2) );
    asm volatile ( "mov %ZF, [WORD]%0" :: "a" (zf) );   
    return zf;
} 

static inline void prestored_condition (uint32_t pres, void (*true_func)(), void (*false_func) ()) {
    asm volatile ( "mov %0, %1" :: "a" (pres)  "Nd=" () );
    asm volatile ( "je %0" :: "a" (&true_func) );
    asm volatile ( "jne %0" :: "a" (&false_func) );
}

Тем не менее, это просто хакерское решение (оно не сработало, поэтому я отказался от него).

Теперь к вопросу:

Как я могу игнорировать этот оператор if после того, как он был вызван один раз, и просто использовать вывод последних раз?

Ответы [ 2 ]

0 голосов
/ 04 мая 2018

Ваша конкретная функция не является подходящим кандидатом для какой-либо оптимизации, которая удаляет, если if() по причинам Питер упоминает в своем ответе , в частности, что (1) обычно это чрезвычайно дешевый для вычисления и (2) он по определению вне критического пути, поскольку он является исключительно зависимостью управления и поэтому не является непрерывной частью какой-либо цепочки зависимостей.

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

В целях иллюстрации в вашем случае это будет примерно так. Сначала объявите typedef для вашей функции get_table и определите указатель функции с именем get_table вместо функции 1 :

typedef generic_sdt* (*get_table_fn)(size_t index);

// here's the pointer that you'll actually "call"
get_table_fn get_table = get_table_trampoline;

Обратите внимание, что get_table инициализируется значением get_table_trampoline, которое является нашей селекторной функцией, вызываемой только один раз, для выбора реализации времени выполнения. Похоже:

generic_sdt* get_table_trampoline(size_t index) {
    // choose the implementation
    get_table_fn impl = (acpi_version == 0) ? zero_impl : nonzero_impl;
    // update the function pointer to redirect future callers
    get_table = impl;
    // call the selected function 
    return impl(index);
}

Он просто выбирает, на основе acpi_version, использовать ли zero_impl или nonzero_impl версии функции, которые являются просто вашей реализацией с уже разрешенным оператором if, например:

generic_sdt* zero_impl(size_t index) {
    if (index >= number_tables) { //no such index
        return NULL;
    }

    return (SDT::generic_sdt*) (rsdt_ptr -> PointerToOtherSDT [index]);
}

generic_sdt* nonzero_impl(size_t index) {
    if (index >= number_tables) { //no such index
        return NULL;
    }

    return (SDT::generic_sdt*) (xsdt_ptr -> PointerToOtherSDT [index]);
}

Теперь все последующие вызывающие переходят непосредственно к базовой упрощенной реализации с if.

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

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

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

В многопоточной программе это вызовет неопределенное поведение в теории, хотя на практике редко 2 . Чтобы привести его в соответствие, вам нужно обернуть указатель get_table в std::atomic и использовать соответствующие методы для его загрузки и сохранения (достаточно использовать memory_order_relaxed, эффективно используя идиому racy-single-check).

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


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

2 Здесь я использую редко в очень ограниченном смысле: я не говорю, что это скомпилирует проблемный многопоточный код, но вы редко будете сталкиваться с проблема во время выполнения (это было бы довольно плохо). Скорее, я говорю, что это скомпилируется для исправления многопоточного кода на большинстве компиляторов и платформ. Таким образом, будет редко, что лежащий в основе сгенерированный код будет вообще небезопасен (и действительно, подобные шаблоны активно использовались и поддерживались в элементарностях до C ++ 11).

0 голосов
/ 04 мая 2018

Сгенерированный компилятором cmp/jcc на acpi_version == 0 примерно такой же дешевый, как вы получите в общем случае. Он должен очень хорошо прогнозировать, потому что ветка всегда идет одинаково каждый раз, а стоимость самой ветки довольно низкая, даже если она берется каждый раз. (Полученные ветви имеют немного большую стоимость, чем неиспользованные ветви, поскольку они влияют на этапы извлечения / декодирования внешнего интерфейса и разбивают используемые части кода на несколько строк I-кэша.)

Процессоры не могут сохранять результаты сравнения каким-либо особым образом, что быстрее, чем проверка целого числа на ненулевое. (т. е. уже ноль / ненулевое целое число означает , как вы будете хранить результат сравнения! ) 1

В конкретном случае, когда две стороны вашего if очень похожи, может быть место для экономии, но легко прогнозируемая ветвь сравнения + стоит очень дешево. Программы полны сравнения + ветки, поэтому современные процессоры должны очень хорошо их запускать. Примерно от 10 до 25% количества команд в обычной программе - это сравнение и переход, включая безусловные переходы (не указывайте точное число, я слышал это, но не смог найти надежный источник с быстрым поиском). Больше ветвей требует немного больше ресурсов прогнозирования ветвлений или в среднем ухудшает предсказание других ветвей, но это тоже небольшой эффект.

Компилятор уже выведет проверку циклов после вставки. ( Безусловно, самое важное, что вы можете здесь сделать, это убедиться, что небольшие функции доступа, такие как sdt_wrapper::get_table, могут быть встроенными, либо помещая их в .h, либо используя оптимизацию во время соединения ). Встроенный ассм может только усугубить ситуацию (http://gcc.gnu.org/wiki/DontUseInlineAsm), если только вы не совершите какой-нибудь супер-хак, например, не вставите метку в код asm, чтобы вы могли изменить ее или что-то в этом роде.

Если вы сравните так много, что думаете, что стоило бы хранить acpi_version в фиксированном глобальном регистре, выделенном только для этого (глобальная переменная регистра, которую поддерживает GNU C ++, но которая, вероятно, не на самом деле будет хорошо 2 , даже если вы думаете, что это так), тогда вместо этого вы можете сделать условие параметром шаблона для всего вашего кода (или макроса static constexpr или CPP), и построить 2 версии вашего кода: одну для true и одну для false . Когда вы узнаете значение условия при загрузке, распакуйте и восстановите страницы, содержащие версию, которая никогда не будет работать, и перейдите к версии, которая будет работать. (Или для неядерного, то есть обычной программы, выполняемой в пространстве пользователя под ОС, обычно не проблема просто оставить чистые страницы отображенными, особенно если их не трогать (в том числе путем перемещения во время выполнения)). if(acpi_version == 0) { rest_of_kernel<0>(...); } else { rest_of_kernel<1>(...); } (исключая unmap / free part).

Если rsdt_ptr и xsdt_ptr являются инвариантами, вы могли бы по крайней мере устранить этот дополнительный уровень косвенности, если оба PointerToOtherSDT массива (для краткости PTOS) находятся в статическом хранилище.


Исправление кода один раз при запуске

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

Ядро Linux делает это, но оно сложно: например, что-то вроде .pushsection list_of_addresses_to_patch; .quad .Lthis_instance%= ; .popsection для построения массива указателей (в виде специального раздела компоновщика) для мест, которые необходимо пропатчить, везде, где встроен оператор asm. Одно из мест, где используется этот трюк, - это исправление lock префиксов к nop на однопроцессорных машинах, работающих под ядром, скомпилированным с поддержкой SMP. Это исправление происходит один раз, при загрузке. (И даже может быть в состоянии исправить префиксы lock обратно перед горячим добавлением ЦП, потому что счетчики мьютексов все еще поддерживаются.)

Фактически, Linux даже использует asm goto и исправления между jmp или nop для таких неизменных условий, как ваши, которые определяются один раз при загрузке , в bool _static_cpu_has(u16 bit) в arch /x86/include/asm/cpufeature.h. Для начала, есть блок jmp, который выполняет обычную проверку во время выполнения, немного протестировав. Но он использует .section .altinstructions,"a" / .previous, чтобы записать, где находится каждый jmp, а также длину / местоположение патча. Он выглядит продуманно для работы с 2-байтовыми короткими по сравнению с 5-байтовыми длинными jmp rel8 / jmp rel32 прыжками. Таким образом, ядро ​​может исправить все места, где заканчивается этот код, заменив jmp либо jmp в нужном месте или nop, чтобы перейти к метке t_yes: return true. GCC компилирует это довольно хорошо, когда вы пишете if(_static_cpu_has(constant)) { ... }. После исправления в какой-то момент после обнаружения функций процессора вы получаете просто NOP и попадаете в тело цикла. (Или, возможно, несколько кратких инструкций NOP, я не проверял, но, надеюсь, нет!)

Это чертовски круто, так что я просто собираюсь скопировать код, потому что интересно видеть такое творческое использование inline asm. Я не искал код, который делает исправления, но очевидно, что + скрипт компоновщика - другие ключевые части этого. Я не пытаюсь предоставить работоспособную версию этого для этого случая, просто покажи, что метод возможен , и где найти реализацию GPLv2, которую ты мог бы скопировать.

// from Linux 4.16 arch/x86/include/asm/cpufeature.h

/*
 * Static testing of CPU features.  Used the same as boot_cpu_has().
 * These will statically patch the target code for additional
 * performance.
 */
static __always_inline __pure bool _static_cpu_has(u16 bit)
{
    asm_volatile_goto("1: jmp 6f\n"
         "2:\n"
         ".skip -(((5f-4f) - (2b-1b)) > 0) * "
             "((5f-4f) - (2b-1b)),0x90\n"
         "3:\n"
         ".section .altinstructions,\"a\"\n"
         " .long 1b - .\n"      /* src offset */
         " .long 4f - .\n"      /* repl offset */
         " .word %P[always]\n"      /* always replace */
         " .byte 3b - 1b\n"     /* src len */
         " .byte 5f - 4f\n"     /* repl len */
         " .byte 3b - 2b\n"     /* pad len */
         ".previous\n"
         ".section .altinstr_replacement,\"ax\"\n"
         "4: jmp %l[t_no]\n"
         "5:\n"
         ".previous\n"
         ".section .altinstructions,\"a\"\n"
         " .long 1b - .\n"      /* src offset */
         " .long 0\n"           /* no replacement */
         " .word %P[feature]\n"     /* feature bit */
         " .byte 3b - 1b\n"     /* src len */
         " .byte 0\n"           /* repl len */
         " .byte 0\n"           /* pad len */
         ".previous\n"
         ".section .altinstr_aux,\"ax\"\n"
         "6:\n"
         " testb %[bitnum],%[cap_byte]\n"
         " jnz %l[t_yes]\n"
         " jmp %l[t_no]\n"
         ".previous\n"
         : : [feature]  "i" (bit),
             [always]   "i" (X86_FEATURE_ALWAYS),
             [bitnum]   "i" (1 << (bit & 7)),
             [cap_byte] "m" (((const char *)boot_cpu_data.x86_capability)[bit >> 3])
         : : t_yes, t_no);
t_yes:
    return true;
t_no:
    return false;
}

Бинарное исправление во время выполнения для вашего конкретного случая

В вашем конкретном случае разница между вашими двумя версиями заключается в том, какой глобальный (?) Указатель вы разыменовываете, и типом PTOS. Хранить указатель на базу правого массива (например, void* или char*) легко с чистым C ++, но индексация по-другому сложна. В вашем случае это массив uint32_t или uint64_t, как гибкий элемент массива в конце структуры. (На самом деле uint32_t PTOS[1], потому что ISO C ++ не поддерживает гибкие элементы массива, но если вы собираетесь использовать встроенный синтаксис asm GNU, вам, вероятно, подойдет подходящий гибкий элемент массива, такой как uint32_t PTOS[]).

На x86-64 изменение масштабного коэффициента в режиме индексированной адресации с 4 до 8 принесло бы пользу, потому что 64-битная загрузка по сравнению с расширяющейся до нуля 32-битной нагрузкой использует тот же код операции, только REX. W = 0 (или без префикса REX) против REX.W = 1 для размера операнда. .byte 0x40; mov eax, [rdx + rdi*4] имеет ту же длину, что и mov rax, [rdx + rdi*8]. (Байт 0x40 в первом - это префикс REX со всеми очищенными битами. Во 2-й версии требуется REX.W = 1 для размера 64-битного операнда; первый ноль расширяется до RAX путем записи EAX. Если первая версия уже нужен префикс REX для такого регистра, как r10, у него уже будет префикс REX.) В любом случае, патчить один из них будет просто , если вы знаете, где находятся все соответствующие инструкции.

Если бы у вас была инфраструктура для записи мест для исправления, вы бы использовали ее для исправления инструкции mov, которая получает указатель таблицы и index в регистрах и возвращает 64-битное значение (из 32- или 64-битная загрузка). (И не забудьте фиктивный ввод, чтобы сообщить компилятору, что вы на самом деле читаете память, на которую указывает указатель таблицы, в противном случае компилятору разрешено выполнять оптимизации, которые могут нарушить ваш код как перемещение магазинов через оператор asm). Но вы должны быть осторожны; встроенный asm может повредить оптимизации, отключив постоянное распространение (например, для index). По крайней мере, если вы опустите volatile, компилятору будет разрешено считать его чистой функцией входных данных и использовать CSE.


Взломать это на чистом C ++

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

Сдвиг с переменным счетом стоит 3 моп на ЦП семейства Intel Sandybridge (http://agner.org/optimize/) (из-за устаревшей семантики CISC; count = 0 оставляет EFLAGS неизменным, поэтому EFLAGS является входом для счетчика переменных) сдвиги .) Если вы не позволите компилятору использовать BMI2 для shlx (сдвиги без флага). index += foo ? 0 : index будет условно удваивать index (разница в количестве одного сдвига), но делать это без разветвления на x86 не стоит для условия, которое предсказывает это хорошо.

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

uint64_t против uint32_t без исправлений во время выполнения - другая проблема; одна версия должна выполнять расширяющуюся до нуля 32-разрядную загрузку, а другая должна выполнять 64-разрядную загрузку (если старшие байты не оказываются для вас всегда нулевыми?) Мы могли бы всегда делать 64-битная загрузка, а затем маска для сохранения или сброса старших 32 бит, но для этого нужна другая константа. И это может привести к снижению производительности, если нагрузка пересекает границу строки кэша (или, что еще хуже, страницы). например если бы 32-разрядное значение было последним на странице, обычная 32-разрядная загрузка просто загрузила бы его, но для загрузки данных со следующей страницы потребовалась бы 64-разрядная загрузка + маска.

Но если взять обе эти вещи вместе, это действительно того не стоит. Просто для удовольствия, вот что вы могли бы сделать: исходный код + вывод asm в проводнике компилятора Godbolt

// I'm assuming rsdt_ptr and xsdt_ptr are invariants, for simplicity
static const char *selected_PTOS;
static uint64_t opsize_mask;   // 0x00000000FFFFFFFF or all-ones
static unsigned idx_scale;     // 2 or 3
// set the above when the value for acpi_version is found

void init_acpi_ver(int acpi_version) {
    ... set the static vars;
}

// branchless but slower than branching on a very-predictable condition!
SDT::generic_sdt* sdt_wrapper::get_table (size_t index)
{
    const char *addr = selected_PTOS + (index << idx_scale);
    uint64_t entry = *reinterpret_cast<const uint64_t*>(addr);
    entry &= opsize_mask;      // zero-extend if needed
    return reinterpret_cast<SDT::generic_sdt*>(entry);
}

Вывод Asm из Godbolt (с более простыми типами, так что он фактически компилируется)

get_table(unsigned long):
    mov     ecx, DWORD PTR idx_scale[rip]
    mov     rax, QWORD PTR selected_PTOS[rip]  # the table
    sal     rdi, cl
    mov     rax, QWORD PTR [rax+rdi]        # load the actual data we want
    and     rax, QWORD PTR opsize_mask[rip]
    ret

При использовании встраивания и CSE компилятор может сохранять некоторые из этих значений маски и числа сдвигов в регистрах, но это все еще дополнительная работа (и связывает регистры).

И, кстати, не делает static локальные переменные внутри функции; это заставит компилятор каждый раз проверять, выполнялась ли функция в первый раз. Быстрый путь для static local (все, что запускается после того, как пыль осела из кода инициализации) довольно дешевый, но примерно такой же стоимости, как и то, что вы пытаетесь избежать : a ветвь с целым числом, отличным от нуля!

int static_local_example() {
    static int x = ext();
    return x;
}

    # gcc7.3
    movzx   eax, BYTE PTR guard variable for static_local_example()::x[rip]
    test    al, al
    je      .L11
    # x86 loads are always acquire-loads, other ISAs would need a barrier after loading the guard
    mov     eax, DWORD PTR static_local_example()::x[rip]
    ret

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


Сноска 1 : Если ваше условие было acpi_version == 4, то MIPS мог бы сохранить одну инструкцию от сохранения результата 0/1. Вместо сравнения с флагами, имеет сравнение в регистр и инструкции ветвления, которые сравниваются с нулем или регистром, и регистр, который уже читается как ноль. Даже на x86 сравнение для нуля / не нуля экономит байт размера кода, если значение уже находится в регистре (test eax,eax против cmp eax,4). Это сэкономило бы больше, если бы это было результатом инструкции ALU (таким образом, ZF уже был бы установлен), но это не так.

Но большинство других архитектур сравниваются с флагами, и вы не можете загрузить их из памяти напрямую во флаги. Таким образом, вы захотите хранить статический bool результат, только если acpi_version было бы сравнительно дорого, например, целое число шире, чем регистр, такой как __int128 или int64_t на 32-битной машине.

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

Прогноз ветвления + спекулятивное выполнение означает, что ЦП на самом деле не нужно ждать результата загрузки, когда вы ветвитесь на нем, и если вы все время читаете его, он все равно будет горячим в кеше L1d. (Спекулятивное выполнение означает, что управляющие зависимости не являются частью критического пути, при условии правильного предсказания ветвления)


PS: если вы сделали это далеко и все поняли, то вам следует рассмотреть возможность использования бинарного исправления, как это делает Linux, для нескольких часто проверяемых условий. Если нет, то, вероятно, не стоит!

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...