Introduction

ClangIR (CIR) has been upstreamed incrementally since 2024. In February 2026, the original incubator repository was archived, and development moved to llvm-project. The incubator’s experimental lifetime checker, cir-lifetime-check, has not been upstreamed yet. The prototype implementation discussed here is available on the cir-lifetime-check branch.

The lifetime checker combines CIR’s explicit operations and control flow with C++ semantic information preserved from Clang’s AST. This gives the analysis an IR for tracking program state together with the source-level facts needed to interpret C++ operations.

Use-after-move detection was one of my last additions to the incubator checker. Such bugs can occur when code uses a moved-from object while assuming that it still holds its original value. A later operation’s validity depends on the type’s post-move guarantees and the operation’s preconditions. For example, standard-library objects generally remain valid after a move, although their state may be unspecified. clang-tidy already provides a check for detecting potential use-after-move bugs: bugprone-use-after-move.

Checks such as bugprone-use-after-move can become very expensive to run on large, complex C++ projects. I believe a checker built on CIR can offer a useful alternative, and this article tests that expectation.

I compare the CIR lifetime checker with the original clang-tidy bugprone-use-after-move check, using the Clang source tree as the test corpus. The prototype was extended to handle more analysis paths and reduce false positives. The aim was to make the performance comparison with bugprone fairer by bringing the work done by the two checks closer, within CIR’s coverage limits. I measure execution time and review distinct use-after-move warning sites as one measure of diagnostic quality.

I first describe the CIR lifetime checker and its use-after-move analysis. I then explain the comparison framework and present the performance results and the review of false positives.

Use-after-move detection with CIR

The clang-tidy check bugprone-use-after-move uses Clang’s control-flow graph (CFG) to find uses of an object after a move and account for reinitialization. This analysis can become expensive on large, complex C++ projects.

CIR gives the analysis an explicit representation of operations, data flow, and lifetime scopes, including where storage is allocated and where object lifetimes end. This provides a basis for tracking program state. To interpret C++ operations correctly, the analysis also needs information from Clang’s AST - for example, whether a type is a smart pointer and which constructor has been selected. CIR combines these sources of information, allowing the checker to follow state changes through the IR and interpret them using facts preserved from the AST.

Figure 1 shows how the checker connects Clang’s AST, CIR, and lifetime diagnostics. The clang-tidy integration reuses its parsed AST to build CIR with explicit control flow, data flow, and lifetime boundaries, together with C++ facts about ownership, moves, and library contracts.

CIR generation and lifetime analysis in the experimental checker

The analysis tracks lifetime and move states through branches and loops and reports potentially invalid uses with source locations and, when known, the preceding move or invalidation. For more detail on the underlying approach, see the earlier articles Building the LifetimeCheck Pass and AST Semantics and Use-After-Move Detection.

Consider the std::unique_ptr example in Figure 2. CIR generation preserves AST-derived facts about the owner type, selected move constructor, and operator* in CIR attributes.

AST-derived semantics and analysis over CIR detect an invalid dereference after a move

Using CIR’s call operands and execution flow, the lifetime analysis applies the recognized move contract: q takes ownership and p becomes empty. The later *p then violates the dereference precondition, so the checker reports an invalid use of p. std::move casts its argument to an rvalue reference; the selected move constructor performs the ownership transfer.

This overview shows how the CIR lifetime checker combines CIR operations with C++ facts from the AST. By tracking program state directly over CIR, it avoids the repeated CFG construction and move-specific searches performed by bugprone-use-after-move. In the next section, we compare the performance of the CIR lifetime checker and clang-tidy’s bugprone-use-after-move on the Clang source tree.

Comparison with bugprone-use-after-move

Test corpus and configuration

For the tests, I used a fixed snapshot of the Clang source tree. The built-in profiler measurements used the checker from that snapshot. The independent phase measurements used an updated profiling build, with the same checker implementation and Clang corpus.

The frozen compilation database contained 1,614 translation units (TUs). The comparison uses the 1,572 C++ TUs that both checks completed successfully. CIR generation failed for three inputs, and the lifetime pass rejected another 37. These inputs and the two C inputs remain listed separately in the experiment records. None of the invocations crashed or timed out.

Both checks used the same clang-tidy binary, built in Release mode with assertions disabled, and the compilation flags from the frozen database. For each check and TU, I used one warmup followed by three measured runs, with a 600-second limit per invocation.

The two profilers were run separately on this fixed corpus. The built-in profiler measurements processed one check at a time, with four clang-tidy processes running in parallel. The runner alternated the order of the two checks between repetitions. The independent profiler used serial paired invocations, alternating check order. CIR itself used one analysis thread throughout.

The following commands show an unprofiled invocation of each check for one TU. Here, $TIDY is the path to clang-tidy, $DB is the compilation database directory, $TU is the source file, and $OUT is the output directory.

CIR lifetime check

"$TIDY" -p="$DB" \
  --checks='-*,cir-lifetime-check' \
  --config='{CheckOptions: {
    cir-lifetime-check.AnalyzeUnusedMainFileFunctions: true,
    cir-lifetime-check.HistLimit: 1,
    cir-lifetime-check.HistoryList: all,
    cir-lifetime-check.NumThreads: 1,
    cir-lifetime-check.RemarksList: ""
  },
    HeaderFilterRegex: ".*",
    SystemHeaders: false,
    WarningsAsErrors: ""}' \
  --warnings-as-errors= --use-color=false --quiet \
  --export-fixes="$OUT/cir/diagnostics.yaml" "$TU"

Bugprone use-after-move check

"$TIDY" -p="$DB" \
  --checks='-*,bugprone-use-after-move' \
  --config='{CheckOptions: {
    bugprone-use-after-move.InvalidationFunctions: "",
    bugprone-use-after-move.ReinitializationFunctions: ""
  },
    HeaderFilterRegex: ".*",
    SystemHeaders: false,
    WarningsAsErrors: ""}' \
  --warnings-as-errors= --use-color=false --quiet \
  --export-fixes="$OUT/bugprone/diagnostics.yaml" "$TU"

Built-in clang-tidy profiler

I first used clang-tidy’s built-in profiler with --enable-check-profile and --store-check-profile=<directory>.

Table 1 shows the wall time recorded within each check’s timer interval, summed across the common TUs and all three measured runs.

Check times recorded by the built-in clang-tidy profiler
Recorded check interval Bugprone (s) CIR (s)
Wall time 3,588.19 760.17

The recorded interval is \(4.72\times\) smaller for CIR, including CIR generation and the subsequent passes. However, the built-in profiler adds different overhead to the two checks. It starts and stops timers during AST matching, so bugprone’s statement matcher pays this cost much more often than CIR’s translation-unit matcher.

With built-in profiling enabled, bugprone’s whole-invocation total was about 34% higher than in the unprofiled measurements on the same Clang snapshot, while CIR’s total was almost unchanged. These measurements were collected in separate runs, so the difference cannot be attributed solely to profiling. The recorded check intervals include the unequal instrumentation costs.

To avoid this bias, I implemented an independent phase profiler. It records four timestamps per translation unit, regardless of how many AST nodes the check visits. The following comparison uses this profiler with clang-tidy’s built-in profiling disabled.

Independent phase profiler

The benchmark runner collected the independent phase measurements using the serial paired configuration described above. The profiling protocol uses --store-phase-profile=<directory> without enabling the built-in check timers.

Table 2 adds the times across the common translation units and all three measured runs. Each whole invocation includes startup, frontend processing, analysis, diagnostic output, shutdown, and the measurement supervisor.

Whole-invocation timing with the independent phase profiler
Recorded interval Bugprone (s) CIR (s)
Consumer setup 2.179 2.117
Frontend before analysis 6,759.134 6,768.282
Analysis after parsing 1,983.137 907.087
Process residual 471.834 352.465
Whole invocation 9,216.285 8,029.950

Across all measured invocations, CIR took 12.87% less overall time. CIR’s analysis after parsing was \(2.19\times\) faster. This interval includes matcher traversal, dispatch, and diagnostics. For CIR, it also includes generation, verification, passes, and context teardown.

Frontend processing took nearly the same time for both checks. Analysis accounted for 21.52% of bugprone’s invocation time and 11.30% of CIR’s, which helps explain why the overall improvement was smaller. The frontend interval includes parsing and semantic analysis. The process residual includes startup, AST teardown, diagnostic export, profile serialization, and the measurement supervisor.

Before the full run, I compared profiling enabled and disabled on a separate frozen 100-TU sample. For both checks, the 95% confidence interval for the change in whole-invocation time stayed within \(\pm1\%\).

A separate detailed run on the same frozen 100-TU sample measured the work inside CIR’s analysis interval. Table 3 adds the times across three repetitions for the 98 TUs that CIR completed successfully. The detailed CIR stage timers were enabled for this separate run.

CIR analysis on the successful 98-TU subset
Work inside CIR’s analysis interval Time (s)
CIR generation 25.711
Explicit verification 1.412
Canonicalization, including pass verification 1.631
Lifetime pass, including pass verification 8.314
Remaining analysis time 11.016

Generating CIR took just over half of the analysis interval in this sample.

Diagnostic quality and analysis scope

We can compare diagnostic quality by looking at the use-after-move warnings produced by both checks. A warning in a shared header can appear in several TUs, so each source location is counted once. Table 4 combines false positives and policy warnings on valid code, with the move-semantics tests listed separately.

Source-reviewed use-after-move sites
Reviewed use-after-move source sites Bugprone CIR
Warnings on valid code, excluding move-semantics tests 16 7
Warnings in move-semantics tests 2 3
Confirmed naturally occurring production defects 0 0
Total distinct sites 18 10

Some of the warnings in Table 4 come from test code. For example, consider ValueMoveSemantics in the Interpreter tests. It moves a Value object and then checks that the source is empty. Bugprone warns at the first check, while CIR also warns at a later check after the same move. These are valid operations that inspect the expected state of the moved-from object.

Bugprone searches the AST, including move-related calls in unused inline functions in headers. CIR analyzes functions emitted by CIR generation. To cover more cases, the experiment also enables analysis of selected unused non-template inline functions in the main source file. CIR follows the emitted evaluation order, so it can miss an invalid use that occurs under another order allowed by C++.

Some false-positive fixes came from this same Clang corpus while extending the prototype for the performance comparison. The counts therefore apply to code also used during development. We have not measured how many bugs either check misses.

It’s worth noting that CIR also checks for null or uninitialized pointer dereferences, uses of dangling pointers, and selected coroutine lifetime errors. We do not review those warnings here, but the time spent on these checks is included in the measurements.

Conclusion

In this article, I explored use-after-move detection with CIR and compared it with clang-tidy’s implementation based on Clang’s CFG. The independent profiler measured CIR’s analysis after parsing as \(2.19\times\) faster, including CIR generation and passes. Frontend processing remained nearly unchanged, limiting the whole-invocation improvement to 12.87%. CIR reported fewer use-after-move warning sites on the reviewed Clang corpus, although the checks analyze different sets of functions.