← Back to AILP Home

NLL Drop (last-use early-drop)

New in v0.31.7.x. Function-level opt-in mechanism that retimes auto-emitted RAII drop calls from lexical scope end (the default) to the last use of each RAII-managed binding, computed by backward control-flow liveness.

Motivation

The default Drop policy is lexical: every RAII binding's auto-emitted destructor fires at the closing } of the scope that introduced it. This is predictable and easy to read, but it pins resources for the full lifetime of the enclosing block even when nothing actually uses them past a certain point.

use "drop.npk".*;
use "handle.npk".*;

func:hot_loop = NIL() {
    int64:arena = HandleArena.create();
    Handle<int64>:scratch = raw HandleArena.alloc(arena, 64i64);

    use_scratch_then(pass scratch);      // last use of scratch

    expensive_pure_computation();         // scratch STILL pinned here
    expensive_pure_computation();
    expensive_pure_computation();
};                                        // drops finally fire here

The scratch handle is unused for the entire tail of the function but still consumes a handle slot until }. For hot loops carrying many scratch resources, that pressure is measurable.

#[nll_drop] flips the policy:

#[nll_drop]
func:hot_loop = NIL() {
    int64:arena = HandleArena.create();
    Handle<int64>:scratch = raw HandleArena.alloc(arena, 64i64);

    use_scratch_then(pass scratch);      // last use of scratch
    // ↑↑↑ npk_handle_free(scratch) fires HERE under #[nll_drop]

    expensive_pure_computation();         // scratch already returned
    expensive_pure_computation();
    expensive_pure_computation();
};

Functions without the annotation are byte-for-byte unchanged.

Opt-in: #[nll_drop] on a function

#[nll_drop]
func:compute = int32() {
    wildx int8->:scratch = wildx_alloc(1024i64);
    use_scratch(pass scratch);
    // npk_wildx_free(scratch) fires here, not at };
    pass 0;
};

The attribute attaches to the function declaration directly. It takes no arguments and stacks with other attributes (#[destroys_arena(_)], #[derive(_)], etc.) in any order.

Effects of #[nll_drop]:

Per-binding opt-out: #[lexical_drop]

Inside an #[nll_drop] function, a single binding can opt back into lexical timing:

#[nll_drop]
func:mixed = NIL() {
    #[lexical_drop]
    Handle<int64>:long_lived = raw HandleArena.alloc(arena, 64i64);
    Handle<int64>:short = raw HandleArena.alloc(arena, 16i64);

    use(pass short);                      // short drops here (NLL)

    // ... lots of code that doesn't touch long_lived ...

    record_metric(long_lived);            // long_lived still alive
};                                        // long_lived drops here (lexical)

#[lexical_drop] is a no-op outside an #[nll_drop] function and a no-op on bindings not enrolled in RAII.

It opts that one binding out of NLL timing; the binding still participates in RAII (still drops at }). To opt a binding out of RAII enrolment itself, use the orthogonal nodrop wrapper-keyword from v0.31.6.x — see regions.md.

Conditional last-use

When the last use of a binding lives inside one arm of an if, the drop point is the join point after the if, not inside the branch:

#[nll_drop]
func:branched = NIL(bool:flag) {
    Handle<int64>:h = raw HandleArena.alloc(arena, 8i64);

    if (flag) {
        consume(pass h);                  // last use in the then-arm
    }
    // Both arms converge here — h drops at this join point,
    // not inside the if. The else-arm would otherwise skip the drop.

    other_work();
};

This avoids the "one branch drops, the other branch leaks" hazard by always emitting the drop on a path that dominates both branches' continuation.

Loop last-use

Inside a loop or for, a binding that is read across the back-edge stays live for the entire loop. NLL only fires the drop after the loop exits:

#[nll_drop]
func:iter = NIL() {
    Handle<int64>:buffer = raw HandleArena.alloc(arena, 256i64);

    for (i = 0; i < 100; i += 1) {
        write_to(buffer, i);              // buffer used every iteration
    }
    // buffer drops here, immediately after the loop, not at };

    pure_tail();
};

The back-edge liveness merge that powers this is the same fixpoint the borrow checker already used (Gemini-Report path); v0.31.7.2 simply populated its previously-empty use-set.

pass v; (move) wins

pass v; is a move, not a use. It transfers ownership to the caller and the originating frame's auto-drop is skipped entirely (this rule predates NLL — DROP-DEC-004). NLL changes nothing here:

#[nll_drop]
func:donor = Handle<int64>() {
    Handle<int64>:owned = raw HandleArena.alloc(arena, 16i64);
    pass owned;                           // moves to caller — NO drop emitted
};

The caller is now responsible for owned's eventual drop (at its own scope end, or under its own #[nll_drop] policy).

Error / unwind paths

On fail or panic, Drop still fires for every binding that is live at the fail point. Bindings that were early-dropped under NLL before the fail are not re-dropped — the consumed flag carries through the unwind walker:

#[nll_drop]
func:fallible = Result<NIL>() {
    Handle<int64>:early = raw HandleArena.alloc(arena, 8i64);
    Handle<int64>:late = raw HandleArena.alloc(arena, 8i64);

    use(pass early);                      // early drops here (NLL)
    if (some_condition()) {
        fail "boom";                      // unwind: late drops, early does NOT
    }

    use(pass late);                       // late drops here (NLL)
    pass;
};

Interaction with nodrop (v0.31.6.x)

nodrop and #[lexical_drop] target different axes:

Opt-out What it changes Where it lives
nodrop RAII enrolment — the binding never gets an auto-drop at all wrapper keyword on the initializer
#[lexical_drop] NLL timing — the binding's auto-drop stays at scope end attribute on the binding

They compose: a nodrop binding inside an #[nll_drop] function simply has nothing for NLL to retime, so the attribute is moot for that binding.

#[nll_drop]
func:both = NIL() {
    // Manually managed — neither NLL nor lexical Drop touches this.
    Handle<int64>:manual = nodrop raw HandleArena.alloc(arena, 8i64);
    HandleArena.free(manual);

    // NLL-managed — drops at last use.
    Handle<int64>:auto = raw HandleArena.alloc(arena, 8i64);
    use(pass auto);
};

See regions.md for the nodrop surface.

Borrow lifetime tightening (NLL-DEC-014)

Under #[nll_drop], borrow lifetimes derived from a RAII binding end at the NLL drop point, not at }. This is a tightening — code that borrowed past last-use under lexical Drop may fail to compile under #[nll_drop]:

#[nll_drop]
func:tight = NIL() {
    Handle<int64>:h = raw HandleArena.alloc(arena, 8i64);
    $i Handle<int64>:b = $i h;
    use_handle(pass h);                   // last use of h → drops here

    use_borrow(b);                        // ERROR: ARIA-014 — h dropped
};

Removing the #[nll_drop] attribute makes this code compile again under lexical Drop. The tightening is gated by the attribute, so no existing code regresses.

Troubleshooting: ARIA-052

If you annotate a function with #[nll_drop] but the function has no RAII-enrolled bindings to retime, the compiler emits:

warning: [ARIA-052] function 'name' has no RAII bindings; #[nll_drop] has no effect

Common causes:

Remove the attribute or add at least one RAII binding to silence the warning.

When not to use #[nll_drop]

See also