Чтение выровненной памяти с использованием быстрого доступа u32 может привести к проблемам строгого алиасинга в сочетании с встраиванием.

У меня есть функция, которая по существу создает хэш-значение для произвольной области памяти. Входной аргумент использует тип const void*, как способ сказать «это может быть что угодно». Итак по существу:

unsigned hash(const void* ptr, size_t size);

Все идет нормально.

Блоб байтов может быть чем угодно, и его начальный адрес может быть где угодно. Это означает, что иногда он выровнен по 32-битным границам, а иногда нет.

На некоторых платформах (например, armv6 или mips) чтение из невыровненной памяти приводит к значительному снижению производительности. На самом деле невозможно напрямую прочитать 32-битные данные из невыровненной памяти, поэтому компилятор предпочитает более безопасный алгоритм побайтовой рекомбинации (точные детали реализации скрыты за memcpy()).

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

Разница в производительности огромна, мы не говорим здесь о нескольких процентах, это означает увеличение производительности в 5 раз, а иногда и больше. Так что это не просто «хорошо», это на самом деле делает функцию конкурентоспособной или нет, полезной или нет.

Этот дизайн до сих пор работал нормально в приличном количестве сценариев.

Введите инлайнинг.

Теперь, когда реализация функции доступна во время компиляции, умный компилятор может убрать все уровни косвенности и сократить реализацию до основных элементов. В случае, когда он может доказать, что ввод обязательно выровнен, например, struct с определенными элементами, он может упростить код, удалить все косвенные const void* и перейти к базовой реализации, где область памяти эффективно читается с использованием const u32* указатель.

И теперь может вступить в действие строгий псевдоним, так как область ввода записывается с использованием struct S*, а считывается с использованием другого const u32*, что позволяет компилятору рассматривать эти две операции как полностью независимые, в конечном итоге переупорядочивая их, что приводит к неверный исход.

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

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

Но я еще не нашел решения для него. Мне сказали, что независимо от того, какой тип промежуточного указателя используется, даже если const char* является частью цепочки приведения, это не помешает окончательной операции чтения const u32* нарушить строгое сглаживание (просто повторяю, я не могу проверить это, потому что я не могу воспроизвести случай). Описанное таким образом, это кажется почти безнадежным.

Но я не могу не отметить, что memcpy() может должным образом избежать такого риска переупорядочения, хотя его интерфейс также использует const void*, и точная реализация сильно различается, но мы можем быть уверены, что это не просто чтение побайтно. байт const char*, так как производительность превосходна, и без колебаний использует векторный код, когда он быстрее. Кроме того, memcpy() — это функция, которая определенно много встроена. Поэтому я думаю, что должно быть решение этой проблемы.


person Cyan    schedule 04.06.2020    source источник
comment
Насколько портативным это должно быть? Могут быть специфичные для компилятора способы ослабления строгих правил псевдонимов. Например, gcc и clang имеют __attribute__((may_alias)).   -  person Nate Eldredge    schedule 05.06.2020
comment
Вы предполагаете, что unsigned будет таким же, как uint32_t, или вам нужна переносимость?   -  person chux - Reinstate Monica    schedule 05.06.2020
comment
На данный момент я считаю, что __attribute__((may_alias)) является лучшим ответом, поскольку пока не найдено портативного решения (помимо использования memcpy(), снижающего производительность в некоторых сценариях, как описано выше).   -  person Cyan    schedule 20.06.2020


Ответы (1)


(unsigned) char освобождается от строгих правил псевдонимов. Несмотря ни на что, следующее безопасно и разумно, пока sizeof(uint32_t) == 4:

unsigned hash(const void* ptr, size_t size) {
    const unsigned char* bytes = ptr;

    while (size >= 4) {
        uint32_t x;
        memcpy(&x, bytes, 4);
        bytes += 4;
        size -= 4;

        // Use x.
    }

    // Size leftover bytes.
}

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


Обратите внимание, что если вы принудительно выравниваете, вы можете заставить компилятор генерировать код быстрого пути даже с memcpy:

void* align(void* p, size_t n) {
    // n must be power of two.
    uintptr_t pi = (uintptr_t) p;
    return (unsigned char*) ((pi + (n - 1)) & -n);
}

inline uint32_t update_hash(uint32_t h, uint32_t x) {
    h += x;
    return h;
}

