Подтвердить что ты не робот

Воспользуйтесь преимуществами неосновного доступа к памяти ARM при написании чистого кода C

Раньше считалось, что ARM-процессоры не смогли правильно обрабатывать непринятый доступ к памяти (ARMv5 и ниже). Что-то вроде u32 var32 = *(u32*)ptr; просто сработает (повысит исключение), если ptr не был правильно выровнен по 4 байтам.

Написание такого утверждения будет отлично работать для x86/x64, поскольку этот процессор всегда справлялся с такой ситуацией очень эффективно. Но, согласно стандарту C, это не "правильный" способ написать его. u32, по-видимому, эквивалентен структуре из 4 байтов, которая должна быть выровнена на 4 байта.

Правильный способ достижения такого же результата при сохранении правильности правопорядка и обеспечении полной совместимости с любым процессором:

u32 read32(const void* ptr) 
{ 
    u32 result; 
    memcpy(&result, ptr, 4); 
    return result; 
}

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

Ну, проблема в ARM, нам не очень повезло.

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

В ориентированном на производительность приложении, которое в значительной степени зависит от операций чтения, можно измерить разницу между 1-й и 2-й версиями: оно находится в 5x в настройках gcc -O2. Это слишком много, чтобы его можно было игнорировать.

Попытка найти способ использования возможностей ARMv6/v7, я искал рекомендации по нескольким примерным кодам. Unfortunatley, похоже, они выбирают первое утверждение (прямой u32 доступ), который не должен быть правильным.

Это не все: новые версии GCC теперь пытаются реализовать автоматическую вектологию. На x64 это означает SSE/AVX, на ARMv7, что означает NEON. ARMv7 также поддерживает некоторые новые коды "Load Multiple" (LDM) и "Store Multiple" (STM), которые требуют выравнивания указателя.

Что это значит? Ну, компилятор может свободно использовать эти расширенные инструкции, даже если они не были специально вызваны из кода C (нет встроенных). Чтобы принять такое решение, он использует тот факт, что a u32* pointer должен быть выровнен по 4 байтам. Если это не так, то все ставки отключены: undefined поведение, сбои.

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

Итак, теперь это дилемма: как получить доступ к встроенной производительности ARMv6/v7 по негласному доступу к памяти без записи неправильной версии u32 access?

PS: Я также пробовал инструкции __packed(), и с точки зрения производительности они, похоже, работают точно так же, как и метод memcpy.

[Изменить]: Спасибо за отличные элементы, полученные до сих пор.

Посмотрев на сгенерированную сборку, я смог подтвердить @Notlikethat, что версия memcpy действительно генерирует правильный код операции ldr (невысокая загрузка). Однако я также обнаружил, что сгенерированная сборка бесполезно вызывает str (command). Таким образом, полная операция теперь представляет собой невыровненную нагрузку, выровненное хранилище и конечную согласованную нагрузку. Это намного больше, чем нужно.

Отвечая @haneefmubarak, да, код правильно встроен. И нет, memcpy очень далек от обеспечения максимально возможной скорости, так как принудительное принятие кода для прямого доступа u32 приводит к огромному приросту производительности. Поэтому существует какая-то лучшая возможность.

Большое спасибо @artless_noise. Ссылка на службу godbolt неоценима. Я никогда не мог так ясно видеть эквивалентность исходного кода C и его сборки. Это очень вдохновляет.

Я завершил один из примеров @artless, и он дает следующее:

#include <stdlib.h>
#include <memory.h>
typedef unsigned int u32;

u32 reada32(const void* ptr) { return *(const u32*) ptr; }

u32 readu32(const void* ptr) 
{ 
    u32 result; 
    memcpy(&result, ptr, 4); 
    return result; 
}

после компиляции с использованием ARM GCC 4.8.2 при -O3 или -O2:

reada32(void const*):
    ldr r0, [r0]
    bx  lr
readu32(void const*):
    ldr r0, [r0]    @ unaligned
    sub sp, sp, #8
    str r0, [sp, #4]    @ unaligned
    ldr r0, [sp, #4]
    add sp, sp, #8
    bx  lr

Достаточно сказать....

4b9b3361

Ответ 1

ОК, ситуация более запутанная, чем хотелось бы. Итак, в попытке прояснить, вот выводы в этом путешествии:

доступ к неизмененной памяти

  • Единственное портативное стандартное решение C для доступа к нерациональной памяти - это memcpy. Я надеялся получить еще один вопрос по этому вопросу, но, по-видимому, это единственный, который был найден до сих пор.

Пример кода:

u32 read32(const void* ptr)  { 
    u32 value; 
    memcpy(&value, ptr, sizeof(value)); 
    return value;  }

Это решение безопасно при любых обстоятельствах. Он также компилируется в тривиальную операцию load register на целевой объект x86 с использованием GCC.

Однако, по ARM-цели с использованием GCC, это переводит в слишком большую и бесполезную последовательность сборки, что приводит к снижению производительности.

Используя цель Clang on ARM, memcpy отлично работает (см. комментарий @notlikethat ниже). Было бы легко обвинить GCC в целом, но это не так просто: решение memcpy отлично работает на GCC с объектами x86/x64, PPC и ARM64. Наконец, при попытке другого компилятора icc13 версия memcpy удивительно тяжелее на x86/x64 (4 инструкции, в то время как одного должно быть достаточно). И это только комбинации, которые я мог бы проверить до сих пор.

Я должен поблагодарить проект godbolt, чтобы сделать такие заявления легко наблюдать.

  1. Второе решение - использовать структуры __packed. Это решение не является стандартом C и полностью зависит от расширения компилятора. Как следствие, способ записи зависит от компилятора, а иногда и от его версии. Это беспорядок для обслуживания портативного кода.

В большинстве случаев это приводит к улучшению генерации кода, чем memcpy. В большинстве случаев только...

Например, в отношении вышеприведенных случаев, когда решение memcpy не работает, вот выдержки:

  • на x86 с поддержкой ICC: __packed работает
  • на ARMv7 с GCC: __packed работает решение
  • на ARMv6 с GCC: не работает. Сборка выглядит еще более уродливее, чем memcpy.

    1. Последнее решение - использовать прямой доступ u32 к неустановленным ячейкам памяти. Это решение использовалось в течение десятилетий на процессоре x86, но не рекомендуется, так как оно нарушает некоторые стандартные принципы C: компилятор имеет право рассматривать этот оператор как гарантию того, что данные правильно выровнены, что приводит к генерации ошибок.

К сожалению, по крайней мере в одном случае это единственное решение, способное извлечь производительность из целевой. А именно для GCC на ARMv6.

Не используйте это решение для ARMv7: GCC может генерировать инструкции, зарезервированные для доступа к выделенной памяти, а именно LDM (Load Multiple), приводящие к сбою.

Даже на x86/x64 становится опасным писать ваш код таким образом в наши дни, поскольку компиляторы нового поколения могут попытаться автоиндексировать некоторые совместимые циклы, генерируя код SSE/AVX на основе предположения, что эти позиции памяти должным образом выровненный, сбой программы.

В качестве примера здесь приведены результаты, обобщенные в виде таблицы, с использованием соглашения: memcpy > упакованный > direct.

| compiler  | x86/x64 | ARMv7  | ARMv6  | ARM64  |  PPC   |
|-----------|---------|--------|--------|--------|--------|
| GCC 4.8   | memcpy  | packed | direct | memcpy | memcpy |
| clang 3.6 | memcpy  | memcpy | memcpy | memcpy |   ?    |
| icc 13    | packed  | N/A    | N/A    | N/A    | N/A    |

Ответ 2

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

Одна вещь, которую вы можете сделать, это использовать static inline, что позволит компилятору встроить функцию load32(), тем самым увеличивая производительность. Однако на более высоких уровнях оптимизации компилятор должен уже вставлять это для вас.

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


В этот момент вы можете сделать очень мало, чтобы выровнять данные. Однако, если вы имеете дело со смежным массивом неуравновешенных u32, вы можете сделать одно:

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

// get array of aligned u32
uint32_t *align32 (const void *p, size_t n) {
    uint32_t *r = malloc (n * sizeof (uint32_t));

    if (r)
        memcpy (r, p, n);

    return r;
}

Это просто использует выделение нового массива с помощью malloc(), потому что malloc() и друзья выделяют память с правильным выравниванием для всего:

Функции malloc() и calloc() возвращают указатель на выделенную память, которая соответствующим образом выровнена для любой переменной.

- malloc(3), Руководство для программистов Linux

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