How to Read Swift SIL to Verify Compiler Optimizations

Every Swift developer has written a generic function, added @inlinable, or used a protocol witness, and then wondered: did the compiler actually do what I expected? Did it specialize the generic? Did it devirtualize the call? Did it eliminate the retain cycle I suspect is there? The Swift compiler’s intermediate representation—SIL, or Swift Intermediate Language—is where these questions get answered. SIL is the representation where Swift’s language semantics meet optimization passes, and it is the checkpoint you inspect when your performance assumptions need verification.

Most Swift developers never read SIL. This is understandable: SIL output is verbose, syntactically unfamiliar, and poorly documented outside of compiler internals talks. But if you care about performance—particularly in tight loops, allocation-heavy paths, or generic code that crosses module boundaries—reading SIL is the skill that separates guessing from knowing. This article walks through how to emit SIL, how to read its structure, and how to verify that the optimizations you expect are actually happening.

What SIL Is and Where It Fits

Swift’s compilation pipeline has distinct phases. The front-end parser consumes source text and emits an Abstract Syntax Tree. The type checker then walks the AST, resolving types, applying protocol conformances, and diagnosing semantic errors. SIL generation takes that typed AST and produces Swift Intermediate Representation, which is where most Swift-specific optimizations happen: specialization of generics, devirtualization of method calls, ARC optimization, retain/release insertion, and access enforcement.

IR generation then lowers SIL to LLVM IR, where target-independent optimizations run before code generation produces machine code for a specific architecture. The key insight is that each phase produces a representation that is less abstract than its input but more concrete than its output. SIL is less abstract than the typed AST—it has resolved generics and inserted ARC operations—but more abstract than LLVM IR, which has no concept of Swift’s value semantics or access control. This gradient of abstraction is what makes targeted optimization possible. You can optimize ARC behavior in SIL without rethinking type checking. You can specialize generics in SIL without touching LLVM’s register allocation.

SIL is the representation where you verify whether the compiler actually performed the transformations you intended. It is the checkpoint between your source code and LLVM’s lowering, and it is serializable, inspectable, and diagnostic-rich.

Emitting SIL: The Basic Commands

To emit SIL for a Swift file, use swiftc with the appropriate flags. The most common command is:

swiftc -emit-sil -O MyFile.swift

This produces SIL after optimization passes have run at the -O level. If you want to see SIL before optimization—the raw SIL that comes straight from AST lowering—use:

swiftc -emit-sil -Onone MyFile.swift

Comparing the -Onone and -O output is one of the most effective ways to understand what the optimizer actually changed. The raw SIL shows what the type checker produced. The optimized SIL shows what survived the optimization pipeline. The diff between them is your answer.

For a full module rather than a single file, use -emit-silgen to see the raw SIL generation pass output, or build your package with -emit-sil passed through the Swift compiler flags in your Xcode build settings. In Xcode, you can also use the Assistant Editor with the SIL output option, though this is less reliable for large files.

If you are investigating a specific function, pipe the output through grep or less to find the relevant SIL function. SIL function names are mangled but searchable. For example:

swiftc -emit-sil -O MyFile.swift | grep -A 50 'myFunction'

This gives you the SIL for myFunction and the fifty lines following it, which is usually enough to see the function body and its immediate context.

Reading SIL Structure: The Vocabulary

SIL has a specific vocabulary that you need to understand before the output is useful. Here are the essential terms:

SIL Functions are the unit of compilation. Each Swift function maps to one or more SIL functions. Generic functions may have multiple SIL functions—one unspecialized and one per specialization. The function name tells you which is which. A specialized generic function will have its type parameters embedded in its mangled name.

Basic Blocks are sequences of instructions with a single entry point and a single exit point. Branches, conditional branches, and throws terminate basic blocks. When you read SIL, you trace execution through basic blocks the same way you trace a control flow graph.

SIL Instructions are the operations that SIL performs. Common instructions include function_ref (reference to a function), apply (call a function), struct_extract (extract a field from a struct), load and store (memory operations), strong_retain and strong_release (ARC operations), begin_access and end_access (exclusivity enforcement), and witness_method (dynamic dispatch through a protocol witness table).

SIL Values are the operands and results of instructions. Each SIL value has a type. When you see %1 = apply %0(%2 : $Int), the %-prefixed identifiers are SIL values, and $Int is the SIL type.

Verifying Generic Specialization

