[X86] Computed goto with a single possible target lowered to an indirect branch (`jmp *%reg`) at -O2 instead of a direct jump
- Dominant language
- LLVM
- Stars
- 40.5k
- Forks
- 18.7k
- PR merge metrics
- PR metrics pending
Description
## Summary
For a computed goto (`goto *p`, GNU labels-as-values) whose pointer `p` can only ever hold the address of one label, clang at `-O2` keeps the label address in a register and emits an indirect branch (`jmp *%reg`) instead of folding it into a direct branch to that label. GCC folds the identical source to direct jumps and emits no indirect branch at all.
No LTO and no sanitizer are required to reproduce; plain `-O2` is enough.
This is the codegen behind a Linux kernel objtool failure (objtool classifies the unresolved indirect jump as an indirect sibling call). Beyond the objtool issue, an indirect branch here is undesirable in the kernel for retpoline/CFI reasons, and it interacts badly with scope-based cleanup. Discussion thread below.
## Reproducer
The pattern is distilled from the kernel's `drm_exec` control-flow macros (`include/drm/drm_exec.h`): a label is placed before a `for` loop, its address is stashed into a local `void *` via `&&label`, and `goto *ptr` restarts the loop. The pointer is only ever assigned that single label. Two such constructs live in one function (this is what the macros are designed to allow, and what trips the codegen).
`repro.c`:
```c
#define __PASTE1(a, b) a##b
#define __PASTE(a, b) __PASTE1(a, b)
#define unlikely(x) __builtin_expect(!!(x), 1)
struct drm_exec { int contended; };
extern int drm_exec_cleanup(struct drm_exec *exec);
extern int drm_exec_is_contended(struct drm_exec *exec);
extern void drm_exec_init(struct drm_exec *exec, unsigned int flags, unsigned int nr);
extern void drm_exec_fini(struct drm_exec *exec);
extern void *lock_by_pasid(void *adev, unsigned int pasid, struct drm_exec *exec);
extern int do_work(void *adev, void *vm);
#define drm_exec_until_all_locked(exec) \
__PASTE(__drm_exec_, __LINE__): \
for (void *__drm_exec_retry_ptr; ({ \
__drm_exec_retry_ptr = &&__PASTE(__drm_exec_, __LINE__);\
(void)__drm_exec_retry_ptr; \
drm_exec_cleanup(exec); \
});)
#define drm_exec_retry_on_contention(exec) \
do { \
if (unlikely(drm_exec_is_contended(exec))) \
goto *__drm_exec_retry_ptr; \
} while (0)
int repro(void *adev, unsigned int pasid)
{
struct drm_exec exec;
void *vm = 0;
drm_exec_init(&exec, 0, 1);
drm_exec_until_all_locked(&exec) {
vm = lock_by_pasid(adev, pasid, &exec);
drm_exec_retry_on_contention(&exec);
if (!vm)
break;
}
if (!vm) {
drm_exec_fini(&exec);
return -1;
}
drm_exec_fini(&exec);
drm_exec_init(&exec, 0, 1);
drm_exec_until_all_locked(&exec) {
vm = lock_by_pasid(adev, pasid, &exec);
drm_exec_retry_on_contention(&exec);
if (!vm)
break;
}
if (!vm) {
drm_exec_fini(&exec);
return -1;
}
int r = do_work(adev, vm);
drm_exec_fini(&exec);
return r;
}
```
Build:
```
clang -O2 -fno-omit-frame-pointer -S repro.c -o repro.s
```
## Observed (clang -O2)
clang materializes the block address into `%rcx` and branches indirectly:
```asm
.LBB0_13: # in Loop: Header=BB0_2 Depth=1
jmpq *%rcx
```
`%rcx` holds the address of the loop-header label (the `&&label` value). The indirect jump's only possible target is that in-function label. There is a second identical indirect jump for the other loop. `grep -nE 'jmp[q]?[[:space:]]+\*'` on the `-S` output shows the `jmpq *%reg`.
## Expected (GCC -O2, same source)
GCC folds the single-target computed goto into direct branches and emits **no** indirect jump (`grep` for `jmp *` matches 0 lines). The retry becomes a plain `jmp .Lxxx` back to the loop header.
## At the IR level
The function ends up with a `blockaddress` + `indirectbr` whose successor list has a single, statically known destination. That `indirectbr` is not simplified to an unconditional `br` to that destination during `-O2`, so the backend emits an indirect branch.
## Why this matters (kernel context)
objtool cannot resolve the indirect jump (the target is a relocation to a `.text` label in the same function, not an indexed jump table), so it classifies it as an indirect sibling call and, with the frame set up, reports "sibling call from callable instruction with modified stack frame". Under `CONFIG_OBJTOOL_WERROR=y` this breaks the build. The `drm_exec_until_all_locked()` / `drm_exec_retry_on_contention()` macros are used widely across DRM, so any such loop is exposed.
An indirect branch here is also undesirable independent of objtool: it defeats retpoline/CFI expectations, and (per the kernel maintainers) the moment the compiler emits an indirect branch for this construct it has lost track of control flow, which is a problem for scope-based cleanup (`__cleanup`/`guard()`).
In the original kernel report the function additionally went through ThinLTO and KASAN, which inflate it and make the missed fold more likely, but neither is required: plain `-O2` on the reproducer above is sufficient.
## Version
- clang version 22.1.8 (Fedora 22.1.8-4.fc45)
- also reported by the kernel CI with clang 22.1.3 (llvm-project commit e9846648fd6183ee6d8cbdb4502213fcf902a211)
- target: x86_64-unknown-linux-gnu
## Note
This is being addressed on the kernel side independently (the drm_exec macros are being reworked to avoid the computed goto), so this report is not a request to unblock a build. It is filed as a standalone missed optimization: clang should fold a single-target computed goto into a direct branch, as GCC does.
## Discussion
Linux kernel thread (objtool report, analysis, and kernel-side discussion):
https://lore.kernel.org/oe-kbuild-all/20260624092806.GX48970@noisy.programming.kicks-ass.net/
Contributor guide
Research direction
Start with repro.c and reproduce the issue using clang -O2 -fno-omit-frame-pointer -S, then inspect the generated assembly and the IR containing blockaddress and indirectbr. Trace the optimization handling the single-successor indirectbr and add a regression test based on this reproducer. Done means the single-target computed gotos become direct branches and the assembly contains no indirect jump.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- c
- Domain
- compilers
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 55/100