Newshot
Account

Zero-initialisation shenanigans

Original text(via Lobsters)

And how hard could it be?

Suppose we have:


        struct S {
    char buf[9u * 1024];
};

struct S
a(void)
{
    return (struct S){0};
}
      

a() compiles down to:


        a:
    sub  rsp, 8
    mov  edx, 9216
    xor  esi, esi
    call memset
    add  rsp, 8
    ret
      

A memset() call is generated by the compiler for zero-initialising. 9K seems to be the threshold at which GCC emits a memset(), instead of rep and friends. Not sure why, given that the latter is (I think) microcoded and vector ops are likely better at any N.

In any case, the above assembly is still interesting. Under the SysV ABI, a() is transformed into:


        void
b(struct S *p)
{
    memset(p, 0, sizeof(*p));
}
      

And indeed, if you compile that, you'll get identical* assembly. The caller allocates memory for the return value and passes a pointer to the callee.

In case you're wondering, this is entirely unrelated to RVO from C++.

Now, if you're good at assembly (I'm not) you'll have noticed that what the compiler generated isn't optimal. Compile the manually "transformed" b() and you'd get:


        b:
    mov edx, 9216
    xor esi, esi
    jmp memset
      

which is probably optimal. Ha, the compiler did TCO on the memset() call. How come it couldn't do that with a()? If you check LLVM's IR (at -O3):


        define dso_local void @a(ptr dead_on_unwind noalias nofree writable writeonly sret(%struct.S) align 1 captures(none) initializes((0, 9216)) %agg.result) local_unnamed_addr {
entry:
  tail call void @llvm.memset.p0.i64(ptr noundef nonnull align 1 dereferenceable(9216) %agg.result, i8 0, i64 9216, i1 false)
  ret void
}

define dso_local void @b(ptr nofree noundef writeonly captures(none) initializes((0, 9216)) %s) local_unnamed_addr {
entry:
  tail call void @llvm.memset.p0.i64(ptr noundef nonnull align 1 dereferenceable(9216) %s, i8 0, i64 9216, i1 false)
  ret void
}
      

The middle-end did manage to tail call memset() in a()! Aside from some attribute differences, both functions lower down to something very similar. So it's really the backend that for some reason fumbles and doesn't generate a tail call.

GCC fails too, I'm not sure why (ABI braindamage?). Though, it doesn't really matter, since no one would write code like that, and the call would be inlined anyway.

Oh, my sweet GCC

While playing around with this, I found a cool GCC bug. I'm a dinosaur and use Debian 13, which ships with GCC 14. If you compile a c() variant with that, you get:


        struct S
c(void)
{
    struct S r;
    memset(&r, 0, sizeof(r));
    return r;
}
      

        c:
    push rbx
    mov  edx, 9216
    mov  rbx, rdi
    xor  esi, esi
    sub  rsp, 9216
    mov  rdi, rsp
    call memset
    mov  rdi, rbx
    mov  edx, 9216
    xor  esi, esi
    call memset
    add  rsp, 9216
    pop  rbx
    ret
      

Isn't that nice of GCC? It decided to be extra cautious and called memset() twice, just to be sure. And it allocated a struct S from the callee's frame. GCC 15 and onwards are reckless and generate only a single memset() call, and so spit identical assembly to a()'s.

But even with GCC 15, it seems like some edge-cases were missed and you can still get crazy output. If we had this variant:


        struct S
d(void)
{
    struct S r = {0};
    return r;
}
      

        .LC0:
.zero 2
.zero 9214

d:
    push rbx
    mov  edx, 9216
    mov  rbx, rdi
    mov  esi, OFFSET FLAT:.LC0
    sub  rsp, 9216
    mov  rdi, rsp
    call memcpy
    mov  rsi, rsp
    mov  rdi, rbx
    mov  edx, 9216
    call memcpy
    add  rsp, 9216
    pop  rbx
    ret
      

Instead of calling memset(), it creates a 9K object filled with 0s and does memcpy(). Twice. It allocates a struct S r from the callee, copies the 9K object into it, then copies r into the struct S allocated by the caller. Wtf?

The double memcpy() was fixed in GCC 16, but still, a 9K object is copied instead of calling memset():


        .LC0:
.zero 2
.zero 9214

d:
    sub  rsp, 8
    mov  edx, 9216
    mov  esi, OFFSET FLAT:.LC0
    call memcpy
    add  rsp, 8
    ret
      

I think this has been fixed in trunk? It's certainly an optimisation bug, not a correctness one. For one more:


        struct S
e(void)
{
    struct S r = (struct S){0};
    return r;
}
      

The above is like d(), but uses a compound literal to initialise r (like a()). GCC 16 generates a memset() for this, identical to a()'s assembly. And so do earlier versions. Wtf?

If you use something other than 0, it can still trip:


        struct S
f(void)
{
    return (struct S){{-1}};
}
      

        f:
    sub  rsp, 8
    mov  edx, 9216
    xor  esi, esi
    call memset
    mov  BYTE PTR [rax], -1
    add  rsp, 8
    ret
      

        struct S
g(void)
{
    struct S r = {{-1}};
    return r;
}
      

        .LC0:
.string "\377"
.zero   9214

g:
    sub  rsp, 8
    mov  edx, 9216
    mov  esi, OFFSET FLAT:.LC0
    call memcpy
    add  rsp, 8
    ret
      

        struct S
h(void)
{
    struct S r = (struct S){{-1}};
    return r;
}
      

        h:
    push rbx
    mov  edx, 9216
    mov  rbx, rdi
    xor  esi, esi
    sub  rsp, 9216
    mov  rdi, rsp
    call memset
    mov  rsi, rsp
    mov  rdi, rbx
    mov  edx, 9216
    mov  BYTE PTR [rsp], -1
    call memcpy
    add  rsp, 9216
    pop  rbx
    ret
      

The text above was extracted automatically from the source page and may miss images, tables or code blocks. The original is authoritative.

Published by Lobsters
This site only summarises and aggregates; copyright stays with the original author. Headlines and summaries follow the source — where they differ, the original wins.
Read the original ↗