On this page
$ technical

Rust Code Coverage Internals: What Happens Under the Hood

How the Rust compiler adds metadata to compiled binaries and how the profiler runtime generates code coverage.

All articles

LLVM code coverage instrumentation pipeline

Context

Rust is a compiled language. It creates an executable or a library depending on the nature of the application. The Rust compiler has several stages which compiles the code to a lower level as it passes through each stage. In this article, let’s explore how the Rust compiler adds metadata to the compiled binary and how this data is later used by the profiler runtime to generate code coverage data.

Flags:

First, to let the Rust compiler know that it needs to add metadata for coverage to the compiled binary, we need to pass additional flags to the compiler. The Rust compiler provides 2 different flags that can be used to get coverage data. Each flag is completely different from the other due to using a different compiler backend:

  1. -Z profile: GCC-compatible gcov based instrumentation.
  2. -C instrument-coverage: LLVM intrinsics to generate coverage data.

Note: Intrinsics are special functions recognized by the compiler that often map directly to underlying hardware instructions, allowing for low-level code generation and optimization.

LLVM Based Code Coverage

This article focuses on the -C instrument-coverage flag as it’s the preferred method for generating coverage data in modern Rust. Let’s delve into how this flag influences the compilation process.

What is a Counter?

At its core, code coverage relies on counting the executions of specific code segments. The compiler achieves this by inserting “counters” at strategic locations within the compiled binary. These counters are essentially variables that increment each time the associated code segment is executed.

What Happens at Different Stages of Compilation?

When -C instrument-coverage is used, the compiler performs the following actions during compilation:

  1. Instrumentation: During intermediate representation (IR) generation, the compiler identifies basic blocks within the code. A basic block is a sequence of instructions with a single entry point and a single exit point. The compiler inserts calls to LLVM intrinsic functions that increment counters at the beginning of each basic block.

Example: Original Rust code

fn foo(x: i32) -> i32 {  
    if x > 0 {  
        x + 1  
    } else {  
        x - 1  
    }  
}

Example: LLVM IR with instrumentation (simplified)

define i32 @foo(i32 %x) {  
entry:  
  %cmp = icmp sgt i32 %x, 0  
  br i1 %cmp, label %if.then, label %if.else  
if.then: ; preds = %entry  
  call void @llvm.instrprof.increment(...)   
  %addtmp = add i32 %x, 1  
  br label %if.end  
if.else: ; preds = %entry  
  call void @llvm.instrprof.increment(...)   
  %subtmp = sub i32 %x, 1  
  br label %if.end  
if.end: ; preds = %if.else, %if.then  
  %result = phi i32 [ %addtmp, %if.then ], [ %subtmp, %if.else ]  
  ret i32 %result
}
  1. Counter Generation: The compiler creates global variables to represent the counters. Each counter is associated with a specific basic block.

  2. Metadata Embedding: The compiler embeds metadata within the compiled binary. This metadata contains information about the counters, their locations, and the associated source code.

What is a Profile Runtime?

The “profile runtime” is a library linked with the instrumented binary. It is responsible for managing the counters and providing the infrastructure for generating coverage data. The Rust standard library includes this runtime.

How the Profile Runtime is Used

The profile runtime performs the following actions:

  1. Counter Initialization: At program startup, the runtime initializes all counters to zero.
  2. Counter Incrementing: As the program executes, the runtime’s intrinsic function calls increment the relevant counters whenever a basic block is entered.
  3. Data Collection: Upon program termination or through specific API calls, the runtime gathers the values of all counters.

Generating Profraw Files

The collected counter data is written to a file, typically named default.profraw. This file contains raw profiling information in a format specific to LLVM.

What Does a Profraw File Contain?

A profraw file stores:

  1. Function Information: The names and addresses of all instrumented functions.
  2. Counter Data: The values of all counters, representing the number of times each basic block was executed.
  3. Mapping Information: Data that maps counters to their corresponding source code locations.

Generating Reports From the Profraw Files

Specialized tools, such as llvm-profdata and llvm-cov, are used to process the profraw files and generate human-readable code coverage reports. These tools utilize the mapping information within the profraw file to associate execution counts with specific lines of code.

Conclusion

The -C instrument-coverage flag empowers the Rust compiler to embed coverage instrumentation directly into the compiled binary using LLVM intrinsics. This, in conjunction with the profile runtime, enables the collection of detailed code coverage information during program execution. Utilizing tools like llvm-profdata and llvm-cov, developers can translate the raw profiling data into comprehensive reports, providing valuable insights into the effectiveness of their test suites and the overall coverage of their codebase.