Skip to content

Instantly share code, notes, and snippets.

@atrick
Last active October 2, 2025 06:56
Show Gist options
  • Save atrick/63d2ff8ae2aaf6792f9e7915f3b1592a to your computer and use it in GitHub Desktop.
Save atrick/63d2ff8ae2aaf6792f9e7915f3b1592a to your computer and use it in GitHub Desktop.
OSSA borrowed return values

OSSA borrowed return values

return_borrow

Insert a return_borrow instruction for each nested borrow scope that continues past the function return:

%l = load_borrow
%b = begin_borrow %l
%e = struct_extract %l
...
%r1 = return_borrow %e, %b // forward %e; end scope %b
%r2 = return_borrow %r1, %l
return %r2

The return_borrow instructions can be (re)computed at any time by finding the return value's enclosing borrow scopes. SILGen can call this utility directly, or it can run in SILGen cleanup. Verification can run after each pass in the same manner as borrowed_from.

Inlining

A borrowed return value always depends on a parameter. When substituting the return value, find that parameter's enclosing borrow scope. Replace each return_borrrow in the callee, with an end_borrow in the caller at each point where the parameter's scope ends.

Scope-ending behavior - option 1: guaranteed forwarding

Assume all the SILValues in the above return_borrow example have guaranteed ownership.

We need to handle the basic invariants:

  1. All guaranteed SILValues have an enclosing borrow scope.

Finding the enclosing borrow scope currently requires a use-def traversal over guaranteed forwarding instructions. return_borrow can be a considered a forwarding operation on its first operand.

2a. All borrow scopes have a set of direct uses that are scope-ending.

2b. Each scope-ending use is mapped to an instruction after all forwarded uses (on all paths to exit).

Finding a borrow scope's scope-ending instructions is more complicated now because we need to distinguish between the scope-ending operand vs. the scope-ending instruction. The return_borrow has the scope-ending operand, but the return is the scope-ending instruction. This way, the borrow scope's range continues to the end of the function and cover all enclosed uses.

For example, when computing an instruction range, we currently insert each scope ending instruction. Today, we do

for operand in scopedInst.scopeEndingOperands {
   insert(operand.instruction)
}

Now we need something like:

insert(operand.endInstruction)

We have the same thing on the C++ side where we do:

BorrowedValue::visitLocalScopeEndingUses((Operand *use) {
    positon = use->getUser();
  })

That's quite error prone, and we'll never catch those errors. To make it harder to do the wrong thing, we would need an EndScope abstraction that you can iterate over instead of picking operands or instructions. That's a lot of complexity for this corner case.

Scope-ending behavior - option 2: unowned forwarding

Now assume that return_borrow produces an unowned value.

%r1 = return_borrow %e, %b // forward %e; end scope %b
%r2 = return_borrow %r1, %l
return %r2

Since %r1 and %r2 are unowned, we can never ask for their enclosing borrow scope.

And return_borrow can be considered a scope-ending instruction without any other compiler changes or new abstractions.

Safety is still ensured by verifying that these instructions are only used by a return.

Unowned values in OSSA

The unowned forwarding alternative above contradicts my claim (earlier today) that OwnershipKind::Unowned doesn't make sense in OSSA. But Unowned per se isn't the problem. What doesn't makes sense is the current expectation that an unowned value needs to be copied. This is exactly the opposite of how it should work. An unowned value must be verifiably kept alive by an enclosing scope. For example, a return or return_borrow use is allowed because the verifier can check that the outermost scope is kept alive by a function argument, and the return_borrow ensures that inlining will extend any nested enclosing scopes.

As for switching over OwnershipKind, OwnershipKind::Unowned and OwnershipKind::None could simply be merged since they are always handled the same way. The only difference is that non-trivial types have extra constraints, which can be enforced without a separate OwnershipKind. Namely, an unowned value of non-trivial type can only be used in specific known-safe patterns in which the verifier can ensure and enclosing scope keeps the value alive.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment