Why is SwiftUI's View a PAT?

Why is View a PAT? (protocol with associated type).

Seems it could be implemented like so:

protocol View {
    var body: View? { get }
}

Where the primitive types return nil for body.

Where would that break down?

2 Likes

I hope one or more of the SwiftUI team answers this, because I'd like to know the “real” answers. But here's one answer I'm aware of: using an associated type for body reduces heap allocations.

In your design, a View's body property returns an “existential container”, which can hold any object that conforms to the View protocol. TypeLayout.rst describes the memory layout of existential containers:

Existential Container Layout

Values of protocol type, protocol composition type, or Any type are laid out using existential containers (so-called because these types are "existential types" in type theory).

Opaque Existential Containers

If there is no class constraint on a protocol or protocol composition type, the existential container has to accommodate a value of arbitrary size and alignment. It does this using a fixed-size buffer , which is three pointers in size and pointer-aligned. This either directly contains the value, if its size and alignment are both less than or equal to the fixed-size buffer's, or contains a pointer to a side allocation owned by the existential container. The type of the contained value is identified by its type metadata record, and witness tables for all of the required protocol conformances are included. The layout is as if declared in the following C struct:

struct OpaqueExistentialContainer {
    void *fixedSizeBuffer[3];
    Metadata *type;
    WitnessTable *witnessTables[NUM_WITNESS_TABLES];
};

So an existential container is 40 bytes in size (on a 64-bit platform) of which 24 bytes are available to hold the contained object. If the object doesn't fit into 24 bytes, Swift instead allocates the object on the heap and stores a pointer in the container.

Now, here's a custom view:

import SwiftUI

struct Hello: View {
    var body: some View {
        Text("Hello!")
    }
}

How big is the body of a Hello view?

MemoryLayout<Hello.Body>.size
// 32

The body (a Text) is 32 bytes. In SwiftUI's design (where body returns an associated type), when SwiftUI asks for that body, it allocates those 32 bytes on the stack.

In your design, where body's type is View? and there's no associated type, the body property returns an existential container, which is 40 bytes. SwiftUI allocates those 40 bytes on the stack. But the body getter needs to put a 32-byte object (the Text) into that container, and the container only has room for a 24-byte payload. So the body getter has to allocate 32 bytes on the heap and store the pointer in the container.

Returning an existential container forces heap allocation whenever the object is more than 24 bytes in size. Returning an associated type doesn't force a heap allocation.

11 Likes

AFAIK it’s to keep the type information around, so that it can be used by the renderer to optimise things (like animating things at the right layer, or collapsing views together)

7 Likes

I suppose that with the view hierarchy being regenerated frequently (like, during a drag), there would be a measurable penalty for heap allocation in terms of runtime or energy, even though the data structure is tiny?

Also, why must an existential container always be fixed size? It would seem that it could be arbitrarily sized, at least if stack allocated (similar to alloca). Would that simply complicate a compiler implementation? Or is there something more fundamental?

To work in collection type like Array and Dictionary?

You probably mean that it has to be fixed size in order to fit into a container.

Yes, sure. That's why I said always above and restricted the question to stack allocated values.

To be specific: I'm asking why

let protocol: MyProtocol = someFunction()

must use a fixed-size existential container.

Or, you could have one size for stack allocated existential containers, and another for heap allocated existential containers.

There's something unsatisfying about performance being the answer here.

Back of the envelope, a 5 year old computer can do ~16m heap allocations per second (according to one test I found). So if your UI can be described in, say 500 allocations, that would be .0018 of your frame budget at 60fps. Why bother?

Maybe an app which does the 500 allocations would consume much more energy than otherwise?

@audulus Also one small nit, View is not a generic protocol. Swift does not support generic protocols (yet or ever). A generic protocol would be protocol P<T> { ... }. That said View is a PAT (Protocol with Associated Type).

2 Likes

Fair enough. I'll try to change the terminology above.

1 Like

The calling code here (the block where let protocol: MyProtocol is declared) needs to allocate space for the existential container, before it calls someFunction. It passes a pointer to that allocated, uninitialized space into someFunction as a hidden parameter. The caller doesn't generally know what concrete type someFunction will return. In fact, someFunction doesn't always have to return the same type:

func someFunction() -> CustomStringConvertible {
    return Bool.random() ? true : "Hello"
}

Allocation performance is not the primary concern. Existential boxes can be optimized out in a lot of cases. Making the view body an associated type enables larger systemic optimizations. By raising the static structure of the view hierarchy into the type system, diffing and updating become much easier, since it doesn't normally need to do a graph diff and can do a 1:1 diff of structural properties in the fixed graph. This is also an API usability benefit for the library, because statically-typed view nodes do not need a manually-assigned id like they would in similar libraries like React, because their "id" is implied by their position in the type system. This is important for the robustness of the animation system, which needs a consistent notion of identifiers across frames to be able to correctly interpolate animatable properties.

35 Likes

Don't judge me please. Can't wait for it to be open sourced (wish :crossed_fingers:) to learn from these techniques.

6 Likes

I think you're making an assumption about calling conventions. There's nothing preventing a function from returning variable sized data on a stack.

Thanks. That makes more sense.

Though theoretically possible, Swift does not return existentials unboxed on the stack today. LLVM doesn't support leaving stack space allocated after a return, and retrofitting support for that sounds like a major undertaking from the discussions we've had about adding support for it.

6 Likes

Just curious if anyone knows any more about this… is this a novel technique, is there prior art somewhere? I’d love to read more about this topic, even just some more clarity about what an implementation of the above ideas actually looks like.

I’m sure Apple will describe this more in the fullness of time, but I’m sure curious as heck until then.

2 Likes

I remember reading You Might Not Need The Virtual DOM which might be similar to how SwiftUI works under the hood. From the article:

This simple change to the types is enough to guarantee that only the content of text nodes, attributes and event handlers are allowed to vary with the model. Everything else must be static.

1 Like
Terms of Service

Privacy Policy

Cookie Policy