Why is gcc allowed to speculatively load from a struct?

前端 未结 6 746
爱一瞬间的悲伤
爱一瞬间的悲伤 2021-02-06 20:29

Example Showing the gcc Optimization and User Code that May Fault

The function \'foo\' in the snippet below will load only one of the struct members A or B; well at le

相关标签:
6条回答
  • 2021-02-06 21:09

    A -> operator on a Pair * implies that there's a whole Pair object fully allocated. (@Hurkyl quotes the standard.)

    x86 (like any normal architecture) doesn't have side-effects for accessing normal allocated memory, so x86 memory semantics are compatible with the C abstract machine's semantics for non-volatile memory. Compilers can speculatively load if/when they think that will be a performance win on target microarchitecture they're tuning for in any given situation.

    Note that on x86 memory protection operates with page granularity. The compiler could unroll a loop or vectorize with SIMD in a way that reads outside an object, as long as all pages touched contain some bytes of the object. Is it safe to read past the end of a buffer within the same page on x86 and x64?. libc strlen() implementations hand-written in assembly do this, but AFAIK gcc doesn't, instead using scalar loops for the leftover elements at the end of an auto-vectorized loop even where it already aligned the pointers with a (fully unrolled) startup loop. (Perhaps because it would make runtime bounds-checking with valgrind difficult.)


    To get the behaviour you were expecting, use a const int * arg.

    An array is a single object, but pointers are different from arrays. (Even with inlining into a context where both array elements are known to be accessible, I wasn't able to get gcc to emit code like it does for the struct, so if it's struct code is a win, it's a missed optimization not to do it on arrays when it's also safe.).

    In C, you're allowed to pass this function a pointer to a single int, as long as c is non-zero. When compiling for x86, gcc has to assume that it could be pointing to the last int in a page, with the following page unmapped.

    Source + gcc and clang output for this and other variations on the Godbolt compiler explorer

    // exactly equivalent to  const int p[2]
    int load_pointer(const int *p, int c) {
      int x;
      if (c)
        x = p[0];
      else
        x = p[1];  // gcc missed optimization: still does an add with c known to be zero
      return c + x;
    }
    
    load_pointer:    # gcc7.2 -O3
        test    esi, esi
        jne     .L9
        mov     eax, DWORD PTR [rdi+4]
        add     eax, esi         # missed optimization: esi=0 here so this is a no-op
        ret
    .L9:
        mov     eax, DWORD PTR [rdi]
        add     eax, esi
        ret
    

    In C, you can pass sort of pass an array object (by reference) to a function, guaranteeing to the function that it's allowed to touch all the memory even if the C abstract machine doesn't. The syntax is int p[static 2]

    int load_array(const int p[static 2], int c) {
      ... // same body
    }
    

    But gcc doesn't take advantage, and emits identical code to load_pointer.


    Off topic: clang compiles all versions (struct and array) the same way, using a cmov to branchlessly compute a load address.

        lea     rax, [rdi + 4]
        test    esi, esi
        cmovne  rax, rdi
        add     esi, dword ptr [rax]
        mov     eax, esi            # missed optimization: mov on the critical path
        ret
    

    This isn't necessarily good: it has higher latency than gcc's struct code, because the load address is dependent on a couple extra ALU uops. It is pretty good if both addresses aren't safe to read and a branch would predict poorly.

    We can get better code for the same strategy from gcc and clang, using setcc (1 uop with 1c latency on all CPUs except some really ancient ones), instead of cmovcc (2 uops on Intel before Skylake). xor-zeroing is always cheaper than an LEA, too.

    int load_pointer_v3(const int *p, int c) {
      int offset = (c==0);
      int x = p[offset];
      return c + x;
    }
    
        xor     eax, eax
        test    esi, esi
        sete    al
        add     esi, dword ptr [rdi + 4*rax]
        mov     eax, esi
        ret
    

    gcc and clang both put the final mov on the critical path. And on Intel Sandybridge-family, the indexed addressing mode doesn't stay micro-fused with the add. So this would be better, like what it does in the branching version:

        xor     eax, eax
        test    esi, esi
        sete    al
        mov     eax, dword ptr [rdi + 4*rax]
        add     eax, esi
        ret
    

    Simple addressing modes like [rdi] or [rdi+4] have 1c lower latency than others on Intel SnB-family CPUs, so this might actually be worse latency on Skylake (where cmov is cheap). The test and lea can run in parallel.

    After inlining, that final mov probably wouldn't exist, and it could just add into esi.

    0 讨论(0)
  • 2021-02-06 21:10

    Reading a variable (that was not declared as volatile) is not considered to be a "side effect" as specified by the C standard. So the program is free to read a location and then discard the result, as far as the C standard is concerned.

    This is very common. Suppose you request 1 byte of data from a 4 byte integer. The compiler may then read the whole 32 bits if that's faster (aligned read), and then discard everything but the requested byte. Your example is similar to this but the compiler decided to read the whole struct.

    Formally this is found in the behavior of "the abstract machine", C11 chapter 5.1.2.3. Given that the compiler follows the rules specified there, it is free to do as it pleases. And the only rules listed are regarding volatile objects and sequencing of instructions. Reading a different struct member in a volatile struct would not be ok.

    As for the case of allocating too little memory for the whole struct, that's undefined behavior. Because the memory layout of the struct is usually not for the programmer to decide - for example the compiler is allowed to add padding at the end. If there's not enough memory allocated, you might end up accessing forbidden memory even though your code only works with the first member of the struct.

    0 讨论(0)
  • 2021-02-06 21:21

    A postfix expression followed by the -> operator and an identifier designates a member of a structure or union object. The value is that of the named member of the object to which the first expression points

    If invoking the expression P->A is well-defined, then P must actually point to an object of type struct Pair, and consequently P->B is well-defined as well.

    0 讨论(0)
  • 2021-02-06 21:26

    This is always allowed under the "as-if" rule if no conforming program can tell the difference. For example, an implementation could guarantee that after each block allocated with malloc, there are at least eight bytes that can be accessed without side effects. In that situation, the compiler can generate code that would be undefined behaviour if you wrote it in your code. So it would be legal for the compiler to read P[1] whenever P[0] is correctly allocated, even if that would be undefined behaviour in your own code.

    But in your case, if you don't allocate enough memory for a struct, then reading any member is undefined behaviour. So here the compiler is allowed to do this, even if reading P->B crashes.

    0 讨论(0)
  • 2021-02-06 21:29

    No, if *P is allocated correctly P->B will never be in unallocated memory. It might not be initialized, that is all.

    The compiler has every right to do what they do. The only thing that is not allowed is to oops about the access of P->B with the excuse that it is not initialized. But what and how they do all of this is under the discretion of the implementation and not your concern.

    If you cast a pointer to a block returned by malloc to Pair* that is not guaranteed to be wide enough to hold a Pair the behavior of your program is undefined.

    0 讨论(0)
  • 2021-02-06 21:29

    This is perfectly legal because reading some memory location isn't considered an observable behavior in the general case (volatile would change this).

    Your example code is indeed undefined behavior, but I can't find any passage in the standard docs that explicitly states this. But I think it's enough to have a look at the rules for effective types ... from N1570, §6.5 p6:

    If a value is stored into an object having no declared type through an lvalue having a type that is not a character type, then the type of the lvalue becomes the effective type of the object for that access and for subsequent accesses that do not modify the stored value.

    So, your write access to *P actually gives that object the type Pair -- therefore it just extends into memory you didn't allocate, the result is an out of bounds access.

    0 讨论(0)
提交回复
热议问题