var vs let in SIL
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’s High-Level IR: A Case Study of Complementing LLVM IR with Language-Specific Optimization