Swift Intermediate Language – why you should not try to escape low-level code

In this article I will try to:

  • present the basics of SIL (Swift Intermediate Language)
  • show a not-real-life-scenario
  • badly hack optimise the code using the information from SIL
  • measure the effectiveness

Low Level

There is a case to be made against low-level coding ever since anything better was available. Low-level coding is complicated, hard, slow and the amount of you doing it is directly correlates to the speed of the your hair-line disappearance.

Ever since we’ve stopped directly using assembly, low-level can mean a lot of things to a lot of people. If you’re into Arduino or any other microcontroller, low-level cannot be avoided due to the environment which is heavily constrained: little to no memory, slow CPU clock speeds, the need to manually handle interrupts, manually set voltage on certain pins, etc.

Web development is, arguably, high-level as little to nothing hardware related is actually exposed directly to the user (in this case the user is you – my dear programmer) other than via APIs. Memory is not being manually managed, etc.

Mobile (native) development is a mixed bag. You can go on without ever dealing with low-level but there might come a time when things either get complicated or something happens and you don’t know why it’s happening. The typical scenario involves creating a screen, opening a screen, reacting to button events, etc. Low level is never actually encountered and for good reason.

Low level is dealt with by guys that do driver coding, kernel-level development, compiler programming… you get the gist. We usually don’t touch it, don’t want to touch it and like to pretend it doesn’t exist.

SIL

You mention sil And everybody loses their minds

Enter SIL – Swift Intermediate Language. Every time you compile your code the compiler parses the code, creates an intermediary that, in turn, later is used to generate the final binary (the byte code used by the processor). It looks awfully close to an assembly-type language that you might have hated all your life.

If you’re still here then I guess you saw that sign at the gates of hell Dante was writing about:

So… what’s SIL good for? It shows exactly what will happen when you compile your code. With a little luck and time, SIL can show you where your code is slowing down, why it’s behaving in a weird way or why… it compiles slowly. The amount of information SIL gives you is proportional to the amount of attention and time you give it. Reading it might pose a challenge but… challenges is what got many of us to this particular field.

Let’s consider the following sample code, that adds two numbers together.

func addTwo(_ first: Int, _ second: Int) -> Int {
    return first + second
}

Just a function that returns the result of two integers added together.

We can save that file under sample.swift and quite easily compile from the command line:

   swiftc sample.swift

This will produce an object file (sample.o) and the binary/executable (sample) that we can simply run (./sample).

We can also ask the compiler to give us the intermediate language version:

   swiftc sample.swift -emit-sil

This, in turn, prints outs the wonderfully, horrifying text (click on the image to enter the madness properly):

😳 Breathe

There’s a lot happening there. We see that the swift compiler, without our doing, did the following:

  • imported some libraries (including the Builtin module)
  • prepared a “main” function for us
  • created the addTwo function that adds two integers
  • (added some other things we might want to get into at the moment)

Going through the whole file is too much for us (or me at the moment) so let’s focus on the actual function addTwo:

// addTwo(_:_:)
// Isolation: unspecified
sil hidden @$s6sample6addTwoyS2i_SitF : $@convention(thin) (Int, Int) -> Int {
// %0 "first"                                     // users: %4, %2
// %1 "second"                                    // users: %5, %3
bb0(%0 : $Int, %1 : $Int):
  debug_value %0, let, name "first", argno 1      // id: %2
  debug_value %1, let, name "second", argno 2     // id: %3
  %4 = struct_extract %0, #Int._value             // user: %7
  %5 = struct_extract %1, #Int._value             // user: %7
  %6 = integer_literal $Builtin.Int1, -1          // user: %7
  %7 = builtin "sadd_with_overflow_Int64"(%4, %5, %6) : $(Builtin.Int64, Builtin.Int1) // users: %9, %8
  %8 = tuple_extract %7, 0                        // user: %11
  %9 = tuple_extract %7, 1                        // user: %10
  cond_fail %9, "arithmetic overflow"             // id: %10
  %11 = struct $Int (%8)                          // user: %12
  return %11                                      // id: %12
} // end sil function '$s6sample6addTwoyS2i_SitF'

Again a lot of stuff is happening but we can see that:

  • in lines 7-10 the parameters sent to the function are being extracted and stored in local variables (SSA – Static Single Assignment) for later use
  • in line 12 those two initial parameters are being added together by executing a built-in function called sadd_with_overflow_Int64 (which is wrapper for LLVM’s llvm.sadd.with.overflow.i64 that adds two numbers and indicates whether the addition causes an overflow)
  • apparently the built-in function sadd_with_overflow_Int64 returns a tuple
  • lines 13 and 14 extract the values from the tuple (0 – first, 1 – second)
           (result, is_overflow)
  • the code checks for the arithmetic overflow by checking the second tuple value

