Issue 202393
Summary [libc][clang] escape analysis does not capture stack variable passed via ABI layout in thread launcher
Labels
Assignees
Reporter SchrodingerZhu
    The bug is detected during a continuous run of libc cnd_test. We introduced a loop to ensure conditional variable waiter is signaled, however, that loop's condition check is optimized out. The problem is that SROA thinks the local atomic variable does not escape. I am uncertain if this is a libc bug or a clang bug due the "hacky" nature of the code, but gcc does work in this case.

**Reproducer minimized by AI, verified by me**.

## The Reproducer (`repro.cpp`)

```cpp
#include <stdio.h>

struct StartArgs {
  void (*func)(void*);
  void *arg;
};

// Mock start_thread that reads arguments from the stack frame.
//
// Why this is needed on Linux:
// In a raw SYS_clone syscall, the child thread starts executing on a new stack
// immediately following the syscall instruction. Since RSP has switched to the 
// new stack, local variables on the parent thread's stack are inaccessible.
// To pass arguments, the parent packs them into the top of the new stack.
// The child thread must then "sniff out" these arguments using frame-address 
// offsets (__builtin_frame_address) since it cannot receive them as standard 
// function parameters.
__attribute__((noinline)) void start_thread() {
  void *caller_frame = __builtin_frame_address(1);
 // We assume StartArgs is at caller_frame - 32
  StartArgs *args = (StartArgs*)((char*)caller_frame - 32);
  args->func(args->arg);
}

// Mock thread creator that packs arguments into the stack frame
__attribute__((noinline)) void run_thread(void (*func)(void*), void *arg) {
  StartArgs args;
  args.func = func;
  args.arg = arg;
 start_thread();
}

// Mock atomic structure using compiler builtins (as in LLVM libc)
struct MockAtomic {
  bool val;
  bool load() {
    bool res;
    __atomic_load(&val, &res, __ATOMIC_SEQ_CST);
    return res;
 }
  void store(bool v) {
    __atomic_store(&val, &v, __ATOMIC_SEQ_CST);
 }
};

void waiter_func(void *arg) {
  MockAtomic *f = (MockAtomic*)arg;
 f->store(true);
}

int main() {
  MockAtomic flag;
 flag.store(false);
  
  run_thread(waiter_func, &flag);
  
  // Under optimization (-O3), the compiler optimizes away this check loop because it
 // thinks 'flag' never escapes (the write in run_thread was eliminated
  // as dead code).
  while (!flag.load()) {
  }
  
 printf("Success!\n");
  return 0;
}
```

## Compilation Command

To reproduce the bug (the loop optimization) on this single-file TU, compile the code using standard `clang++` with `-O3`:

```bash
clang++ -O3 -fno-omit-frame-pointer repro.cpp -o repro
```

## LLVM IR Analysis

The optimizer bug can be traced to the **SROA (Scalar Replacement of Aggregates)** pass. SROA eliminates the stack-allocated structures because it fails to detect accesses through `__builtin_frame_address`.

### 1. `run_thread` Optimization (SROA)

SROA on `run_thread` determines that the `StartArgs args` struct is write-only in the function IR because the read in `start_thread` is opaque (via `__builtin_frame_address(1)`). Therefore, SROA optimizes away the allocation and initialization of `args` entirely:

**Before SROA:**
```llvm
define dso_local void @_Z10run_threadPFvPvES_(ptr noundef %0, ptr noundef %1) #0 {
  %3 = alloca ptr, align 8
  %4 = alloca ptr, align 8
  %5 = alloca %struct.StartArgs, align 8
  store ptr %0, ptr %3, align 8
  store ptr %1, ptr %4, align 8
 call void @llvm.lifetime.start.p0(ptr %5)
  %6 = load ptr, ptr %3, align 8
 %7 = getelementptr inbounds nuw %struct.StartArgs, ptr %5, i32 0, i32 0
 store ptr %6, ptr %7, align 8
  %8 = load ptr, ptr %4, align 8
  %9 = getelementptr inbounds nuw %struct.StartArgs, ptr %5, i32 0, i32 1
  store ptr %8, ptr %9, align 8
  call void @_Z12start_threadv()
  call void @llvm.lifetime.end.p0(ptr %5)
  ret void
}
```

**After SROA:**
```llvm
define dso_local void @_Z10run_threadPFvPvES_(ptr noundef %0, ptr noundef %1) #0 {
  call void @_Z12start_threadv()
  ret void
}
```
*(Notice that `%0` and `%1` are no longer saved to the stack, meaning `flag`'s address never escapes.)*

---

### 2. `main` Optimization (SROA)

Later, SROA is run on `main`. Because `flag`'s address never escaped, the compiler treats `flag` (type `MockAtomic`) as a thread-local, unmodified variable. SROA replaces the atomic load inside the loop with the constant `false` (`0`):

**Before SROA (Inlined `MockAtomic::load`):**
```llvm
define dso_local noundef i32 @main() local_unnamed_addr #5 {
  %1 = alloca %struct.MockAtomic, align 1
  call void @llvm.lifetime.start.p0(ptr nonnull %1)
  store atomic i8 0, ptr %1 seq_cst, align 1
  call void @_Z10run_threadPFvPvES_(ptr noundef nonnull @_Z11waiter_funcPv, ptr noundef nonnull %1)
  br label %2

2: ; preds = %2, %0
  %3 = load atomic i8, ptr %1 seq_cst, align 1
  %4 = trunc nuw i8 %3 to i1
  br i1 %4, label %5, label %2
  
5:
  ...
}
```

**After SROA:**
```llvm
define dso_local noundef i32 @main() local_unnamed_addr #4 {
  call void @_Z10run_threadPFvPvES_(ptr nonnull poison, ptr nonnull poison)
  br label %1

1:                                                ; preds = %1, %0
 %2 = trunc nuw i8 0 to i1
  br i1 %2, label %3, label %1

3:
 ...
}
```
*(The `load atomic` instruction has been replaced with `%2 = trunc nuw i8 0 to i1` (constant false). A subsequent `InstCombinePass` further simplifies this to `br i1 false` which becomes an unconditional infinite jump loop.)*

---

## Explanation

1. In `run_thread`, the `StartArgs` structure (which holds the pointer to `flag`) is created on the local stack.
2. In `start_thread`, `__builtin_frame_address(1)` is used to retrieve the caller's frame pointer, casting it to read the `StartArgs` structure.
3. Because the compiler's alias analysis cannot trace stack layouts across `__builtin_frame_address` calls, it does not connect the write of `args` in `run_thread` to the read in `start_thread`.
4. As a result, the **SROA** pass on `run_thread` decides that `args` is never read in `run_thread`, marking it as dead code and eliminating the initialization of `args` entirely.
5. Since the initialization is eliminated, the address of the local variable `flag` never escapes.
6. Consequently, the compiler concludes that `flag` is never modified by any other code, and **SROA on `main`** replaces the `load atomic` inside the loop with constant `0` (`false`), which `InstCombine` later transforms into an infinite `jmp` loop.

_______________________________________________
llvm-bugs mailing list
[email protected]
https://lists.llvm.org/cgi-bin/mailman/listinfo/llvm-bugs

Reply via email to