swiftlang / swift

The Swift Programming Language
https://swift.org
Apache License 2.0
67.31k stars 10.34k forks source link

Conforming to `Codable` adds an unexpected increase to the binary size #60287

Open liamnichols opened 2 years ago

liamnichols commented 2 years ago

Describe the bug

When making a type conform to the Codable protocol (and leveraging the compiler synthesised code gen), it brings with it a significant (and I guess unexpected, at least to me) increase to the size of the binary.

In the most simplified example:

Input ```swift import Foundation struct Foo { let bar: String } ``` ```swift import Foundation struct Foo: Codable { let bar: String } ```
Command ```bash $ swiftc -wmo File.swift && stat -f%z File ```
Output (Bytes) > 35345 > 62545

That's over 1.7x increase in size that I would never have anticipated from just conforming to a protocol.

Steps To Reproduce

Steps to reproduce the behavior:

  1. Download macOS.playground.zip
    • This Playground benchmarks the complication of a Swift file that contains 300 structs created in different configurations.
  2. Open the Playground in Xcode 13.4.1 (or the 14 betas)
  3. Run the playground and observe the output that looks similar to the following:
    No Conformance     : 556 kB (569,466 bytes)
    Decodable (Auto)   : 3,501 kB (3,585,354 bytes)
    Decodable (Manual) : 3,018 kB (3,090,618 bytes)
    Nested Enum        : 1,744 kB (1,785,498 bytes)
    StringCodingKey    : 929 kB (950,874 bytes)

Expected behavior

The Decodable (Auto) benchmark should result in a size that is a lot closer to No Conformance.

Environment (please fill out the following information)

Additional context

I'm coming from CreateAPI/CreateAPI#57, which is a tool for generating entities from OpenAPI schema documents (such as the App Store Connect API). In some cases, the generated code ends up including a lot of Codable conforming types and it became apparent that something was bloating the binary size when compiling this code.

After some digging, I tracked a significant part of the issue back to the presence of CodingKey enums in every type and I was surprised to find that when we replace all n enums with a single StringCodingKey type, our generated code ends up taking significantly less space in the binary.

In CreateAPI, we can make use of this optimisation by using StringCodingKey, but what stuck out to me was that the compiler generated Codable implementations are also bloated due to the creation of multiple CodingKey enums. While I get the value of using CodingKey enums for type safety in manually written implementations, it feels to me that the compiler generated code could probably avoid this?

I'm happy to provide more details if required, and I'm also keen to learn more about why enums have this kind of impact if anybody is able to provide more info. Thanks!

tbkka commented 2 years ago

CC: @jckarter @aschwaighofer @drexin

vakhidbetrakhmadov commented 2 weeks ago

Hi,

I was experimenting with the approach that uses manual conformances with single StringCodingKey struct as a replacement to Swift generated conformances of Codable|Decodable|Encodable protocols on the iOS project that i work on, hoping to see the app size reduction similar in magnitude to what's been reported in this thread (Decodable (Auto) - StringCodingKey) and on CreateAPI thread (see), but unfortunately the difference that i observed and consequently the app size reduction was much much smaller.

I spent some time trying to figure out why, supposing that maybe there was something i was doing wrong. What i actually found out is that when binaries are properly stripped the difference is indeed much much smaller.

To demonstrate this i put together an iOS project from Xcode template, added to it 300 structs that conform to Codable, using manual conformances with single StringCodingKey struct and using Swift generated conformances, toggleable by compilation condition, and measured app's binary size when the project is compiled with default Release configuration VS when the project is compiles with default Release configuration + build settings for stripping.

https://github.com/vakhidbetrakhmadov/Codable/tree/main

These are the results that i got:

Test case App's Binary Size
Swift generated conformances AND default Release configuration
( make build__with_default_codable )
3,414,816 bytes (3.4 MB on disk)
Manual conformances with single StringCodingKey struct AND default Release configuration
( make build__with_string_coding_key_codable )
855,056 bytes (856 KB on disk)
Swift generated conformances AND default Release configuration + build settings for stripping
( make build__with_default_codable__and_size_optimization_build_settings )
613,472 bytes (614 KB on disk)
Manual conformances with single StringCodingKey struct AND default Release configuration + build settings for stripping
( make build__with_string_coding_key_codable__and_size_optimization_build_settings )
260,688 bytes (262 KB on disk)
liamnichols commented 2 days ago

Very interesting, thanks for sharing this @vakhidbetrakhmadov! So the trick is the non-default build settings that your project uses:

SWIFT_OPTIMIZATION_LEVEL = -Osize
DEPLOYMENT_POSTPROCESSING = YES
STRIP_INSTALLED_PRODUCT = YES
STRIPFLAGS = -rSTx
OTHER_SWIFT_FLAGS = -Xfrontend -internalize-at-link
OTHER_LDFLAGS = -Xlinker -exported_symbols_list -Xlinker /dev/null

Do you happen to know much about which settings are the most beneficial here? Or is it a combination of everything?

I'll need to dig into the output binaries a bit further to better understand what exactly is being stripped. It's great that there is a workaround, but I'm not quite sure of the tradeoffs from these settings so would love to learn more 🙇