Skip to content

Performance improvement for main-thread NodeRenderSystem #2661

@ChrisBenua

Description

@ChrisBenua

Problem Summary

In large apps, Swift runtime protocol conformance checks can significantly impact startup time and app responsiveness. This issue documents profiling results and proposes concrete fixes to reduce casting overhead in Lottie's NodeRenderSystem.

Background: Swift Protocol Casting Costs

The first conformance check for any given (type, protocol) pair is expensive because the runtime must scan all protocol conformance type descriptors. After a successful cast, Swift caches the protocol witness table for that (type, protocol) pair - but in apps that initialize Lottie animations at startup, repeated casting across many types can still add up to measurable overhead.

Profiling Results

Profiling was performed in our app on iOS Simulator and iPhone 13 (Release build).

Environment MainThreadAnimationLayer.init total Time spent in protocol casts
iOS Simulator ~25ms 17ms (68%)
iPhone 13 ~42ms 31ms (74%)

The majority of this time was spent inside GroupNode.init and initializeNodeTree, as shown below:

Image

After applying proposed fixes all overhead from type-casting is gone

Root Cause

There are two categories of protocol casting happening inside the NodeRenderSystem:

Proposed Fixes

Fix 1: Replace protocol casts with exhaustive type switches in ItemsExtension.swift

Since PathNode and RenderNode are internal protocols with a closed, known set of conforming types , we can replace runtime protocol casting with concrete type switches. This avoids conformance scanning entirely.

private func getPathNode(rootNode: AnimatorNode?) -> PathNode? {
    guard let rootNode else { return nil }
    switch rootNode {
    case let node as EllipseNode:    return node
    case let node as PolygonNode:    return node
    case let node as ShapeNode:      return node
    case let node as StarNode:       return node
    case let node as RectangleNode:  return node
    default:                         return nil
    }
}

private func getRenderNode(rootNode: AnimatorNode?) -> RenderNode? {
    guard let rootNode else { return nil }
    switch rootNode {
    case let node as GradientFillNode:   return node
    case let node as StrokeNode:         return node
    case let node as FillNode:           return node
    case let node as GradientStrokeNode: return node
    default:                             return nil
    }
}

And update initializeNodeTree to use these helpers:

if let pathNode = getPathNode(rootNode: nodeTree.rootNode) {
    nodeTree.paths.append(pathNode.pathOutput)
}
if let renderNode = getRenderNode(rootNode: nodeTree.rootNode) {
    nodeTree.renderContainers.append(ShapeRenderLayer(renderer: renderNode.renderer))
}

Tradeoffs:
This approach violates the Open/Closed Principle. Any new type conforming to PathNode or RenderNode must also be added to these switch statements. A compile-time exhaustiveness check or a documentation comment on the protocols would help mitigate this risk.

Fix 2: Explicit element-wise boxing to avoid _arrayForceCast
Replacing implicit array covariance casts with an explicit .map { $0 as KeypathSearchable } causes the compiler to emit a direct existential box operation per element, completely bypassing _arrayForceCast and its per-element conformance checks.

// GroupNode.swift — before
var childKeypaths: [KeypathSearchable] = tree.childrenNodes

// GroupNode.swift — after
var childKeypaths: [KeypathSearchable] = tree.childrenNodes.map { $0 as KeypathSearchable }
// ShapeCompositionLayer.swift — before
childKeypaths.append(contentsOf: results.childrenNodes)

// ShapeCompositionLayer.swift — after
childKeypaths.append(contentsOf: results.childrenNodes.map { $0 as KeypathSearchable })

Why this works: The explicit as KeypathSearchable cast on a value already known to be a concrete conforming type is resolved at compile time. The .map introduces a small allocation, but this is far cheaper than repeated conformance table scanning.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions