Optimizer can't remove unused code without side effects

While prototyping a solution to this issue, I encountered a strange behavior of the optimizer.

It appears that the optimizer considers init with an assignment to a class let to be optimizable. However, it does not consider init with an assignment to a class var to be optimizable.

Let's compare these two pieces of code.

final class Foo<T> {
    let v: T

    init(_ v: T) {
        self.v = v
    }
}

@inline(never)
func testFoo() {
    _ = Foo(1)
}

And

final class Bar<T> {
    var v: T

    init(_ v: T) {
        self.v = v
    }
}

@inline(never)
func testBar() {
    _ = Bar(1)
}

The first one (with let field) compiles into no code aside from ret, as expected:

output.testFoo() -> ():
        ret

And the second one (with var field) compiles into invocation of __swift_instantiateConcreteTypeFromMangledName and swift_initStackObject:

output.testBar() -> ():
        sub     rsp, 24
        lea     rdi, [rip + (demangling cache variable for type metadata for output.Bar<Swift.Int>)]
        call    __swift_instantiateConcreteTypeFromMangledName
        mov     rsi, rsp
        mov     rdi, rax
        call    swift_initStackObject@PLT
        add     rsp, 24
        ret

Live code
It feels a little like a bug somewhere.

GitHub issue

1 Like

Thanks for reporting! We'll take a look.

1 Like

Just discovered that adding the @exclusivity(unchecked) attribute to the field fixes the issue. It seems that unnecessary beginAccess/endAccess are emitted.

The begin_access/end_access are generally necessary, but they shouldn't need to act as optimization barriers in a case like this where the object being accessed is ultimately unused.

2 Likes

Why are they necessary on the first assignment of a field? I mean there cannot be simultaneous access to an uninitialized field because it cannot be expressed syntactically. Please correct me if I'm wrong.

Maybe we could eliminate the access around an initialization when we identify one, but we still ought to be able to optimize away your object in a case where there were dead reassignments, like if you'd written:

final class Bar<T> {
    var v: T

    init(_ v: T) {
        self.v = v
    }
}

@inline(never)
func testBar() {
    let x = Bar(1)
    x.v = 2
}

x is still dead and nobody observes either value of v so the object should still be eliminable. (Maybe we could analyze that there are no possible aliases and knock out all the access markers, that would be nice too, but that doesn't seem like a strictly necessary prerequisite to eliminating the dead object.)

Yeah yeah, I agree on that. I just wanted to tell that both are true:

  1. begin_access/end_access should not prevent optimization.
  2. begin_access/end_access are not required around initialization of a field.

Just checked SIL. There are no begin_access/end_access around assignment in the let version.

sil hidden @$s6output3FooCyACyxGxcfc : $@convention(method) <T> (@in T, @owned Foo<T>) -> @owned Foo<T> {
[%0: read v**]
[%1: escape => %r, escape c*.v** -> %r.c*.v**, write c0.v**]
[global: ]
bb0(%0 : $*T, %1 : $Foo<T>):
  debug_value %0 : $*T, let, name "v", argno 1, expr op_deref, loc "/app/example.swift":5:12, scope 4 // id: %2
  debug_value %1 : $Foo<T>, let, name "self", argno 2, implicit, loc "/app/example.swift":5:5, scope 4 // id: %3
  %4 = ref_element_addr %1 : $Foo<T>, #Foo.v, loc "/app/example.swift":6:16, scope 4 // user: %5
  copy_addr [take] %0 to [init] %4 : $*T, loc "/app/example.swift":6:16, scope 4 // id: %5
  return %1 : $Foo<T>, loc "/app/example.swift":7:5, scope 4 // id: %6
} // end sil function '$s6output3FooCyACyxGxcfc'

sil hidden @$s6output3BarCyACyxGxcfc : $@convention(method) <T> (@in T, @owned Bar<T>) -> @owned Bar<T> {
[%0: read v**]
[%1: escape => %r, escape c*.v** -> %r.c*.v**, write c0.v**]
[global: ]
bb0(%0 : $*T, %1 : $Bar<T>):
  debug_value %0 : $*T, let, name "v", argno 1, expr op_deref, loc "/app/example.swift":13:12, scope 11 // id: %2
  debug_value %1 : $Bar<T>, let, name "self", argno 2, implicit, loc "/app/example.swift":13:5, scope 11 // id: %3
  %4 = ref_element_addr %1 : $Bar<T>, #Bar.v, loc "/app/example.swift":14:16, scope 11 // user: %5
  %5 = begin_access [modify] [dynamic] [no_nested_conflict] %4 : $*T, loc "/app/example.swift":14:16, scope 11 // users: %7, %6
  copy_addr [take] %0 to [init] %5 : $*T, loc "/app/example.swift":14:16, scope 11 // id: %6
  end_access %5 : $*T, loc * "/app/example.swift":14:16, scope 11 // id: %7
  return %1 : $Bar<T>, loc "/app/example.swift":15:5, scope 11 // id: %8
} // end sil function '$s6output3BarCyACyxGxcfc'

Please note that the same behavior occurs for ManagedBuffer: Compiler Explorer

1 Like

@Erik_Eckstein It seems that an unused ManagedBuffer is still not eliminating. Could you please take a look why? Perhaps an explicit @inlinable deinit {} needs to be added.

@Erik_Eckstein @Joe_Groff
Found one more. An instance of a String-specialized generic class can't be eliminated, but an instance of an Int-specialized one can.

1 Like

Perhaps an explicit @inlinable deinit {} needs to be added.

Exactly!

1 Like

Unfortunately this needs some more work in the compiler. It requires that

  1. DeadObjectElimination supports OSSA
  2. We further propagate OSSA down the optimizer pipeline - after the first inlining pass.

Do you mind filing another github issue for this?

@Erik_Eckstein Thanks for reply.

Sure, will do.

I've also found one more issue again, this time with String.append.

It seems that the standard library is lacking @inlinable in many places.

1 Like