Как написать компилятор "понятный" код C? - PullRequest
1 голос
/ 01 октября 2019

Недавно мне пришлось написать код для критически важных функций реального времени, и я использовал несколько __ встроенных _... функций. Я понимаю, что такой код не переносим, ​​потому что не все компиляторы поддерживают "__buildin _..." функции или синтаксис. Мне было интересно, есть ли способ написать код на простом C, чтобы компилятор мог его распознать и использовать некоторую внутреннюю "__buildin _..." - такую ​​как функцию?

Ниже приведено описание небольшого опыта, который я сделал, но мой вопрос :

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

Например, обратные байты в Dword (так что первый байт становится последним, последний становится первым и т. д.), для архитектуры x86_64 предусмотрена специальная инструкция по сборке - bswap. Я пробовал 4 разных варианта:

#include <stdint.h>
#include <stdlib.h>

typedef union _helper_s
{
    uint32_t val;
    uint8_t bytes[4];
} helper_u;

uint32_t reverse(uint32_t d)
{
    helper_u b;
    uint8_t temp;

    b.val = d;
    temp = b.bytes[0];
    b.bytes[0] = b.bytes[3];
    b.bytes[3] = temp;
    temp = b.bytes[1];
    b.bytes[1] = b.bytes[2];
    b.bytes[2] = temp;

    return b.val;
}

uint32_t reverse1(uint32_t d)
{
    helper_u b;
    uint8_t temp;

    b.val = d;
    for (size_t i = 0; i < sizeof(uint32_t) / 2; i++)
    {
        temp = b.bytes[i];
        b.bytes[i] = b.bytes[sizeof(uint32_t) - i - 1];
        b.bytes[sizeof(uint32_t) - i - 1] = temp;
    }

    return b.val;
}

uint32_t reverse2(uint32_t d)
{
    return (d << 24) | (d >> 24 ) | ((d & 0xFF00) << 8) | ((d & 0xFF0000) >> 8);
}

uint32_t reverse3(uint32_t d)
{
    return __builtin_bswap32(d);
}

Все опции обеспечивают одинаковую функциональность. Я скомпилировал его с разными компиляторами и разными уровнями оптимизации, результаты были не очень хорошими:

  1. GCC - отлично! Для уровней оптимизации -O3 и -Os он дал одинаковый результат для всех функций:

    reverse:
            mov     eax, edi
            bswap   eax
            ret
    reverse1:
            mov     eax, edi
            bswap   eax
            ret
    reverse2:
            mov     eax, edi
            bswap   eax
            ret
    reverse3:
            mov     eax, edi
            bswap   eax
            ret
    
  2. Clang немного разочаровал меня. С -O3 он дал тот же результат, что и GCC, но с -Os он полностью потерял путь в reverse1. Он не распознал шаблон и создал гораздо менее оптимальный двоичный файл:

    reverse1:                               # @reverse1
            lea     rax, [rsp - 8]
            mov     dword ptr [rax], edi
            mov     ecx, 3
    .LBB1_1:                                # =>This Inner Loop Header: Depth=1
            mov     sil, byte ptr [rax]
            mov     dl, byte ptr [rsp + rcx - 8]
            mov     byte ptr [rax], dl
            mov     byte ptr [rsp + rcx - 8], sil
            dec     rcx
            inc     rax
            cmp     rcx, 1
            jne     .LBB1_1
            mov     eax, dword ptr [rsp - 8]
            ret
    

    На самом деле разница между reverse и reverse1 заключается в том, что reverse - это версия с развернутым циклом reverse1, поэтому я предполагаю, что с -Os компилятор даже не пытался развернуть или попытаться предвидеть цель цикла for.

  3. С ICC , дела пошли еще хуже, потому что он не смог распознать шаблон в функциях reverse и reverse1 как с уровнями оптимизации -O3 и -Os.

PS

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

Ответы [ 2 ]

1 голос
/ 02 октября 2019

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

uint32_t reverse2(uint32_t d)
{
    return ((d & 0x000000FFU) << 24) |
           ((d & 0x0000FF00U) << 8)  |
           ((d & 0x00FF0000U) >> 8)  |
           ((d & 0xFF000000U) >> 24) ;
}

Попробуйте онлайн: gcc

Попробуйте онлайн: clang

К вашим конкретным точкам:

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

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

К сожалению, у меня нет в голове удобного списка идиом. Но я могу сказать, что я в значительной степени выучил C, прочитав Язык программирования C (конечно, от K & R). И я был заядлым читателем FAQ по программированию на C (автор Steve Summit).

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

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

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

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

Таким образом, это еще одна причина, чтобы попытаться написать идиоматический код на Си.

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

1 голос
/ 01 октября 2019

Насколько мне известно, правильный способ сделать это - с помощью условной компиляции.

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

...