I’m the maintainer of a library that contains 7000+ static let properties. The binary size of the package is quite large, which is understandable. but what i don’t quite get is why when i switch to make them computed properties, the binary size of the package drops by ~50%.
I expected the change to only affect the memory usage or performance, but not the binary size.
Does someone have an idea of what is happening under the hood?
static let properties store their values as static data in the binary, whereas computed properties store how to calculate their values at runtime in the binary.
Due to many Swift optimizations (and optimization boundaries), performance could vary using one technique over the other. You should benchmark/profile major changes like this to make sure it is acceptable (in terms of binary size, runtime performance, usability, maintainability, etc). You could use package traits to support both at the same time allowing the developer to choose, but that would require a minimum of Swift 6.1.
Stripping the binary of symbols makes a huge difference in the binary size if you weren't doing it before (not mentioned in your post).
Regardless, I built your library on my 2019 Intel iMac from the stable and feature/binary-size branches. I see a ~12.6% drop when built in release mode and stripped. No idea what that would translate to on ARM as I don't have one available.
However, I do see a 42.1% binary size drop when building for debug (stripped and unstripped).
I usually only consider release numbers. A 12% drop in binary size is small, but is it worth it for your project (considering runtime performance metrics)?
Thanks for the reply and your investigation. Is there a resource for stripping symbols from a package? I would like to know the pros and cons, and how to do it.
Stripping is done, at least on Linux (I think it also works on macOS via Xcode command line tools), using the strip command from GNU.
You can also build a package excluding the symbols (remember to swift package clean before executing) using the -Xlinker -s flags (like swift build -c release -Xlinker -s). After doing some research, it looks like stripping the binary exhibits different behavior based on the OS and linkers (I daily drive Linux, so the linker aggressively strips symbols; on macOS it looks to be less aggressive). I used the flag approach in my previous post since your project doesn't have an executable.
Since the values have to be saved in the binary in both approaches, my intuition is computed property approach would have (slightly) larger size than let property approach. How come the actual result is opposite?
This is definitely an area where the compiler can do better (and hopefully work on compile-time evaluation and other stuff needed by embedded will improve the situation).
If you compare the two, with a simple example the version that uses static let stored properties is bigger because it actually contains both versions: it has the stored constant data (which also requires two new accessors to be generated, adding even more code) as well as the version inlined into the getter (because for this simple example, a struct with two fields, it's more efficient to just return those two values directly).
The version with the computed properties sheds all that and just has the computed getter.
The size/shape of the structs involved may/will change things depending on how much the optimizer chooses to inline/outline, but the lack of predictability here is one of my constant frustrations.
Unfortunately, this doesn't explain why it takes so much space. See this concrete example:
Example
extension StaticString {
public static var errorCode: StaticString { "error_code" }
public static var errorDomain: StaticString { "error_domain" }
public static var errorDescription: StaticString { "error_description" }
public static var errorDebugDescription: StaticString { "error_debug_description" }
public static var errorMessage: StaticString { "error_message" }
public static var errorLocalizedMessage: StaticString { "error_localized_message" }
public static var errorType: StaticString { "error_type" }
public static var failureReason: StaticString { "failure_reason" }
public static var errorSource: StaticString { "error_source" }
public static var severity: StaticString { "severity" }
public static var underlyingError: StaticString { "underlying_error" }
public static var exception: StaticString { "exception" }
public static var retryAttemptsLimit: StaticString { "retry_attempts_limit" }
public static var retryCount: StaticString { "retry_count" }
public static var retryDelay: StaticString { "retry_delay" }
public static var hasPermission: StaticString { "has_permission" }
public static var permissionStatus: StaticString { "permission_status" }
public static var isLoggedIn: StaticString { "is_logged_in" }
public static var authenticationStatus: StaticString { "authentication_status" }
public static var authorizationStatus: StaticString { "authorization_status" }
}
Even with computed properties, on releases build, these 20 properties add ~15kb to binary size.
Each literal is about 15 chars length in average.
Does anybody know, why it takes about 750 bytes per property while the string contents itself is 15 bytes? I mean is it necessary / required to generate so much code (according to godbolt) to access something from readonly region of memory?
Is there optimization techniques to reduce binary size?
Using swift.godbolt.org I see a four-instruction function generated for each computed property. Comparing two minimal object files produced with swiftc -c -O -o test.o - I get 240 bytes more for adding a second string; after linking with clang -dynamiclib the difference drops to 170 bytes. Which is still a bunch (at least 40 bytes is the symbol name), but it’s also a far cry away from 750. How did you get that number?