gccrs-final-report
Design Note
The design and implementation of Rust Drop support in gccrs.
Jump to:
- Part 1: Manual Drop Emission
- Part 2: Structured Drop Cleanup with
TRY_FINALLY_EXPR - Part 3: BIR and CFG-Based Drop Analysis
Part 1: Manual Drop Emission
1.1 Tracking Drop candidates and LIFO order
When I started working on Drop support, the compiler first needed to know which variables should be dropped.
In the first version, gccrs only recorded an initialized local binding with a simple name, such as let x = .... If its type implemented Drop, gccrs saved it as a Drop candidate in the current block scope. ref bindings and subpatterns were not supported yet.
How the candidates were stored. A DropCandidate stored the HIR ID of the binding and its source location:
struct DropCandidate
{
DropCandidate (HirId hirid, location_t locus)
: hirid (hirid), locus (locus)
{}
HirId hirid;
location_t locus;
};
Context::block_drop_candidates kept one candidate list for each active block:
std::vector<::std::vector<DropCandidate>> block_drop_candidates;
DropBuilder::note_simple_drop_candidate added a candidate to the current block:
void
DropBuilder::note_simple_drop_candidate (HirId hirid, location_t locus)
{
rust_assert (!ctx.block_drop_candidates.empty ());
ctx.block_drop_candidates.back ().emplace_back (hirid, locus);
}
The outer vector acts as a stack of block scopes, and back() selects the current block. The HIR ID identifies the binding later. Context::push_block and Context::pop_block create and remove the per-block lists.
At the end of the scope, the compiler added Drop calls in reverse declaration order. For example:
fn f() {
let a = Droppable("a");
let b = Droppable("b");
}
The compiler needed to run:
drop(b)
drop(a)
The core loop in the gccrs source is small:
for (auto it = drop_candidates.rbegin ();
it != drop_candidates.rend (); ++it)
rbegin() visits the newest binding first, so gccrs emits drop(b) before drop(a). View the full implementation.
The first block-scope implementation was added in PR #4564, with more tests for LIFO order and nested scopes in PR #4632.
PR #4586 moved candidate tracking behind DropBuilder. This refactoring did not change Drop behavior, but it gave later work a clearer interface.
I then used this manual approach to add Drop support for normal function exits and explicit returns.
1.2 Normal function exits
Normal function exit means that control reaches the end of the function body without an explicit return. gccrs must then drop the local variables and function parameters that are still initialized.
1. Reaching the closing brace with no tail expression
fn f() {
let _x = Droppable;
}
Drop _x
function ends
View drop-function-scope-unit.rs, added in PR #4564.
2. Unit tail expressions
fn unit_tail_call() {
let _x = Droppable;
foo()
}
fn unit_tail_literal() {
let _x = Droppable;
()
}
unit_tail_call():
run foo()
Drop _x
function ends
unit_tail_literal():
reach the () tail expression
Drop _x
function ends
View drop-function-scope-unit-tail.rs, added in PR #4591.
3. A non-unit tail expression
fn f() -> i32 {
let _x = Droppable;
foo()
}
run foo()
save the result
Drop _x
return the saved result
View drop-function-scope-non-unit-tail.rs, added in PR #4602.
Function parameters are also dropped on normal exit. PR #4591 tests named and wildcard parameters, and PR #4789 adds multiple-parameter coverage.
1.3 Explicit returns
An explicit return uses the return keyword and leaves the function immediately. The testcase in PR #4621 covers four cases.
1. Unit return
fn unit_return() {
let _x = UnitDroppable;
return;
}
Drop _x
return
2. Unit return expression
fn unit_return_expr() {
let _x = UnitExprDroppable;
return make_unit();
}
run make_unit()
Drop _x
return ()
3. Non-unit return expression
fn non_unit_return() -> i32 {
let _x = NonUnitDroppable;
return make_value();
}
run make_value()
save the result
Drop _x
return the saved result
4. Return from a nested block
fn nested_return() {
let _outer = OuterDroppable;
{
let _inner = InnerDroppable;
return;
}
}
Drop _inner
Drop _outer
return
Philip Herron suggested using TRY_FINALLY_EXPR so that gccrs did not need to insert Drop calls directly at each return. Part 2 explains that design.
Part 1 pull requests and references
- PR #4559: Register the Drop lang item
- PR #4564: Add Drop support for block-local variables
- PR #4586: Refactor CompileDrop and add DropBuilder
- PR #4591: Support function-scope Drops on normal function exit
- PR #4602: Evaluate non-unit tail expressions before Drops
- PR #4621: Handle explicit-return Drops
- PR #4632: Add LIFO and nested-scope tests
Part 2: Structured Drop Cleanup with TRY_FINALLY_EXPR
2.1 Moving cleanup into the scope
Philip’s suggestion was to create a TRY_FINALLY_EXPR when gccrs lowered a HIR::BlockExpr.
The block body would be placed in the main region. The Drop calls for the block would be placed in the cleanup region:
TRY_FINALLY_EXPR {
block body
} cleanup {
Drop the local values in this block
}
At the GCC tree level, the generic gccrs backend helper builds this node with TRY_FINALLY_EXPR. The source takes the compiled body and cleanup as its two operands.
gccrs now attaches the Drop calls to each scope once. Nested scopes create nested cleanup regions, so a return does not need to rebuild their Drop calls.
2.2 Using the GCC Go frontend as a reference
I then studied how the GCC Go frontend implements defer. Go’s defer and Rust’s Drop are different features. A deferred Go call runs when its function exits, not when any block ends. However, the Go frontend still gave me a useful example of how GCC represents code that must run when a function exits.
In the GCC Go backend, a final cleanup is lowered with TRY_FINALLY_EXPR.
Conceptually, nested Drop scopes can be represented like this:
try {
block body
try {
inner block body
} finally {
drop inner values
}
} finally {
drop outer values
}
This is a compiler IR structure. It does not add try or finally syntax to Rust. The inner cleanup runs first, which keeps the correct LIFO Drop order.
2.3 Handling the personality linker problem
When I tested the first TRY_FINALLY_EXPR version, the Drop order was correct, but drop-nested-block-scope.rs failed to link:
undefined reference to `__gccrs_personality_v0`
Pierre-Emmanuel Patry explained that a personality routine is used during stack unwinding. gccrs does not support stack unwinding yet, so it does not provide this symbol.
Compiling the test with -fno-exceptions removed the reference, but this was only an experiment. It was not a suitable compiler-wide solution.
Pierre-Emmanuel then pointed me to the GCC Internals documentation and the historical rustc_codegen_gcc source. They showed that EH_ELSE_EXPR can split normal cleanup from exceptional cleanup.
The key part I added to Context::pop_block_impl is:
tree exceptional_cleanup = build_empty_stmt (cleanup_locus);
tree cleanup_selector
= build2_loc (cleanup_locus, EH_ELSE_EXPR, void_type_node, cleanup,
exceptional_cleanup);
tree try_finally
= Backend::exception_handler_statement (body, NULL_TREE,
cleanup_selector,
cleanup_locus);
cleanup is the normal arm, and exceptional_cleanup is the empty exceptional arm. With this structure, the testcase linked without __gccrs_personality_v0 and still printed the correct Drop order. View the full gccrs source.
2.4 Applying structured cleanup to functions and returns
Once the block-scope cleanup was ready, I applied the same design to function scopes in PR #4711.
The final version of PR #4621 then removed the manual explicit-return Drop code. Normal function exits and explicit returns could now use the same cleanup structure.
The argument scope and function-body scope were later separated in PR #4718, which made the Drop scopes more closely match rustc.
2.5 Extending cleanup to unlabeled break and continue
break and continue can leave a block before its end, so the local values in that block must be dropped first.
PR #4710 includes this break testcase:
fn test_break() {
loop {
let _outer = BreakOuter;
{
let _inner = BreakInner;
break;
}
}
}
Drop _inner
Drop _outer
leave the loop
The same testcase also checks continue:
fn test_continue() {
let mut done = false;
loop {
if done {
break;
}
{
let _outer = ContinueOuter;
{
let _inner = ContinueInner;
done = true;
continue;
}
}
}
}
Drop _inner
Drop _outer
start the next iteration
The lowered break or continue stays inside the TRY_FINALLY_EXPR body, so leaving that body runs the existing cleanup. The full testcase also covers tail-position forms, while loops, and break with a value. View drop-unlabeled-break-continue.rs.
Labeled jumps and a return used as the final expression of a block were outside this PR.
This work also exposed a separate double-Drop case. One loop path could reach cleanup before a local value was initialized. This showed the next problem: structured cleanup knew where to run Drop, but it still could not decide whether a value should be dropped. Part 3 describes the analysis for that decision.
Part 2 pull requests and references
- PR #4685: Emit block-scope Drops through TRY_FINALLY_EXPR
- PR #4711: Apply try/finally cleanup to function-scope Drops
- PR #4621: Handle explicit-return Drops with try/finally cleanup
- PR #4718: Separate argument and function-body Drop scopes
- PR #4710: Run block cleanup for unlabeled break and continue
- Itanium C++ ABI: Exception handling and personality routines
- GCC Internals documentation
TRY_FINALLY_EXPRandEH_ELSE_EXPRin the GCC fork used by rustc_codegen_gcc
Part 3: BIR and CFG-Based Drop Analysis
3.1 Why structured cleanup is not enough
Structured cleanup tells the compiler where a Drop call should run. However, it does not tell the compiler whether a value still needs to be dropped.
For example:
fn f() {
let x = Droppable("x");
let y = x;
}
The value is moved from x to y. At the end of the function, the compiler should drop y, but it must not drop x again.
To handle this, gccrs needs an analysis that tracks whether a local variable still holds a value. I implemented this analysis in BIR, before the backend creates the final Drop cleanup. The current version handles direct moves of the whole variable. It does not handle moving only one field yet.
3.2 Straight-line BIR Drop analysis
The first version handles straight-line control flow. This means that the function has no branches or loops.
The analysis adds BIR Drop statements at scope exits. It then follows assignments and moves through the function. When a whole local variable is assigned a value, it becomes initialized. If BIR marks the assignment as a move from another whole local, the source becomes uninitialized.
The merged source updates the state in this way:
PlaceId lhs = place;
AbstractExpr &expr = statement.get_expr ();
if (expr.get_kind () == ExprKind::ASSIGNMENT)
{
PlaceId rhs = static_cast<Assignment &> (expr).get_rhs ();
const Place &rhs_place = function.place_db[rhs];
if (rhs_place.kind == Place::VARIABLE
&& rhs_place.should_be_moved ())
initialized[rhs.value] = false;
}
initialized[lhs.value] = true;
View the merged BIR analysis source.
Each Drop statement is then given a Drop style:
Static: the value is initialized and should be dropped
Dead: the value is uninitialized here and should not be dropped
For the earlier move example, the result is:
Drop(y): Static
Drop(x): Dead
Function arguments start in the initialized state.
This straight-line analysis was added in PR #4730.
3.3 Using the analysis in backend cleanup
PR #4748 connected the analysis to backend cleanup. The backend emits Static Drops and skips Dead Drops. This support is now merged in the BIR/borrow-check path.
3.4 Extending the analysis across a CFG — Under review
Status: Under review. The BIR analysis in this section is in PR #4777. It has not been merged yet.
Conditional control flow is more difficult because a value may be moved on one path but not another:
fn f(condition: bool) {
let x = Droppable("x");
if condition {
let y = x;
}
}
The control-flow graph can be viewed as:
move x
/ \
initialize x -- -- join -- Drop(x)
\ /
keep x
At the start of every basic block, the analysis records whether each tracked local may be initialized and whether it may be uninitialized.
At a CFG join, into holds the state already stored for the block. from is the state from a new incoming path. The actual merge_state function is:
static bool
merge_state (BlockInitializationState &into,
const BlockInitializationState &from)
{
bool changed = false;
if (!into.reachable)
{
into = from;
return !changed;
}
for (size_t i = 0; i < into.maybe_initialized.size (); i++)
{
bool maybe_initialized
= into.maybe_initialized[i] || from.maybe_initialized[i];
bool maybe_uninitialized
= into.maybe_uninitialized[i] || from.maybe_uninitialized[i];
changed |= maybe_initialized != into.maybe_initialized[i];
changed |= maybe_uninitialized != into.maybe_uninitialized[i];
into.maybe_initialized[i] = maybe_initialized;
into.maybe_uninitialized[i] = maybe_uninitialized;
}
return changed;
}
The first incoming path copies the full state. Later paths use || to keep every possible state. If the result changes, the worklist processes the block again.
View the implementation under review.
The worklist sends each result to the block’s successors:
while (!worklist.empty ())
{
BasicBlockId block_id = worklist.back ();
worklist.pop_back ();
queued[block_id.value] = false;
BlockInitializationState state = entry_states[block_id.value];
BasicBlock &block = function.basic_blocks[block_id];
for (Statement &statement : block.statements)
update_state_for_statement (function, statement, state);
for (BasicBlockId successor : block.successors)
{
bool state_changed =
merge_state (entry_states[successor.value], state);
if (state_changed && !queued[successor.value])
{
worklist.push_back (successor);
queued[successor.value] = true;
}
}
}
The worklist processes a successor again only when its entry state changes. queued prevents duplicate entries. An empty worklist means that the block-entry states are stable.
View the worklist implementation.
After the block-entry states stop changing, the analysis walks through each reachable block again. It chooses the Drop style from the state just before each Drop runs:
initialized only -> Static
uninitialized only -> Dead
initialized and uninitialized -> Conditional
The classify_drop implementation directly maps those two state bits to the three styles.
3.5 Backend Drop flags for conditional moves — Under review
Status: Under review. The backend implementation in this section is in PR #4798. It has not been merged yet.
A Conditional Drop cannot always run and cannot always be skipped. The backend needs a runtime flag that records whether the value is still initialized on the path that was taken.
The backend then creates a Drop flag for x. Conceptually, the generated backend code for the example above behaves like this:
try {
bool x_drop_flag = false;
let x = Droppable("x");
x_drop_flag = true;
if (condition) {
let y = x;
x_drop_flag = false;
}
} finally {
if (x_drop_flag) {
x_drop_flag = false;
Drop(x);
}
}
The backend code under review follows the same lifecycle in four places:
- Create the flag before compiling the initializer.
- Set it after the initializer finishes.
- Clear the source flag when compiling a move.
- Guard the Drop call and clear the flag before dropping.
In let y = x, the move expression and the source local have different HIR IDs. move_sources maps the expression ID to the local ID, so the backend can clear the correct Drop flag. The PR discussion gives a concrete example.