var vs let in SIL

Yusuke Kita
Swift type in SIL
Published in
6 min readMay 12, 2018

Hi all, I’m @kitasuke, iOS Engineer.

This is my first post, “var vs let in SIL” as series of “Swift type in SIL”. Today I’m going to share what I’ve learned about how var and let work in SIL.

If you are interested in other posts, see more details here.

var and let

We use var and let a lot on a daily basis, but let me briefly explain about them.

var

As var is known as a variable, a value of var can be set or modified multiple times.

var x: Int
x = 1
x = 10

let

On the other hand, as let is known as a constant, a value of let can’t be changed once it’s set.

let x: Int 
x = 1
x = 10 // error

So it’s quite simple. The difference is literally variable or constant.

SIL

Next, let’s take a look at how they are represented in SIL.

Examples

There is a simple function number() which returns Int value. The only difference between two files is whether the Int value's declared as var or let.

var.swift

func number() -> Int {
var x: Int
x = 1
return x
}

let.swift

func number() -> Int {
let x: Int
x = 1
return x
}

raw SIL

Let’s generate raw SIL for var.swift with swiftc command below.

$swiftc -emit-silgen var.swift -o var.silgen

Below is var.silgen which is raw SIL of var.swift. You might see unfamiliar functions, but there is no need to understand them yet.

var.silgen

%0 = alloc_box ${ var Int }, var, name "x"      
%1 = mark_uninitialized [var] %0 : ${ var Int }
%2 = project_box %1 : ${ var Int }, 0
%3 = metatype $@thin Int.Type
%4 = integer_literal $Builtin.Int2048, 1
// function_ref Int.init(_builtinIntegerLiteral:)
%5 = function_ref @$SSi22_builtinIntegerLiteralSiBi2048__tcfC :
$@convention(method) (Builtin.Int2048, @thin Int.Type) -> Int
%6 = apply %5(%4, %3) : $@convention(method) (Builtin.Int2048, @thin Int.Type) -> Int
%7 = begin_access [modify] [unknown] %2 : $*Int
assign %6 to %7 : $*Int
end_access %7 : $*Int
%10 = begin_access [read] [unknown] %2 : $*Int
%11 = load [trivial] %10 : $*Int
end_access %10 : $*Int
destroy_value %1 : ${ var Int }
return %11 : $Int

Same swiftc command for let.swift.

$swiftc -emit-silgen let.swift -o let.silgen

Below is let.silgen which is raw SIL of let.swift.

let.silgen

%0 = alloc_stack $Int, let, name "x"           
%1 = mark_uninitialized [var] %0 : $*Int
%2 = metatype $@thin Int.Type
%3 = integer_literal $Builtin.Int2048, 1
// function_ref Int.init(_builtinIntegerLiteral:)
%4 = function_ref @$SSi22_builtinIntegerLiteralSiBi2048__tcfC :
$@convention(method) (Builtin.Int2048, @thin Int.Type) -> Int
%5 = apply %4(%3, %2) : $@convention(method) (Builtin.Int2048, @thin Int.Type) -> Int
assign %5 to %1 : $*Int
%7 = load [trivial] %1 : $*Int
dealloc_stack %0 : $*Int
return %7 : $Int

Diff

I’ll highlight the differences so that you could easily see them.

If you closely look at the diff, you can see alloc_box and begin_access in var.silgen.

%0 = alloc_box ${ var Int }, var, name "x"      
%1 = mark_uninitialized [var] %0 : ${ var Int }
%2 = project_box %1 : ${ var Int }, 0
%7 = begin_access [modify] [unknown] %2 : $*Int
end_access %7 : $*Int
%10 = begin_access [read] [unknown] %2 : $*Int
end_access %10 : $*Int
destroy_value %1 : ${ var Int }

However, you can see alloc_stack, not alloc_box in let.silgen.

%0 = alloc_stack $Int, let, name "x"           
%1 = mark_uninitialized [var] %0 : $*Int
dealloc_stack %0 : $*Int

What’s the difference between alloc_box and alloc_stack? I guess this can guide us to deep understanding of what var and let are.

