Implicit switch return causes excessive stack allocation in debug build

Hello, guys.

Long story short here are two pieces of code.

First:

func test<T: Decodable>(decoder: Decoder) throws -> T {
    switch T.self {
    default:
        try T(from: decoder)
    }
}

Which by running swiftc -Onone -emit-sil overflow.swift converts to:

Summary

sil_stage canonical

import Builtin
import Swift
import SwiftShims

func test(decoder: any Decoder) throws -> T where T : Decodable

// main
// Isolation: unspecified
sil @mainmain@convention: $@convention(c) (Int32, UnsafeMutablePointer) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer):
%2 = integer_literal $Builtin.Int32, 0 // user: %3
%3 = struct $Int32 (%2) // user: %4
return %3 // id: %4
} // end sil function 'main'

// test(decoder:)
// Isolation: unspecified
sil hidden @$s8overflow4test7decoderxs7Decoder_@convention_tKSeRzlF@in_guaranteed: $@convention(thi@out) (@errorin_guaranteed any Decoder) -> (@out T, @error any Error) {
// %0 "$return_value" // user: %12
// %1 "decoder" // users: %7, %2
bb0(%0 : $*T, %1 : $*any Decoder):
debug_value %1, let, name "decoder", argno 1, expr op_deref // id: %2
debug_value undef : $any Error, var, name "$error", argno 2 // id: %3
%4 = alloc_stack $T @thick // users: %13, %12, %9, %18
%5 = metatype $@thick T.Type // user: %9
%6 = alloc_stack $any Decoder // users: %11, %9, %17, %7
copy_addr %1 to [init] %6 // id: %7
%8 = witness_method $T, decodable.init!allocator @convention (Self.Type) -> (any Decoder) throws -> Self : $@convention(@initness_method:@thickDecodable) <τ_0_0@outwhere τ_@error_0 : Decodable> (@in any Decoder, @thick τ_0_0.Type)@convention-> (@out τ_0_0, @error any Error) // user: %9
try_apply %8(%4@in %6, %5) : $@c@thicknvention(witness_@outethod: D@errorcodable) <τ_0_0 where τ_0_0 : Decodable> (@in any Decoder, @thick τ_0_0.Type) -> (@out τ_0_0, @error any Error), normal bb1, error bb2 // id: %9

bb1(%10 : $()): // Preds: bb0
dealloc_stack %6 // id: %11
copy_addr [take] %4 to [init] %0 // id: %12
dealloc_stack %4 // id: %13
%14 = tuple () // user: %15
return %14 // id: %15

// %16 // user: %19
bb2(%16 : $any Error): // Preds: bb0
dealloc_stack %6 // id: %17
dealloc_stack %4 // id: %18
throw %16 // id: %19
} // end sil function '$s8overflow4test7decoderxs7Decoder_p_tKSeRzlF'

// Mappings from '#fileID' to '#filePath':
// 'overflow/overflow.swift' => 'overflow.swift'

Second:

func test<T: Decodable>(decoder: Decoder) throws -> T {
    switch T.self {
    default:
        return try T(from: decoder)
    }
}

Which by running swiftc -Onone -emit-sil no_overflow.swift converts to:

Summary

sil_stage canonical

import Builtin
import Swift
import SwiftShims

fun@main tes@convention(decoder: any Decoder) throws -> T where T : Decodable

// main
// Isolation: unspecified
sil @main : $@convention(c) (Int32, UnsafeMutablePointer) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer):
%2 = integer_literal $Builtin.Int32, 0 // user: %3
%3 = struct $Int32 (%2) // user: %4
return %3 // id: %4
} // end sil function 'ma@conventionn'

// te@in_guaranteedt(decoder:)
// Iso@outatio@error: unspecified
sil hidden @$s11no_overflow4test7decoderxs7Decoder_p_tKSeRzlF : $@convention(thin) (@in_guaranteed any Decoder) -> (@out T, @error any Error) {
// %0 "$return_value" // user: %8
// %1 "decoder" // users: %6, %2
bb0(%0 : $*T, %1 : $*any Decoder):
debug_valu@thick %1, let, name "decoder", argno 1, expr op_deref // id: %2
debug_value undef : $any Error, var, name "$error", argno 2 // id: %3
%4 = metatype $@thick T.Type // user: %8
%5 = alloc_stack $any Decoder // users: %10, %8, %14, %6@conventioncopy_addr %1 to [init] %5 // id: %6
%7 @in witness_metho@thick $T, decodable.init!alloc@outtor : (@errorelf.Type) -> (any Decoder) throws -> Self : $@conven@conventionion(witness_method: Decodable) <τ_0_0 where τ_0_0 : Decodable@in (@in any Deco@thicker, @thick τ_0_0.@outype) -> (@out τ_0_0, @error any Error) // user: %8
try_apply %7(%0, %5, %4) : $@convention(witness_method: Decodable) <τ_0_0 where τ_0_0 : Decodable> (@in any Decoder, @thick τ_0_0.Type) -> (@out τ_0_0, @error any Error), normal bb1, error bb2 // id: %8

bb1(%9 : $()): // Preds: bb0
dealloc_stack %5 // id: %10
%11 = tuple () // user: %12
return %11 // id: %12

// %13 // user: %15
bb2(%13 : $any Error): // Preds: bb0
dealloc_stack %5 // id: %14
throw %13 // id: %15
} // end sil function '$s11no_overflow4test7decoderxs7Decoder_p_tKSeRzlF'

// Mappings from '#fileID' to '#filePath':
// 'no_overflow/no_overflow.swift' => 'no_overflow.swift'

swiftc --version

swift-driver version: 1.127.15 Apple Swift version 6.2.4 (swiftlang-6.2.4.1.4 clang-1700.6.4.2)

Snippets differ only in explicit vs. explicit return.

Generated SIL in first example contains (as opposed to second example) line such as:

%4 = alloc_stack $T

which means a stack allocation for return value (if I understand correctly).

It’s optimised away in release builds.

Just wondering if this is a bug or correct behavior?

Was quite interesting issue for debugging :slight_smile:: return was removed by formatter (didn’t know that) and decoding crashed with generic bad access error. As you might have guessed the crash was caused by stack overflow.

3 Likes

Moving this over to the Development > Compiler category as it’s about compiler implementation details :)

1 Like

Meant implicit vs. explicit (typo)

Out of curiosity, was this crash due to decoding either a deeply-nested hierarchy and/or value types of a particularly large size?

It looks like the relevant code usually emits a temporary allocation for storing the results of the single value expression, and it seems in some cases those allocations aren't eliminated without additional optimization passes that are disabled at -Onone. I'm not sure I would call this a "bug" because the generated code seems sound, but "a missed optimization opportunity" is maybe a more accurate characterization.

In local testing, this change seemed to resolve the specific problem by directly initializing the result value when possible. However, I'm not very familiar with the relevant code that's involved, so it's possible it's too simplistic of an approach.

2 Likes

I’d say it was more about nested properties and a lot of calls to this method as a consequence.