unsigned hash(const void* ptr, size_t size) {
    const unsigned char* bytes = (unsigned char*) ptr;
    const unsigned char* aligned_bytes = align((void*) bytes, 4);

    uint32_t h = 0;
    uint32_t x;
    if (bytes == aligned_bytes) {
        // Aligned fast path.
        while (size >= 4) {
            memcpy(&x, bytes, 4);
            h = update_hash(h, x);
            size -= 4;
            bytes += 4;
        }
    } else {
        // Slower unaligned path, copy to aligned buffer.
        while (size >= 4) {
            uint32_t buffer[32];
            size_t bufsize = size < 4*32 ? size / 4 : 32;
            memcpy(buffer, bytes, 4*bufsize);
            size -= 4*bufsize;

            for (int i = 0; i < bufsize; ++i) {
                h = update_hash(h, buffer[i]);
            }
        }
    }

    if (size) {
        // Assuming little endian.
        x = 0;
        memcpy(&x, bytes, size);
        h = update_hash(h, x);
    }

    return h;
}
person orlp    schedule 05.06.2020
comment
Это метод memcpy(), который уже используется для невыровненного пути доступа к памяти. И, как уже упоминалось, да, это работает, да, это безопасно, но это приводит к катастрофической производительности на системах, которые не могут обеспечить достойную производительность для невыровненного доступа к памяти (хороший пример — armv6; этой проблемы нет на x64 или aarch64). - person Cyan; 05.06.2020
comment
@Cyan Осмотрите сгенерированную сборку, и вы заметите, что memcpy полностью удален. Обратите внимание, что я только memcpy вхожу в одну локальную переменную. Выравнивание может быть решено, если вы в порядке с побайтовым доступом к первой паре элементов. Если это не так, вы все еще можете memcpy кусков за раз в выровненную память. - person orlp; 05.06.2020
comment
@Cyan Отмечено относительно armv6. Ваш вопрос в основном два в одном, я в основном сосредоточился на проблеме псевдонимов, которая имеет очень простое решение. Вы согласны с доступом к первым парам байтов один за другим, за которым следует u32 доступ, или вам нужен u32 доступ для всей вашей хеш-функции? - person orlp; 05.06.2020
comment
Вопрос касается этих моментов. Вход выровнен. Просто memcpy() это не волнует, и он использует общий безопасный, но сверхмедленный метод доступа. godbolt.org/z/vXQESu - person Cyan; 05.06.2020
comment
@Cyan Я обновил свой ответ примером быстрого / медленного пути. - person orlp; 05.06.2020
comment
Педантичный Несмотря ни на что, следующее безопасно --› Нет, если sizeof(uint32_t) != 4. Просто используйте sizeof(uint32_t) вместо 4. - person chux - Reinstate Monica; 05.06.2020
comment
align() предполагает линейную зависимость между uintptr_t и void *. Разумно, но не указано C. - person chux - Reinstate Monica; 05.06.2020
comment
@chux-ReinstateMonica C не предлагает какого-либо определенного способа выравнивания указателей, насколько мне известно, так что мда. То же самое для sizeof(uint32_t), не равного 4, тогда у вас все равно есть большие проблемы, и этот код дает сбой по большему количеству способов (хороший шанс, что bytes больше не является байтами). В какой-то момент вы должны начать принимать некоторые вещи о вашей платформе. - person orlp; 05.06.2020
comment
Я не вижу в вашем ответе ничего, что могло бы потерпеть неудачу, если бы 4 заменили на sizeof(uint32_t) и sizeof(uint32_t) != 4. - person chux - Reinstate Monica; 05.06.2020
comment
@chux-ReinstateMonica Единственный способ sizeof(uint32_t) != 4 - это если CHAR_BIT != 8, и в этом случае все предположение, что unsigned char является байтом, результатом хэш-функции, прямым порядком байтов и т. д., все разваливается. - person orlp; 05.06.2020
comment
Вопрос ОП и этот ответ не требуют CHAR_BIT == 8. Код отлично работает с CHAR_BIT == 16 or 32, если 4 заменить на sizeof(uint32_t). - person chux - Reinstate Monica; 05.06.2020
comment
Я обдумывал ту же идею, давая компилятору четкий сигнал понять, что адрес выровнен, и надеюсь, что в результате memcpy() будет оптимизировано как одно 32-битное чтение. Но пока произведенная сборка не соответствует этому ожиданию. В лучшем случае это все та же медленная побайтовая рекомбинация. Один из рисков заключается в том, что результат может зависеть от компилятора, а некоторые из них действительно плохо оптимизируют memcpy(), что делает этот метод не универсальным (если имеет значение производительность). В любом случае, я с удовольствием возьму любой пример вывода сборки. - person Cyan; 06.06.2020
comment
@Cyan Ты проверял мой метод? С GCC на ARM64 все работает просто отлично: godbolt.org/z/4vk6p- Обратите внимание на циклы. L7 и .L10. - person orlp; 06.06.2020
comment
aarch64 не имеет проблем с невыровненным доступом к памяти. Ситуация с этой архитектурой аналогична x64, поэтому ей не нужен быстрый путь: просто постоянное использование memcpy() прекрасно работает с gcc. Проблема строго ограничена процессорами, которые страдают невозможностью или снижением производительности при доступе к невыровненной памяти, например armv6 или mips. - person Cyan; 07.06.2020
comment
@Cyan Затем измените компилятор на ARM, вы увидите, что он все еще использует быстрый путь ... Не для MIPS, потому что GCC 5.4 кажется слишком устаревшим. - person orlp; 07.06.2020
comment
@Cyan: по какой-то причине поставщики компиляторов считают, что лучше распознавать случаи, когда медленный побайтовый код может быть преобразован в более быстрый словесный код, чем расширять язык, добавляя спецификации для некоторых официально неопределенных поведений - что-то авторы Стандарта намеренно предлагали делать реализации. Большинство полезных сценариев каламбура типов было бы легко обнаружить, если бы авторы компилятора приложили для этого хоть какие-то усилия. - person supercat; 15.06.2020
comment
@orlp: некоторые 32-битные архитектуры ARM также поддерживают невыровненный доступ. Попробуйте использовать -mcpu=Cortex-M0 и затем изучите генерацию кода. На самом деле, я считаю, что gcc и clang должны быть перетащены с пинками и криками, чтобы сгенерировать достойный код для этой платформы (например, заменить константы, используемые в цикле, значениями, загружаемыми из объекта static volatile), но должно быть очевидно, что код должен выполнять последовательность загрузки байтов, а не загрузка слов. - person supercat; 17.06.2020
comment
@Cyan: Одна из вещей, которые меня раздражают в отказе gcc/clang принимать конструкции для каламбура типов, заключается в том, что, хотя они приняли модель абстракции, где такая поддержка может быть несколько затруднена, это только потому, что их модель абстракции не может обрабатывать некоторые случаи определяется стандартом C. Расширение модели для фактической поддержки стандарта C, а также разрешение общих шаблонов каламбура на самом деле было бы проще, чем поддержка стандарта C без разрешения распространенных форм каламбура, выходящих за рамки минимальных требований. - person supercat; 19.06.2020