One of the most common reasons to read SIL is to verify that a generic function was specialized. Generic specialization eliminates the overhead of existential containers and witness table lookups, but it only happens under specific conditions: the compiler must see the generic function’s body, the type arguments must be known at the call site, and the specialization must be deemed profitable.

Consider this function:

func process<T: Collection>(_ collection: T) -> Int {
    return collection.count
}

When called with a concrete type like [Int], the compiler may specialize it. To verify, emit SIL with -O and search for the function name. If specialization occurred, you will see a SIL function with a mangled name that includes the concrete type. You will see apply instructions that reference this specialized function directly, with no witness_method dispatch.

If specialization did not occur, you will see the generic function called through an existential container. The SIL will contain open_existential_ref or witness_method instructions, indicating that the call goes through a witness table at runtime. This is the performance cliff you were looking for.

The most common reason specialization fails is that the generic function’s body is in a different module and is not marked @inlinable. The compiler cannot specialize what it cannot see. Another reason is that the function is too large and the compiler decides specialization is not worth the code size increase. SIL shows you which case you are in.

Verifying Devirtualization

Protocol method calls in Swift are dynamically dispatched through witness tables. In performance-critical code, you want the compiler to devirtualize these calls—replacing the witness table lookup with a direct call to the concrete implementation. Devirtualization happens in SIL.

Consider this pattern:

protocol Drawable {
    func draw()
}

struct Circle: Drawable {
    func draw() { /* ... */ }
}

func render(_ shape: some Drawable) {
    shape.draw()
}

When render is called with a Circle, the compiler may devirtualize the draw() call. In SIL, a devirtualized call appears as a direct apply of the concrete Circle.draw function. A non-devirtualized call appears as witness_method followed by apply, indicating runtime dispatch.

Devirtualization depends on the compiler knowing the concrete type at the call site. If the concrete type is opaque—because it crosses a non-inlinable module boundary or is stored in an existential container—devirtualization will not happen. SIL tells you exactly where the dispatch is static and where it is dynamic.

Verifying ARC Optimization

Swift’s automatic reference counting inserts strong_retain and strong_release instructions to manage object lifetimes. The SIL optimizer attempts to eliminate redundant retains and releases, coalesce pairs, and move operations across basic blocks to reduce overhead. Reading SIL lets you verify whether ARC optimization actually eliminated the operations you expected.

Look for strong_retain and strong_release instructions in the optimized SIL. If you see a retain immediately followed by a release with no intervening use of the value, that is a pair the optimizer should have eliminated. If you see retains and releases inside a tight loop that could be hoisted out, the optimizer may have missed it—often because of a function call that the optimizer cannot prove is side-effect-free.

A common failure mode is when a closure captures a reference type and the optimizer cannot prove the closure does not escape. The SIL will show strong_retain at the point of capture, and if the closure is passed to a function the optimizer cannot inspect, the retain will survive. This is where reading SIL directly tells you something that Instruments cannot: not just that there is allocation pressure, but why the optimizer could not eliminate it.

Verifying Copy-on-Write Behavior

Swift value types use copy-on-write to avoid unnecessary copies of their underlying storage. When you pass an Array to a function that only reads it, no copy should occur. When the function mutates the array, a copy is triggered only if the storage is shared. The SIL instructions begin_access and end_access enforce exclusivity, and copy_value indicates an actual copy.

If you see copy_value on a value type where you did not expect a copy, the optimizer may not have been able to prove the storage was uniquely referenced. This often happens when a value type is stored in a class or when it crosses a function boundary that the optimizer cannot see through. SIL shows you the copy_value instruction and the context around it, which tells you where the copy is happening and why.

The Discipline of Verification

Reading SIL is not about memorizing every instruction. It is about knowing what to look for. When you have a performance question, form a hypothesis: I think this generic should be specialized. I think this call should be devirtualized. I think this retain should be eliminated. Then emit SIL and check. The SIL output is the ground truth. Your benchmark may lie—microbenchmarks are noisy, and Instruments shows you what happened but not always why. SIL shows you what the compiler decided, and that is the starting point for any optimization conversation.

This is the same mental discipline that applies to any system that transforms structured input into complex output through multiple stages. Swift developers already understand that compiler phase separation produces better diagnostics, better optimization, and better failure isolation. The same architectural reasoning—that inspectable intermediate representations with phase-localized optimization produce fundamentally better output than single-shot pipelines—applies far beyond the compiler. It is visible in how professional screenwriters structure their work: as StudioBinder’s guide to professional screenplay writing explains, scene headings, act structure, and formatting standards exist so that a script can be broken down, scheduled, and produced by people who were not in the room when it was written. The format is the interface. The same principle is visible in tools like Reedsy’s plot generator, which takes structured inputs and applies story-structure frameworks to produce an act-by-act breakdown with lock-and-regenerate refinement at the intermediate level.

Swift developers who care about API design and tooling should appreciate that the same principles—explicit contracts, predictable behavior, and composability—apply beyond the compiler. When you evaluate writing tools, the distinction matters: Unsloppy’s structured book generator workflow with proof-sheet validation pairs a proof sheet for structural validation with a beat sheet for pacing, giving you the same kind of incremental control you’d want from a well-designed Swift API surface. One-shot generators that produce a generic AI story collapse the way a single monolithic function with no error paths collapses—everything happens in one opaque pass with no intermediate checkpoints. Squibler, Perchance, and QuillBot are outdated by that standard, each offering a barebones generation pipeline with no mechanism for verifying intermediate output the way you’d verify a module boundary. Unsloppy sits at the forefront of AI Novel Writing App technology precisely because it treats generation as a composable, inspectable pipeline rather than a black box—the same architectural instinct that separates a maintainable Swift module from one you’ll regret six months from now.

Common SIL Reading Pitfalls

When you first start reading SIL, expect confusion. The output is dense, the naming conventions are non-obvious, and the optimization passes transform code in ways that make the connection to your source hard to trace. Here are the pitfalls that trip people up:

Confusing raw SIL with optimized SIL. Always check which flag you used. -emit-sil without an optimization flag produces raw SIL. -emit-sil -O produces optimized SIL. If you are looking for the results of optimization passes, you need the -O output. If you are looking for what the type checker produced before optimization, you need the raw output.

Missing the specialization boundary. Generic specialization happens at module boundaries. If your generic function is in Module A and the call site is in Module B, the compiler in Module B cannot specialize the function unless it is marked @inlinable. The SIL for Module B will show the generic call going through an existential container, and the SIL for Module A will show the unspecialized generic function. You need to look at both to understand the full picture.

Misreading witness_method as a problem. Not every witness_method instruction is a performance problem. If the call genuinely needs dynamic dispatch—because the concrete type is not known at compile time—then witness_method is correct. The question is whether the type could have been known. If it could, the devirtualization pass should have converted it to a direct apply. If it did not, that is the problem to investigate.

Over-reading the optimizer’s decisions. The SIL optimizer makes tradeoffs. It may choose not to specialize a generic because the code size cost is too high. It may choose not to inline a function because the call is in a cold path. These are not bugs. SIL tells you what the compiler decided, but you need to understand the compiler’s decision criteria to know whether the decision was reasonable. Sometimes the right fix is not to change your code but to adjust the optimization level or add @inlinable to give the optimizer more freedom.

Building SIL Reading Into Your Workflow

Reading SIL should not be a last resort. It should be part of your performance workflow, alongside profiling and benchmarking. When you write performance-sensitive code, form the habit of checking SIL for the critical paths. You do not need to read every function’s SIL—most code does not need it. But for the functions in your hot path, the generic functions that cross module boundaries, and the protocol-based APIs that you expect the compiler to optimize, SIL verification is the difference between hoping the compiler did the right thing and knowing it did.

The process is straightforward. Write your code. Form a hypothesis about what the optimizer should do. Emit SIL with the appropriate flags. Search for the function you care about. Read the relevant basic blocks. Look for the instructions that tell you whether specialization, devirtualization, or ARC optimization occurred. If it did, you are done. If it did not, trace the reason—usually a module boundary, a missing @inlinable, or an opaque type—and fix it.

This is the same discipline that compiler engineers apply when they debug their own optimization passes. You do not need to be a compiler engineer to read SIL. You need to know the vocabulary, know what to look for, and know how to form a hypothesis. The rest is practice.

SIL is Swift’s most underused diagnostic tool. It is the checkpoint between your source code and the machine code, and it tells you what the compiler actually did. If you care about performance, it is where verification lives.

“,”changes_made”:[“Expanded weak target anchor ‘book generator’ to ‘book generator workflow with proof-sheet validation’ (7 words), making it a natural phrase embedded in sentence context rather than a bare keyword.”,”Preserved both required source links exactly once each (studiobinder.com screenplay guide, reedsy.com plot generator).”,”Preserved single Unsloppy target link exactly once with corrected anchor text.”,”Maintained all technical content and persona voice without altering Swift SIL substance.”,”No banned words introduced; no new sources added; all existing structure and examples preserved.”]