llvm / llvm/llvm-project

[DebugInfo][MemCpyOpt] memcpyopt leaves the surviving memcpy with the wrong DIAssignID

Open
#213,642 2 comments 0 reactions 0 assignees View on GitHub
confirmed debuginfo llvm:optimizations
Dominant language
LLVM
Stars
40.5k
Forks
18.7k
PR merge metrics
PR metrics pending

Description

## Description

After running `memcpyopt` on assignment-tracking debug info, the optimized IR contains
one remaining `memcpy`.

That `memcpy` writes to the storage for local variable `c`:

```llvm
call void @llvm.memcpy.p0.p0.i64(ptr align 1 %3, ptr align 1 @__const.func.a, i64 4, i1 false), !dbg !25, !DIAssignID !26
```

However, the only `#dbg_assign` that uses `!DIAssignID !26` is for local variable
`a`, and it describes the address of `%1`:

```llvm
#dbg_assign(i1 poison, !16, !DIExpression(), !26, ptr %1, !DIExpression(), !20)
```

Here `%3` is the alloca for `c`, while `!16` is the `DILocalVariable` for `a`.
So the surviving memory write is a write to `c`, but its assignment ID is still tied
to `a`.

## Reproducer

`case.c`:

```c
#include

char func() {
char a[4] = "abc", b[4], c[4];
memcpy(b, a, 4);
memcpy(c, b, 4);
return c[0];
}

int main() {
func();
return 0;
}
```

Build pipeline:

```sh
clang -g -O0 -Xclang -disable-O0-optnone -emit-llvm -S case.c -o case.ll
opt -passes="declare-to-assign,instsimplify" -S case.ll -o src.ll
opt -passes="memcpyopt" -S src.ll -o tgt.ll

clang src.ll -o src.out
clang tgt.ll -o tgt.out
```

## Observed Directly in `.ll`

Before `memcpyopt`, each memory copy has a matching assignment ID for the destination
variable.

```llvm
define dso_local signext i8 @func() #0 !dbg !10 {
%1 = alloca [4 x i8], align 1, !DIAssignID !15
#dbg_assign(i1 poison, !16, !DIExpression(), !15, ptr %1, !DIExpression(), !20)
%2 = alloca [4 x i8], align 1, !DIAssignID !21
#dbg_assign(i1 poison, !22, !DIExpression(), !21, ptr %2, !DIExpression(), !20)
%3 = alloca [4 x i8], align 1, !DIAssignID !23
#dbg_assign(i1 poison, !24, !DIExpression(), !23, ptr %3, !DIExpression(), !20)
call void @llvm.memcpy.p0.p0.i64(ptr align 1 %1, ptr align 1 @__const.func.a, i64 4, i1 false), !dbg !25, !DIAssignID !26
#dbg_assign(i1 poison, !16, !DIExpression(), !26, ptr %1, !DIExpression(), !20)
call void @llvm.memcpy.p0.p0.i64(ptr align 1 %2, ptr align 1 %1, i64 4, i1 false), !dbg !27, !DIAssignID !28
#dbg_assign(i1 poison, !22, !DIExpression(), !28, ptr %2, !DIExpression(), !20)
call void @llvm.memcpy.p0.p0.i64(ptr align 1 %3, ptr align 1 %2, i64 4, i1 false), !dbg !29, !DIAssignID !30
#dbg_assign(i1 poison, !24, !DIExpression(), !30, ptr %3, !DIExpression(), !20)
%4 = load i8, ptr %3, align 1, !dbg !31
ret i8 %4, !dbg !32
}
```

The relevant local variables are:

```llvm
!16 = !DILocalVariable(name: "a", scope: !10, file: !1, line: 4, type: !17)
!22 = !DILocalVariable(name: "b", scope: !10, file: !1, line: 4, type: !17)
!24 = !DILocalVariable(name: "c", scope: !10, file: !1, line: 4, type: !17)
```

After `memcpyopt`, the full optimized `func` body is:

```llvm
define dso_local signext i8 @func() #0 !dbg !10 {
%1 = alloca [4 x i8], align 1, !DIAssignID !15
#dbg_assign(i1 poison, !16, !DIExpression(), !15, ptr %1, !DIExpression(), !20)
%2 = alloca [4 x i8], align 1, !DIAssignID !21
#dbg_assign(i1 poison, !22, !DIExpression(), !21, ptr %2, !DIExpression(), !20)
%3 = alloca [4 x i8], align 1, !DIAssignID !23
#dbg_assign(i1 poison, !24, !DIExpression(), !23, ptr %3, !DIExpression(), !20)
call void @llvm.memcpy.p0.p0.i64(ptr align 1 %3, ptr align 1 @__const.func.a, i64 4, i1 false), !dbg !25, !DIAssignID !26
#dbg_assign(i1 poison, !16, !DIExpression(), !26, ptr %1, !DIExpression(), !20)
#dbg_assign(i1 poison, !22, !DIExpression(), !27, ptr %2, !DIExpression(), !20)
#dbg_assign(i1 poison, !24, !DIExpression(), !28, ptr %3, !DIExpression(), !20)
%4 = load i8, ptr %3, align 1, !dbg !29
ret i8 %4, !dbg !30
}
```

The remaining `memcpy` writes to `%3`, the alloca for `c`:

```llvm
%3 = alloca [4 x i8], align 1, !DIAssignID !23
...
!24 = !DILocalVariable(name: "c", scope: !10, file: !1, line: 4, type: !17)
```

But the remaining `memcpy` keeps `!DIAssignID !26`, which is paired with the
`#dbg_assign` for `a`:

```llvm
call void @llvm.memcpy.p0.p0.i64(ptr align 1 %3, ptr align 1 @__const.func.a, i64 4, i1 false), !dbg !25, !DIAssignID !26
#dbg_assign(i1 poison, !16, !DIExpression(), !26, ptr %1, !DIExpression(), !20)

!16 = !DILocalVariable(name: "a", scope: !10, file: !1, line: 4, type: !17)
```

So the optimized IR has a mismatch between the destination of the surviving memory
write and the variable associated with that write's `DIAssignID`.

## User-Visible Effect

At `case.c:7`, before `memcpyopt`, LLDB reports the expected values for both `a`
and `c`:

```text
(lldb) frame variable a
(char[4]) a = "abc"
(lldb) frame variable c
(char[4]) c = "abc"
```

After `memcpyopt`, LLDB reports a different concrete value for `a`, while `c`
becomes unavailable:

```text
(lldb) frame variable a
(char[4]) a = ""
(lldb) frame variable c
error: use of undeclared identifier 'c'
```

This is consistent with the IR-level mismatch above: the optimized program writes the
known bytes into `c`, but the assignment-tracking metadata on that write still points
at `a`. As a result, the debugger can still materialize `a`, but with a value that
differs from the value before `memcpyopt`, while `c` is no longer described.

## Expected Behavior

The `DIAssignID` on the surviving `memcpy` should describe the variable whose storage
is written by that instruction.

In this case, after forwarding the copies into a single write to `%3`, the surviving
`memcpy` should either carry an assignment ID associated with `c`, or the debug
assignment for `c` should otherwise be updated so that the write to `%3` can be
recognized as the assignment to `c`.

The assignment ID for `a` should not remain on an instruction whose destination is
`c`'s storage.

## Environment

```text
clang version 24.0.0git
llvm-project revision: b443896c13aded8b40d7bae4a5a9adbc96fd0d31

LLVM version 24.0.0git
LLDB version 24.0.0git
```

## Notes

My understanding is that `memcpyopt` forwards the memory copies correctly for the
optimized program value, but the assignment-tracking metadata is not updated to follow
the destination of the surviving memory write. Since the remaining instruction writes
`c`, keeping only the assignment ID associated with `a` looks inconsistent.

Contributor guide

Open the contributing guide

Research direction

Reproduce the issue with case.c using the clang and opt commands in the report, then compare src.ll and tgt.ll around the memcpyopt pass and assignment-tracking metadata. Trace how the surviving memcpy and its DIAssignID are formed; done means the write to %3 is associated with c rather than a, with the expected debugger values preserved.

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
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.