What this gives is is the blueprint for the function that will go on, later on, to be executed.

The interesting fact is the checking for overflow. Apparently the code is “safe” as in – it will not introduce strange behaviour when we add large numbers.

You probably know what opportunity this gives us.

Meme: "Disaster Girl": overflow checks are overrated

Hack the code

Let’s say – we don’t want that overflow condition check. We’re feeling awfully rebellious today and want to get rid of that check. After all:

If there’s a function to check the overflow when adding two numbers surely there has to be one that does not. To add two numbers, adding which you are sure will not cause the overflow, you simply use a different operator:

func addTwoTruncate(_ first: Int, _ second: Int) -> Int {
    return first &+ second
}

Compiling our code and generating SIL gives us:

// addTwoTruncate(_:_:)
// Isolation: unspecified
sil hidden @$s4main14addTwoTruncateyS2i_SitF : $@convention(thin) (Int, Int) -> Int {
[global: ]
// %0 "first"                                     // users: %4, %2
// %1 "second"                                    // users: %5, %3
bb0(%0 : $Int, %1 : $Int):
  debug_value %0 : $Int, let, name "first", argno 1 // id: %2
  debug_value %1 : $Int, let, name "second", argno 2 // id: %3
  %4 = struct_extract %0 : $Int, #Int._value      // user: %7
  %5 = struct_extract %1 : $Int, #Int._value      // user: %7
  %6 = integer_literal $Builtin.Int1, 0           // user: %7
  %7 = builtin "sadd_with_overflow_Int64"(%4 : $Builtin.Int64, %5 : $Builtin.Int64, %6 : $Builtin.Int1) : $(Builtin.Int64, Builtin.Int1) // user: %8
  %8 = tuple_extract %7 : $(Builtin.Int64, Builtin.Int1), 0 // user: %9
  %9 = struct $Int (%8 : $Builtin.Int64)          // user: %10
  return %9 : $Int                                // id: %10
} // end sil function '$s4main14addTwoTruncateyS2i_SitF'

The function looks similar:

  • the same built-in function is used (sadd_with_overflow_Int64)
  • the same tuple is being returned but
  • we’re extracting only the value from the tuple (0 – the first tuple item)
  • there is no condition check on the overflow

Now… why would be want that?

  • if we’re absolutely sure the numbers we’re operating on will not cause range overflow we have just eliminated one condition check
  • one condition check does not cause any real-life performance hit
  • condition checks do cause real-life performance hits when used in a massive loops, gigantic loops, etc.

Measurement

Let’s test that assumption. Consider the following code:

import Foundation

func addTwo(_ first: Int, _ second: Int) -> Int {
    return first + second
}

func addTwoTruncate(_ first: Int, _ second: Int) -> Int {
    return first &+ second
}

func measure(_ closure: @escaping () -> Void) {
    let start: Date = .init()
    closure()
    let end: Date = .init()
    print(end.timeIntervalSince(start))
}

measure {
    for i in 0..<32000*10 {
        addTwo(1, 2)
    }
}

measure {
    for i in 0..<32000*10 {
        addTwoTruncate(1, 2)
    }
}

Here:

  • we have two “add” functions: one with overflow protection, one without
  • a primitive “measure” function
  • two quite large loops

When executing we find the following result (MacBook Pro M1):

   0.050994038581848145
   0.0367199182510376

The “addTwoTruncate” function is slightly less than twice faster than “addTwo”. Success on all fronts.

Meme: How you look at your friends after generating SIL

Conclusion

In conclusion – we used SIL for a silly thing (haha). Real life scenarios rarely need this kind of optimisation but as optimisation scenarios go this is a good one if there is a need for small improvements on a large scale without the need to go multithreaded.

But SIL can show us so much more. SIL is close to what will be executed once the code goes on the CPU. While not useful in a direct way to many of us, SIL can shed a little light on our code and the systems we’re creating. And in the age of AI — knowing is close to a superpower.

Krzysztof Pawłowski is a live-long coder turned “developer”. “Initiated” on his trusty Atari XL. Sold his soul to Apple years later just in time for the iPhone. Does mainly iOS development since and dabbles (poorly) in graphics engine’s coding. Ask him anything. He won’t know… but he’ll try to answer xd


Posted

in

, ,

by

Tags: