Removing unused virtual functions and protocol witness conformance calls

We see that unused functions metadata is still a part of the overall binary post applying full swift lto.
The same also applies to unused protocol witness records, which we would expect not to exist since those functions are totally unused and can be stripped.

I believe this would need some deeper virtual method elimination and witness method elimination, and I would appreciate it if someone could guide how to go about removing these entries, any compiler flags that can help us here? If there are flags that can help achieve this deeper stripping, how stable is using those flags for production, any known issues/edge-cases?

Repro 1: Dynamic dispatch - unused virtual function entries

Foo.swift

public class A {
public func unusedFunc() {}
public func usedFunc() {}
public init() {}
}


public class B: A {
public override func usedFunc() {}
}


public class C: B {
public override func usedFunc() {}
}

main.swift

import Foo
let a: A = C()
a.usedFunc()

Execute swiftc

swiftc Foo.swift -emit-library -static -emit-module -Xfrontend -internalize-at-link -lto=llvm-full
swiftc main.swift -I . -L . -lFoo -Xfrontend -internalize-at-link  -lto=llvm-full

nm main | swift demangle

Output:

0000000100003cfc t **Foo.A.unusedFunc() -> ()**
0000000100003d00 t Foo.A.usedFunc() -> ()
0000000100003d04 t Foo.A.__allocating_init() -> Foo.A
0000000100003f74 s reflection metadata field descriptor Foo.A
0000000100003ce0 t type metadata accessor for Foo.A
00000001000081d8 d full type metadata for Foo.A
00000001000081b0 d metaclass for Foo.A
0000000100003e50 s nominal type descriptor for Foo.A
00000001000081e8 d type metadata for Foo.A
0000000100003d14 t Foo.A.__deallocating_deinit
0000000100003d40 t Foo.B.usedFunc() -> ()
0000000100003d44 t Foo.B.__allocating_init() -> Foo.B
0000000100003f84 s reflection metadata field descriptor Foo.B
0000000100003d24 t type metadata accessor for Foo.B
0000000100008278 d full type metadata for Foo.B
0000000100008250 d metaclass for Foo.B
0000000100003ea0 s nominal type descriptor for Foo.B
0000000100008288 d type metadata for Foo.B
0000000100003d7c t Foo.B.__deallocating_deinit
0000000100003da8 t Foo.C.usedFunc() -> ()
0000000100003dac t Foo.C.__allocating_init() -> Foo.C
0000000100003f94 s reflection metadata field descriptor Foo.C
0000000100003d8c t type metadata accessor for Foo.C
0000000100008318 d full type metadata for Foo.C
00000001000082f0 d metaclass for Foo.C
0000000100003eec s nominal type descriptor for Foo.C
0000000100008328 d type metadata for Foo.C
0000000100003df0 t Foo.C.__deallocating_deinit
0000000100003e40 s module descriptor Foo
0000000100008390 b main.a : Foo.A
                 U value witness table for Builtin.NativeObject
                 U _OBJC_CLASS_$_Swift._SwiftObject
                 U _OBJC_METACLASS_$_Swift._SwiftObject
0000000100008048 s __DATA_Foo.A
00000001000080d8 s __DATA_Foo.B
0000000100008168 s __DATA_Foo.C
0000000100008000 s __METACLASS_DATA_Foo.A
0000000100008090 s __METACLASS_DATA_Foo.B
0000000100008120 s __METACLASS_DATA_Foo.C
0000000100003f34 s ___swift_reflection_version
0000000100000000 T __mh_execute_header
                 U __objc_empty_cache
0000000100003c84 T _main
                 U _objc_opt_self
                 U _swift_allocObject
                 U _swift_deallocClassInstance
                 U _swift_release
                 U _swift_retain
0000000100003f62 s _symbolic _____ 3Foo1AC
0000000100003f68 s _symbolic _____ 3Foo1BC
0000000100003f6e s _symbolic _____ 3Foo1CC

Notice: Foo.A.unusedFunc() -> () is present, which can be stripped.

Repro 2: unused protocol witness conformance records entries

Foo.swift

public protocol TheProtocol {
    func unusedFunc()
    func usedFunc()
}

public struct A: TheProtocol {
    public func unusedFunc() {}
    public func usedFunc() {}
    public init() {}
}

main.swift

import Foo

let a: TheProtocol = A()
a.usedFunc()

swiftc Foo.swift -emit-library -static -emit-module -Xfrontend -internalize-at-link -lto=llvm-full
swiftc main.swift -I . -L . -lFoo -Xfrontend -internalize-at-link  -lto=llvm-full

nm main | swift demangle

Output

0000000100003f1c s protocol descriptor for Foo.TheProtocol
0000000100003f8c s reflection metadata field descriptor Foo.TheProtocol
0000000100003ed8 t protocol witness for Foo.TheProtocol.unusedFunc() -> () in conformance Foo.A : Foo.TheProtocol in Foo

0000000100003edc t protocol witness for Foo.TheProtocol.usedFunc() -> () in conformance Foo.A : Foo.TheProtocol in Foo
0000000100003ef0 s protocol conformance descriptor for Foo.A : Foo.TheProtocol in Foo
0000000100004000 s protocol witness table for Foo.A : Foo.TheProtocol in Foo
0000000100003f9c s reflection metadata field descriptor Foo.A
0000000100003ee0 t type metadata accessor for Foo.A
0000000100004018 s full type metadata for Foo.A
0000000100003f48 s nominal type descriptor for Foo.A
0000000100004020 s type metadata for Foo.A
0000000100003f04 s module descriptor Foo
0000000100008000 b main.a : Foo.TheProtocol
                 U value witness table for ()
0000000100003f64 s ___swift_reflection_version
0000000100000000 T __mh_execute_header
0000000100003eb4 T _main
0000000100003f70 s _symbolic Foo.TheProtocol
0000000100003f86 s _symbolic _____ 3Foo1AV

Notice the presence of:
protocol witness for Foo.TheProtocol.unusedFunc() -> () in conformance Foo.A : Foo.TheProtocol in Foo

5 Likes

I suspect it has nothing to do with virtual methods. See example below. It's a simplified version of your example 1, without using virtual method.

$ cat Foo.swift
public class A {
    public func unusedFunc() {}
    public func usedFunc() {}
    public init() {}
}

$ cat main.swift 
import Foo
let a: A = A()
a.usedFunc()

$ swiftc Foo.swift -emit-library -static -emit-module -Xfrontend -internalize-at-link -lto=llvm-full

$ swiftc main.swift -I . -L . -lFoo -Xfrontend -internalize-at-link  -lto=llvm-full

$ nm main | swift demangle | grep Foo.A.unusedFunc
0000000100003eb0 t Foo.A.unusedFunc() -> ()

Foo is compiled as a library, so it has to include all methods. The question is whether the main binary should strip the unused function. That seems to mean modify the static library included in the binary. Do other languages do this?

EDIT: Or do you mean just symbols? You can strip it using strip(1) command, but that applies to all symbols, not just unusedFunc().

Yes, we want unused symbols to be stripped from the main executable. I was searching swift github related to vfe, wme and came across this PR - Add a -experimental-hermetic-seal-at-link flag that triggers aggressive LTO-based dead-stripping (VFE, WME, conditional runtime records, internalization) by kubamracek · Pull Request #39793 · apple/swift · GitHub.

When I add -experimental-hermetic-seal-at-link, the symbols then get stripped off as expected i.e. unusedFunc protocol witness + the vtable ones. -experimental-hermetic-seal-at-link implicitly applies the following:

  1. -enable-llvm-vfe
  2. -enable-llvm-wme
  3. -conditional-runtime-records
  4. -internalize-at-link

I am glad to know that this works, so that means the foundation layer Swift support to strip this unused metadata is already there.

Now the question is, given the experimental nature of these flags, are there any known issues in applying -experimental-hermetic-seal-at-link for production code?

3 Likes

In our internal testing, we came across this validation asset issue while applying swift lto(either thin or full),

The __swift5_entry section is missing for extension bundle Payload/App.app/PlugIns/WidgetExtension.appex, which prevents the extension from running.

Is there a way where we can prevent certain sections (__swift5_entry section) in the binary from being stripped off?

Specifically, the compiler has to include all public methods because it doesn’t know who the users are outside of a module.

If you know that no one is using a method outside of the module, mark it as internal rather than public and it should get eliminated if there are no internal users either, especially if you’re using whole-module optimization.

2 Likes

Thanks for the link to the PR (I didn't know about LTO). BTW, my understanding it it removes dead code, not just the symbols.

Just FYI. I didn't reproduce the issue in my simple experiment. My environment: macOS on MBP (Intel CPU). Or maybe it's specific to widget extension?

$ swiftc Foo.swift -emit-library -static -emit-module -Xfrontend -internalize-at-link -lto=llvm-full -experimental-hermetic-seal-at-link

$ swiftc main.swift -I . -L . -lFoo -Xfrontend -internalize-at-link  -lto=llvm-full

$ nm main | swift demangle | grep unusedFunc

$ objdump -h main | grep __swift5_entry
  3 __swift5_entry   00000008 0000000100003f24 DATA

I'm glad to see interest in these techniques (VFE, WME, internalization, LTO, hermetic seal), but as the name of the compiler flag (-experimental-hermetic-seal-at-link) suggests, it absolutely is experimental at this point. The biggest known issue is missing support for Linux / ELF binaries, but I'm sure there is plenty of not-yet-discovered problems especially if you try to apply this to iOS application code where you need ObjC interop to be enabled. I have almost exclusively tested the techniques without ObjC interop.

5 Likes

Thanks @kubamracek.
Yeah, it's promising and would surely help large iOS binaries. Do you have a GitHub issue where the known issues/roadmap is being tracked - would be great to add our use cases to this feature stress test suite as this eventually would be helping a lot in our binary size savings. (once it's out of experimental stages). would also help to understand where else the community can help and what is planned on your end.

1 Like

Hey @kubamracek, -experimental-hermetic-seal-at-link, as name suggests is experimental.
do all of underlying ones are experimental as well?

  1. -enable-llvm-vfe
  2. -enable-llvm-wme
  3. -conditional-runtime-records
  4. -internalize-at-link

I am specifically interested in -internalize-at-link. We have a big swift package/static lib target of generated proto code with all classes public. Linking it statically doesn't strip unused code as all symbols in the package are public. -internalize-at-link for this case allows linker to strip a lot more public symbols.
What are possible side effects of -internalize-at-link?