【问题标题】:Does Clang misunderstand the 'const' pointer specifier?Clang 是否误解了“const”指针说明符?
【发布时间】:2019-10-16 07:20:49
【问题描述】:

在下面的代码中,我看到在没有隐式restrict 指针说明符的情况下,clang 无法执行更好的优化:

#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

加法:

在与 Lundin 在他的 answer 的 cmets 中进行了一些讨论后,我得到的印象是可能会导致重新读取,因为编译器会假设 queue_ptr-&gt;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 指针以防止我不理解的重新读取(除非它是编译器优化“错误”)。

附言

我也找不到任何指向文件/报告问题的链接clang 不会导致编译器崩溃...

【问题讨论】:

  • const 不代表值不能改变;这意味着无法使用const 标识符更改该值。 int foo; int *a = &amp;foo; int const *b = &amp;foo; *a = 42 /*ok*/; *b = -1 /*nope*/;
  • @pmg 我的意思是它不能被const event_t * 指针改变。我将添加此说明
  • @StaceyGirl 你为什么删除你的答案?
  • @AlexLop。这不是注册压力。这在其他架构的汇编中是可见的。我认为您应该检查 IR 以了解发生了什么,因为它具有明确的别名注释 (!tbaa X)。我检查了一下,发现 Clang 在没有 TBAA 注释的情况下进行事件复制,这可以解释值刷新,但同时我看到不同的编译器配置生成可能 llvm.memcpy 调用 WITH !tbaa 元数据。稍后我可能会尝试检查此问题,但无法从中写出答案。
  • @StaceyGirl 谢谢!我很感激。将等待您的输入。另请注意,可以在 x86 架构上重现类似的行为,而且看起来很奇怪……godbolt.org/z/5OVBEy

标签: c clang compiler-optimization strict-aliasing restrict-qualifier


【解决方案1】:

[谈原作]

这是由 Clang 生成的 TBAA 元数据中的缺陷引起的。

如果您使用 -S -emit-llvm 发出 LLVM IR,您将看到(为简洁起见,已截断):

...

  %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、强类型(不是无缘无故的强制转换、类型双关语等)、相关的函数和变量属性……但在这种情况下不是这样,原始程序已经包含所有必要的信息。这只是编译器的缺陷,解决这些问题的最佳方法是提高认识:在邮件列表中询问和/或提交错误报告。

【讨论】:

  • 谢谢!这看起来比以前在这里发布的所有内容更令人满意。那么为什么不总是使用-Xclang -new-struct-path-tbaa 选项呢?它的优点/缺点是什么(或者这里应该是一个单独的问题......)?
  • @AlexLop。如果它回答了问题,请不要忘记接受答案(我认为可以)。否则,如果对答案的赞成票数量减少,赏金将过期并且不会授予任何人。
  • @StaceyGirl 谢谢斯泰西,我同意这回答了我的问题......没有完全考虑。我仍然看不到我可以从我的编程/优化技能中学到什么,以便我可以为基础设施层实现最优化的代码。除非它确实可以归类为编译器“问题”,它将在未来的版本中修复,并且与我的编码习惯无关。
  • @chill 您能否在您的回答中提及(或仅提及)以下主题,以便我可以将您的回答标记为“已接受”:1. 如何防止将来出现此类重读 2 . 你用来消除这些重读的标志的缺点是什么。
  • @AlexLop。在这种情况下,恐怕没有一般的教训可以吸取。原则上,能够为编译器提供的信息越多越好——比如restrict、强类型(不是无缘无故的强制转换、类型双关语等)、相关的函数和变量属性……但在这种情况下不是这样,原始程序已经包含所有必要的信息。这只是编译器的缺陷,解决这些问题的最佳方法是提高认识:在邮件列表中询问和/或提交错误报告。
【解决方案2】:

queue_ptr 的成员event_t 可以指向与event_ptr 相同的内存。当编译器不能排除两个指针指向同一内存时,它们往往会生成效率较低的代码。因此,restrict 带来更好的代码并没有什么奇怪的。

常量限定符并不重要,因为它们是由函数添加的,并且原始类型可以在其他地方修改。特别是,* const 没有添加任何内容,因为指针已经是原始指针的本地副本,因此包括调用者在内的任何人都不会关心函数是否修改了该本地副本。

“严格别名”而是指编译器可以偷工减料的情况,例如假设 uint16_t* 不能指向 uint8_t* 等。但在您的情况下,您有两种完全兼容的类型,一种它们只是被包裹在一个外部结构中。

【讨论】:

  • 但是即使它们指向同一个位置,这里唯一的动作就是一个一个一个的复制一个dword,那么它是如何“混淆”编译器的呢?
  • @AlexLop。因为通常不能将对象复制到已经存储的地址,或者复制重叠的对象。
  • 好的,我可以理解,但我仍然不明白它如何影响像queue_t 这样完全不同的结构的字段?为什么要重新阅读这些字段?
  • “常量限定符并不重要,因为它们是由函数添加的,原始类型可以在其他地方修改。” - 即使函数获得单个指针,许多其他情况也是如此。但是生成的程序集并不总是读取、修改并立即将值写回内存(除非它是volatile
  • @AlexLop。 *const 限定符是程序员的编码风格,而不是编译器。编译器应该能够推断出指针变量是否在函数内部被修改,无论限定符如何。
【解决方案3】:

据我所知,是的,在您的代码中queue_ptr 指针的内容不能被修改。它是一个优化错误吗?这是一个错过的优化机会,但我不会称之为错误。它不会“误解” const,只是没有/没有进行必要的分析来确定它不能针对这种特定情况进行修改。

附带说明:queue_is_full(queue_ptr) 可以修改 *queue_ptr 的内容,即使它有 const queue_t *const 参数,因为它可以合法地抛弃 const 性,因为原始对象不是 const。话虽如此,quueue_is_full 的定义对编译器来说是可见且可用的,因此它可以确定它确实不存在。

【讨论】:

  • “话虽如此,queue_is_full 的定义对编译器来说是可见的和可用的,因此它可以确定它确实不存在。” - 不仅只是可见,clang 还内联了它的内容。
  • 25 条汇编指令中的 4 条是不需要的,与常规 mov/add/ 相比,ldr/str 指令在 arm cortex-m3 上可能需要约 3 个周期,后者需要 1 个周期。考虑到提供了-O2,我不会称之为“错失良机”
【解决方案4】:

如您所知,您的代码似乎修改了数据,使const 状态无效:

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

如果没有restrict 关键字,编译器必须假定这两种类型可能共享相同的内存空间。

在更新的示例中这是必需的,因为 event_tqueue_t 的成员,并且严格的别名适用于:

...在其成员中包含上述类型之一的聚合或联合类型(递归地包括子聚合或包含联合的成员),或...

在原始示例中,类型可能被视为别名的原因有很多,从而导致相同的结果(即,使用 char 指针以及类型可能被认为“足够”兼容的事实一些架构,如果不是全部)。

因此,编译器需要在内存发生变异后重新加载内存以避免可能的冲突。

const 关键字并没有真正参与其中,因为突变是通过可能指向相同内存地址的指针发生的。

(EDIT) 为方便起见,以下是有关访问变量的完整规则:

一个对象的存储值只能由具有以下类型之一的左值表达式访问 (88):

——与对象的有效类型兼容的类型,

——与对象的有效类型兼容的类型的限定版本,

——对象的有效类型对应的有符号或无符号类型,

— 有符号或无符号类型,对应于对象有效类型的限定版本,

——在其成员中包含上述类型之一的聚合或联合类型(递归地,包括子聚合或包含联合的成员),或

——一种字符类型。

(88) 此列表的目的是指定对象可能有别名或可能没有别名的情况。

附言

_t 后缀由 POSIX 保留。您可以考虑使用不同的后缀。

通常的做法是将_s 用于结构,将_u 用于联合。

【讨论】:

  • 但是event_t 不是queue_t 的成员(在原始示例中)。
  • @StaceyGirl - 是的,但还有其他原因导致这种情况。我不想“律师”这个,所以我将列表附加到答案......有人可能会争辩说 event_t 类型足够兼容,可以考虑用于类型双关语(uint32_t 被填充,留下前两个成员对齐)...其他人可能会注意到任何char指针(或uint8_t *)总是被认为是可能的内存别名(在我们的例子中,queue_ptr-&gt;wr_idx指向unsigned char,引发“别名”标志)...谁知道呢。我并没有真正深入了解“为什么他们可能会起别名”,而是深入了解效果。
  • 我不认为这里是这种情况......例如,看看这个问题在各种情况下的再现:godbolt.org/z/5OVBEy - f5 has no re-read while @ 987654341@确实
  • @AlexLop。您提供的示例可能不相关,因为f5 中的char * 没有发生变异(只有int * 发生了变异并且它不会受到潜在的混叠影响),而f6 有一个明显的严格混叠问题.
猜你喜欢
  • 2012-07-26
  • 1970-01-01
  • 2014-03-03
  • 1970-01-01
  • 2021-09-21
  • 1970-01-01
  • 2016-06-09
  • 1970-01-01
相关资源
最近更新 更多