Clang неправильно понимает спецификатор указателя const?

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

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

typedef struct {
    uint32_t        event_type;
    uintptr_t       param;
} event_t;

typedef struct
{
    event_t                     *queue;
    size_t                      size;
    uint16_t                    num_of_items;
    uint8_t                     rd_idx;
    uint8_t                     wr_idx;
} queue_t;

static bool queue_is_full(const queue_t *const queue_ptr)
{
    return queue_ptr->num_of_items == queue_ptr->size;
}

static size_t queue_get_size_mask(const queue_t *const queue_ptr)
{
    return queue_ptr->size - 1;
}

int queue_enqueue(queue_t *const queue_ptr, const event_t *const event_ptr)
{
    if(queue_is_full(queue_ptr))
    {
        return 1;
    }

    queue_ptr->queue[queue_ptr->wr_idx++] = *event_ptr;
    queue_ptr->num_of_items++;
    queue_ptr->wr_idx &= queue_get_size_mask(queue_ptr);

    return 0;
}

Я скомпилировал этот код с помощью clang версии 11.0.0 (clang-1100.0.32.5)

clang -O2 -arch armv7m -S test.c -o test.s

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

_queue_enqueue:
        .cfi_startproc
@ %bb.0:
        ldrh    r2, [r0, #8]            ---> reads the queue_ptr->num_of_items
        ldr     r3, [r0, #4]            ---> reads the queue_ptr->size
        cmp     r3, r2
        itt     eq
        moveq   r0, #1
        bxeq    lr
        ldrb    r2, [r0, #11]           ---> reads the queue_ptr->wr_idx
        adds    r3, r2, #1
        strb    r3, [r0, #11]           ---> stores the queue_ptr->wr_idx + 1
        ldr.w   r12, [r1]
        ldr     r3, [r0]
        ldr     r1, [r1, #4]
        str.w   r12, [r3, r2, lsl #3]
        add.w   r2, r3, r2, lsl #3
        str     r1, [r2, #4]
        ldrh    r1, [r0, #8]            ---> !!! re-reads the queue_ptr->num_of_items
        adds    r1, #1
        strh    r1, [r0, #8]
        ldrb    r1, [r0, #4]            ---> !!! re-reads the queue_ptr->size (only the first byte)
        ldrb    r2, [r0, #11]           ---> !!! re-reads the queue_ptr->wr_idx
        subs    r1, #1
        ands    r1, r2
        strb    r1, [r0, #11]           ---> !!! stores the updated queue_ptr->wr_idx once again after applying the mask
        movs    r0, #0
        bx      lr
        .cfi_endproc
                                        @ -- End function

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

int queue_enqueue(queue_t * restrict const queue_ptr, const event_t * restrict const event_ptr)

Я знаю, что в clang по умолчанию строгие псевдонимы отключены. Но в этом случае указатель event_ptr определяется как const, поэтому содержимое его объекта не может быть изменено этим указателем, следовательно, он не может влиять на содержимое, на которое указывает queue_ptr (при условии, что объекты перекрываются в памяти), верно?

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

int queue_enqueue(queue_t *const queue_ptr, const event_t *const event_ptr)

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


Сгенерированная сборка с ключевым словом restrict не содержит повторов:

_queue_enqueue:
        .cfi_startproc
@ %bb.0:
        ldr     r3, [r0, #4]
        ldrh    r2, [r0, #8]
        cmp     r3, r2
        itt     eq
        moveq   r0, #1
        bxeq    lr
        push    {r4, r6, r7, lr}
        .cfi_def_cfa_offset 16
        .cfi_offset lr, -4
        .cfi_offset r7, -8
        .cfi_offset r6, -12
        .cfi_offset r4, -16
        add     r7, sp, #8
        .cfi_def_cfa r7, 8
        ldr.w   r12, [r1]
        ldr.w   lr, [r1, #4]
        ldrb    r1, [r0, #11]
        ldr     r4, [r0]
        subs    r3, #1
        str.w   r12, [r4, r1, lsl #3]
        add.w   r4, r4, r1, lsl #3
        adds    r1, #1
        ands    r1, r3
        str.w   lr, [r4, #4]
        strb    r1, [r0, #11]
        adds    r1, r2, #1
        strh    r1, [r0, #8]
        movs    r0, #0
        pop     {r4, r6, r7, pc}
        .cfi_endproc
                                        @ -- End function

Дополнение:

После некоторого обсуждения с Лундином в комментариях к его ответу у меня сложилось впечатление, что повторные чтения могут быть вызваны тем, что компилятор предположил бы, что queue_ptr->queue потенциально может указывать на сам *queue_ptr. Поэтому я изменил структуру queue_t, чтобы она содержала массив вместо указателя:

typedef struct
{
    event_t                     queue[256]; // changed from pointer to array with max size
    size_t                      size;
    uint16_t                    num_of_items;
    uint8_t                     rd_idx;
    uint8_t                     wr_idx;
} queue_t;

Однако повторные чтения остались прежними. Я до сих пор не могу понять, что могло заставить компилятор думать, что поля queue_t могут быть изменены и, следовательно, требуют повторного чтения... Следующее объявление исключает повторное чтение:

int queue_enqueue(queue_t * restrict const queue_ptr, const event_t *const event_ptr)

Но почему queue_ptr должен быть объявлен как указатель restrict, чтобы предотвратить повторное чтение, я не понимаю (если только это не "ошибка" оптимизации компилятора).

P.S.

Я также не смог найти ссылку на файл/отчет о проблеме в clang, которая не приводит к сбою компилятора...


person Alex Lop.    schedule 16.10.2019    source источник
comment
const не означает, что значение не может измениться; это означает, что значение нельзя изменить с помощью идентификатора const. int foo; int *a = &foo; int const *b = &foo; *a = 42 /*ok*/; *b = -1 /*nope*/;   -  person pmg    schedule 16.10.2019
comment
@pmg Я имел в виду, что его нельзя изменить указателем const event_t *. Я добавлю это уточнение   -  person Alex Lop.    schedule 16.10.2019
comment
@StaceyGirl Почему ты удалил свой ответ?   -  person Alex Lop.    schedule 25.10.2019
comment
@АлексЛоп. Это не было давлением регистра. Это видно из сборки для других архитектур. Я думаю, вам следует изучить IR, чтобы выяснить, что происходит, поскольку в нем есть явные аннотации псевдонимов (!tbaa X). Я проверил это и увидел, что Clang копирует события без аннотации TBAA, которая может объяснить сброс значения, но в то же время я видел, что разные конфигурации компилятора могут генерировать llvm.memcpy вызовы WITH !tbaa метаданных. Я мог бы попытаться проверить это позже, не могу написать ответ от этого.   -  person StaceyGirl    schedule 25.10.2019
comment
@StaceyGirl Спасибо! Я ценю его. Буду ждать вашего ввода. Также обратите внимание, что подобное поведение можно воспроизвести на архитектуре x86, и оно выглядит странно... godbolt.org/z/ 5OVBEy   -  person Alex Lop.    schedule 25.10.2019


Ответы (4)


[речь об оригинальной программе]

Это вызвано недостатком метаданных TBAA, сгенерированных Clang.

Если вы излучаете LLVM IR с помощью -S -emit-llvm, вы увидите (обрезано для краткости):

...

  %9 = load i8, i8* %wr_idx, align 1, !tbaa !12
  %10 = trunc i32 %8 to i8
  %11 = add i8 %10, -1
  %conv4 = and i8 %11, %9
  store i8 %conv4, i8* %wr_idx, align 1, !tbaa !12
  br label %return

...

!0 = !{i32 1, !"wchar_size", i32 4}
!1 = !{i32 1, !"min_enum_size", i32 4}
!2 = !{!"clang version 10.0.0 (/home/chill/src/llvm-project 07da145039e1a6a688fb2ac2035b7c062cc9d47d)"}
!3 = !{!4, !9, i64 8}
!4 = !{!"queue", !5, i64 0, !8, i64 4, !9, i64 8, !6, i64 10, !6, i64 11}
!5 = !{!"any pointer", !6, i64 0}
!6 = !{!"omnipotent char", !7, i64 0}
!7 = !{!"Simple C/C++ TBAA"}
!8 = !{!"int", !6, i64 0}
!9 = !{!"short", !6, i64 0}
!10 = !{!4, !8, i64 4}
!11 = !{!4, !5, i64 0}
!12 = !{!4, !6, i64 11}

См. метаданные TBAA !4: это дескриптор типа для queue_t (кстати, я добавил имена в структуры, например, typedef struct queue ..., вы можете увидеть там пустую строку). Каждый элемент в описании соответствует полям структуры и посмотрите на !5, которое является полем event_t *queue: это "любой указатель"! На данный момент мы потеряли всю информацию о фактическом типе указателя, что говорит мне о том, что компилятор предположит, что запись через этот указатель может изменить любой объект памяти.

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

Скомпилируйте исходную программу с помощью -Xclang -new-struct-path-tbaa. Моя точная командная строка была (и я включил stddef.h вместо stdlib.h с той сборки разработки без libc):

./bin/clang -I lib/clang/10.0.0/include/ -target armv7m-eabi -O2 -Xclang -new-struct-path-tbaa  -S queue.c

В результате получилась такая сборка (опять же, немного обрезано):

queue_enqueue:
    push    {r4, r5, r6, r7, lr}
    add r7, sp, #12
    str r11, [sp, #-4]!
    ldrh    r3, [r0, #8]
    ldr.w   r12, [r0, #4]
    cmp r12, r3
    bne .LBB0_2
    movs    r0, #1
    ldr r11, [sp], #4
    pop {r4, r5, r6, r7, pc}
.LBB0_2:
    ldrb    r2, [r0, #11]                   ; load `wr_idx`
    ldr.w   lr, [r0]                        ; load `queue` member
    ldrd    r6, r1, [r1]                    ; load data pointed to by `event_ptr`
    add.w   r5, lr, r2, lsl #3              ; compute address to store the event
    str r1, [r5, #4]                        ; store `param`
    adds    r1, r3, #1                      ; increment `num_of_items`
    adds    r4, r2, #1                      ; increment `wr_idx`
    str.w   r6, [lr, r2, lsl #3]            ; store `event_type`
    strh    r1, [r0, #8]                    ; store new value for `num_of_items`
    sub.w   r1, r12, #1                     ; compute size mask
    ands    r1, r4                          ; bitwise and size mask with `wr_idx`
    strb    r1, [r0, #11]                   ; store new value for `wr_idx`
    movs    r0, #0
    ldr r11, [sp], #4
    pop {r4, r5, r6, r7, pc}

Выглядит хорошо, не правда ли! :D

Я упоминал ранее, что есть недостатки с "новым путем к структуре", но для этого: в списке рассылки.

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

person chill    schedule 26.10.2019
comment
Спасибо! Этот ответ выглядит гораздо более удовлетворительным, чем все, что было опубликовано здесь ранее. Так почему бы не всегда использовать опцию -Xclang -new-struct-path-tbaa? Каковы его преимущества/недостатки (или, возможно, это должен быть отдельный вопрос здесь...)? - person Alex Lop.; 27.10.2019
comment
@АлексЛоп. Не забудьте принять ответ, если он отвечает на вопрос (я думаю, что да). В противном случае при меньшем количестве голосов за ответы срок действия награды истечет и никому не будет предоставлен. - person StaceyGirl; 29.10.2019
comment
@StaceyGirl Спасибо, Стейси, я согласен, что это отвечает на мой вопрос ... не до конца подумал. Я до сих пор не вижу, что я могу извлечь из этого для своих навыков программирования/оптимизации, чтобы я мог реализовать наиболее оптимизированный код для слоев инфраструктуры. Если только это действительно не может быть отнесено к категории проблем с компилятором, которые будут исправлены в будущих выпусках и не связаны с моими привычками кодирования. - person Alex Lop.; 29.10.2019
comment
@chill, не могли бы вы сослаться (или просто упомянуть) в своем ответе на следующие темы, чтобы я мог пометить ваш ответ как принятый: 1. Как предотвратить такое повторное чтение в будущем 2. Что может быть недостатком вашего флага? используется для устранения этих повторных прочтений. - person Alex Lop.; 29.10.2019
comment
@АлексЛоп. Боюсь, в этом случае нельзя извлечь общий урок. В принципе, чем больше информации можно предоставить компилятору, тем лучше: такие вещи, как restrict, строгая типизация (не необоснованные приведения типов, каламбуры и т. д.), соответствующие функции и атрибуты переменных... но не в этом случае, оригинал программа уже содержала всю необходимую информацию. Это просто недостаток компилятора, и лучший способ исправить это — повысить осведомленность: задать вопрос в списке рассылки и/или отправить отчеты об ошибках. - person chill; 29.10.2019
comment
Спасибо @chill. Просто добавьте то, что вы написали в последнем комментарии, к самому ответу. Я приму его и перейду в ветку электронной почты для получения более подробной информации. - person Alex Lop.; 29.10.2019
comment
@АлексЛоп. Думаю, здесь есть чему поучиться: загружать раньше, а сохранять позже. Вы должны предпочесть загружать все необходимые поля-члены в начале и сохранять их в конце. В вашем случае вы должны загрузить wr_ptr, num_of_items в начале, изменить их, они сохранят их в конце. У компилятора гораздо больше гарантий относительно локальных переменных (если не занят их адрес). Это то, что помогает, даже когда компилятору приходится перезагружать значения. - person StaceyGirl; 29.10.2019
comment
@StaceyGirl верно, но это будет выглядеть как сборка C. local_a = global_b; local_a++; global_b = local_a вместо просто global_b++;... - person Alex Lop.; 29.10.2019

event_t член queue_ptr может указывать на ту же память, что и event_ptr. Компиляторы, как правило, производят менее эффективный код, когда они не могут исключить, что два указателя указывают на одну и ту же память. Так что нет ничего странного в том, что restrict ведет к лучшему коду.

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

«Строгий псевдоним» скорее относится к случаям, когда компилятор может срезать углы, например, предполагая, что uint16_t* не может указывать на uint8_t* и т. д. Но в вашем случае у вас есть два полностью совместимых типа, один из них просто обернут во внешний структура.

person Lundin    schedule 16.10.2019
comment
Но даже если они указывают на одно и то же место, единственным действием здесь является копирование двойного слова за двойным словом, так как же это сбивает с толку компилятор? - person Alex Lop.; 16.10.2019
comment
@АлексЛоп. Потому что вы обычно не можете копировать объект по адресу, где он уже хранится, или копировать объекты, которые перекрываются. - person Lundin; 16.10.2019
comment
хорошо, я могу это понять, но я все еще не понимаю, как это может повлиять на поля совершенно другой структуры, такой как queue_t? Зачем перечитывать эти поля? - person Alex Lop.; 16.10.2019
comment
Квалификаторы const на самом деле не имеют значения, потому что они были добавлены функцией, а исходный тип можно изменить в другом месте. - Это верно для многих других случаев, даже когда функция получает единственный указатель. Но все же созданная сборка не всегда читает, модифицирует и сразу же записывает значение в память (если только оно не volatile) - person Alex Lop.; 16.10.2019
comment
@АлексЛоп. Квалификатор *const — это стиль кодирования для программиста, а не для компилятора. Компилятор должен иметь возможность определить, изменена или нет переменная-указатель внутри функции, независимо от квалификаторов. - person Lundin; 16.10.2019
comment
Я имел в виду const event_t *, а не const * - person Alex Lop.; 16.10.2019
comment
@АлексЛоп. В строке queue_ptr->queue[queue_ptr->wr_idx++] = *event_ptr; компилятор не может знать, является ли queue_ptr->queue тем же адресом, что и event_ptr. Квалификаторы const не имеют значения. - person Lundin; 16.10.2019
comment
И вы хотите сказать, что это заставляет компилятор перечитывать все после этой точки? Даже к queue_ptr->queue и event_ptr это не имеет никакого отношения, типа барьер какой-то? - person Alex Lop.; 16.10.2019
comment
@АлексЛоп. Поскольку компилятор не может предположить, что он не изменил исходные данные, он должен их перечитать. Я не могу воспроизвести вашу конкретную систему, но на x86 clang restict имеет значение, так как все переменные могут храниться в регистрах во время цикла. - person Lundin; 16.10.2019
comment
Верно, я знаю, что restrict это исправляет. Однако в каком случае присвоение event_t queue_ptr->queue[...] может повлиять на исходные значения? В случае, если queue_ptr->queue указывает на какое-то место внутри *queue_ptr? Если да, то clang слишком защитный, потому что тот же код без restrict, скомпилированный с gcc, не выполняет повторное чтение... - person Alex Lop.; 16.10.2019
comment
@АлексЛоп. Нельзя предположить, что запись в queue_ptr->queue не изменит содержимое event_ptr. Но да, похоже, clang предполагает, что другие несвязанные элементы в структуре очереди также могут быть изменены из-за записи в queue_ptr->queue. Это действительно кажется немного странным. - person Lundin; 16.10.2019
comment
Ооо, теперь я понимаю, что вы имели в виду... Я говорил о полях queue_t, потому что они были перечитаны после задания. Я согласен, что компилятор может предположить, что содержимое *event_ptr может быть изменено, но он перечитывает поля *queue_ptr, поэтому я запутался... - person Alex Lop.; 16.10.2019

Насколько я могу судить, да, в вашем коде queue_ptr содержимое указателя не может быть изменено. Это ошибка оптимизации? Это упущенная возможность оптимизации, но я бы не назвал это ошибкой. Он не «неправильно понимает» const, он просто не проводит/не выполняет необходимые анализы, чтобы определить, что его нельзя изменить для этого конкретного сценария.

В качестве примечания: queue_is_full(queue_ptr) может изменять содержимое *queue_ptr, даже если у него есть параметр const queue_t *const, потому что он может законно отказаться от константности, поскольку исходный объект не является константой. При этом определение quueue_is_full видно и доступно компилятору, поэтому он может убедиться, что это действительно так.

person bolov    schedule 16.10.2019
comment
При этом определение quueue_is_full видимо и доступно для компилятора, поэтому он может убедиться, что это действительно так. - мало того, что он просто виден, clang также встраивает его содержимое. - person Alex Lop.; 16.10.2019
comment
4 из 25 инструкций по сборке не нужны, также инструкция ldr/str может занять ~3 цикла на коре головного мозга-m3, по сравнению с обычными mov/add/and, которые занимают 1 цикл. Принимая во внимание, что -O2 предоставляется, я бы не назвал это просто упущенной возможностью - person Alex Lop.; 16.10.2019

Как вы знаете, ваш код изменяет данные, делая недействительным состояние const:

queue_ptr->num_of_items++; // this stores data in the memory

Без ключевого слова restrict компилятор должен предположить, что два типа могут совместно использовать одно и то же пространство памяти.

Это требуется в обновленном примере, потому что event_t является членом queue_t, а строгое присвоение псевдонимов применяется к:

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

В исходном примере существует ряд причин, по которым типы могут считаться псевдонимами, что приводит к одному и тому же результату (т. е. использование указателя char и тот факт, что типы могут считаться совместимыми «достаточно» на некоторых архитектурах, если не на всех). ).

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

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

(РЕДАКТИРОВАТЬ) Для вашего удобства, вот полное правило, касающееся доступа к переменной:

Доступ к хранимому значению объекта должен осуществляться только выражением lvalue, которое имеет один из следующих типов (88):

- тип, совместимый с эффективным типом объекта,

- уточненная версия типа, совместимая с действующим типом объекта,

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

- тип, который является подписанным или беззнаковым типом, соответствующим уточненной версии эффективного типа объекта,

- тип агрегата или объединения, который включает в себя один из вышеупомянутых типов среди своих членов (включая, рекурсивно, член подагрегата или содержащего объединения), или

— тип персонажа.

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

P.S.

Суффикс _t зарезервирован POSIX. Вы можете рассмотреть возможность использования другого суффикса.

Обычной практикой является использование _s для структур и _u для объединений.

person Myst    schedule 24.10.2019
comment
Но event_t не является членом queue_t (в исходном примере). - person StaceyGirl; 25.10.2019
comment
@StaceyGirl - правда, но есть и другие причины, вызывающие это. Я не хочу защищать это, поэтому я прикрепил список к ответу ... Можно утверждать, что тип event_t достаточно совместим, чтобы его можно было рассматривать для каламбура типа (uint32_t дополняется, оставляя первые два члена выровненными). .. Другие могут заметить, что любой указатель char (или uint8_t *) всегда считается возможным псевдонимом памяти (в нашем случае queue_ptr->wr_idx указывает на unsigned char, поднимая флаг алиасинга)... кто знает. Я действительно не вникаю в то, почему они могут использовать псевдонимы, а не в эффекты. - person Myst; 25.10.2019
comment
Я не думаю, что здесь дело обстоит именно так... например, взгляните на это воспроизведение проблемы с различными случаями: godbolt.org/z/5OVBEy – f5 не перечитывал, а f6 читает - person Alex Lop.; 25.10.2019
comment
@АлексЛоп. приведенные вами примеры могут быть неактуальными, потому что char * в f5 не мутируется (мутируется только int * и не страдает от потенциального алиасинга), в то время как f6 имеет очевидную строгую проблему алиасинга. - person Myst; 26.10.2019