alloc_box vs alloc_stack

alloc_box

Allocates a reference-counted @box on the heap large enough to hold a value of type T, along with a retain count and any other metadata required by the runtime. The result of the instruction is the reference-counted @box reference that owns the box. The project_box instruction is used to retrieve the address of the value inside the box.
The box will be initialized with a retain count of 1; the storage will be uninitialized. The box owns the contained value, and releasing it to a retain count of zero destroys the contained value as if by destroy_addr. Releasing a box is undefined behavior if the box’s value is uninitialized. To deallocate a box whose value has not been initialized, dealloc_box should be used.

According to documentation, alloc_box allocates a reference counted value on heap. It has to be manually managed by retain count.

alloc_stack

Allocates uninitialized memory that is sufficiently aligned on the stack to contain a value of type T. The result of the instruction is the address of the allocated memory. If a type is runtime-sized, the compiler must emit code to potentially dynamically allocate memory. So there is no guarantee that the allocated memory is really located on the stack. alloc_stack marks the start of the lifetime of the value; the allocation must be balanced with a dealloc_stack instruction to mark the end of its lifetime. All alloc_stack allocations must be deallocated prior to returning from a function. If a block has multiple predecessors, the stack height and order of allocations must be consistent coming from all predecessor blocks. alloc_stack allocations must be deallocated in last-in, first-out stack order.

The memory is not retainable. To allocate a retainable box for a value type, use alloc_box.

According to documentation, alloc_stack allocates a value on stack. It's not reference counting. All alloc_stack allocations must be deallocated prior to returning from a function.

The big difference here would be a lifetime of value. For example, if you have variable declared outside of closure but it’s used in the closure, its value might be modified. For that case, it should be retain counted by alloc_box. However, if you have variable declared inside of function, it should be deallocated by alloc_stack.

I was thinking that var enables us to just modify its value multiple times, but it can also be done even outside of scope. That's why alloc_box is used for reference counting.

Come to think of it, In our example, we just used a local var inside function and its never modified out of scope. Does it really have to use alloc_box for the case? Let's look at canonical SIL next.

canonical SIL

Here is swiftc command to emit canonical SIL.

$swiftc -emit-sil var.swift -o var.sil

Below is var.sil which is canonical SIL of var.swift.

var.sil

%0 = alloc_stack $Int, var, name "x"          
%1 = integer_literal $Builtin.Int64, 1
%2 = struct $Int (%1 : $Builtin.Int64)
%3 = begin_access [modify] [static] %0 : $*Int
store %2 to %3 : $*Int
end_access %3 : $*Int
%6 = begin_access [read] [static] %0 : $*Int
end_access %6 : $*Int
dealloc_stack %0 : $*Int
return %2 : $Int

It’s a bit different from var.silgen. As expected, alloc_box is replaced with alloc_stack in var.sil. How does it happen? This is a part of optimizations in swiftc. To be more specific, it's "box to stack promotion" in AllocBoxToStack module. The idea is that swiftc promotes unnecessary heap allocation to stack. Please see more details the link below.

Same swiftc command for let.swift.

$swiftc -emit-sil let.swift -o let.sil

Below is let.sil which is canonical SIL of let.swift.

let.sil

%0 = alloc_stack $Int, let, name "x"  
%1 = integer_literal $Builtin.Int64, 1
%2 = struct $Int (%1 : $Builtin.Int64)
store %2 to %0 : $*Int
dealloc_stack %0 : $*Int
return %2 : $Int

There is no major differences here. Raw SIL was enough simple, so I guess there is nothing to optimize in this pass.

Summary

Today, we dived into var and let in SIL. We found out that there is a lifetime for Swift values. Also swift compiler is really smart. It has lots of optimizations, not just the one I explained in this post. I thought that var and let are just simple, but they are well considered behind the scenes in the compiler. It might be too detailed knowledge, but it's always good to know how it works as Swift developer.

References

swift/docs/SIL.rst

Swift’s High-Level IR: A Case Study of Complementing LLVM IR with Language-Specific Optimization

--

--