[Pitch] Observation performance and modeling improvements

Observation is now the model layer of most new SwiftUI code, and it is increasingly used outside
SwiftUI: in middleware, in synchronization between models, and on constrained targets. That
breadth exposes costs and gaps the original design couldn't anticipate. This proposal addresses
them as one set of additive changes with a single goal: the cost of observation should track what
is actually observed, and the shapes of model people want to write should be observable.

For the readers who need a tl;dr - this pitch is rather in-depth here are a few key highlights of what is new:

  1. Property indices. @Observable passes each tracked property's small integer to new ObservationRegistrar overloads, so mutating a property nobody observes skips the registrar's lock. These overloads back-deploy.
  2. Event resolution. @Observable(resolution: .object) and @ObservationTracked(group:) choose coarser change events than one per property.
  3. Derived properties. @ObservationDerived declares a stored property computed from other observable state, recomputed only when its inputs change.
  4. Persistent listeners. ObservationTracking.Listener is a synchronous observer re-armed in place, and its .coalesced option delivers a burst of N writes once.
  5. Noncopyable observable types. @Observable can be applied to a ~Copyable struct.
  6. Embedded Swift. Observation builds for Embedded Swift through property index-only registrar entry points.

Most developers meet the first three through the macros: recompiling picks up property indices,
and @ObservationDerived and resolution: are spelled on the declaration. The registrar entry
points, ObservationDerivedValue and Listener are for library authors, frameworks and
hand-written observable types.

Motivation

Mutating an unobserved property of an observed object can be expensive

Every mutation of an @Observable property calls willSet(_:keyPath:) and didSet(_:keyPath:). With only a key path to go on, learning that nobody observes the property means forming the key path (a swift_getKeyPath call plus a retain and release) and taking the registrar's lock and hashing the key path, twice. This is the common case: a SwiftUI model is observed by views that each read a few properties, while the app mutates many more.

Much of the registrar's cost can be removed without API changes (one allocation per registrar, a lazily created observer table, a lock-free "never observed" check); those are implementation details and not part of this proposal. Measured on an M-series Mac, release builds, as library-evolution dylibs:

scenario Swift 6.4 runtime runtime-only changes with property indices
mutate a property of a never-observed object 34 ns 23 ns 23 ns
observe one property, then mutate it 790 ns 260 ns 260 ns
mutate an unobserved property of an observed object 70–115 ns 75–115 ns 19–24 ns

The last row is what runtime-only changes cannot improve. If each mutation also carried a small,
stable integer naming the property, the registrar could keep a per-object bitmask of properties
that may be observed and return after one relaxed atomic load. The macro already knows such an integer: the property's position among its type's tracked properties.

Per-property events are sometimes the wrong granularity

Some types are always read as a whole: a color whose components are rendered together, or a
settings record displayed as one unit. For them, per-property tracking registers one entry per
property read and delivers one notification per property written, for no benefit. Installing one
observer that reads every property of an object took 41 µs and 60 allocations with 32 properties,
and 1.6 ms and 2,203 allocations with 2,048. A type can't currently easily say "treat these
properties as one".

Derived state is recomputed on every read

A computed property of an @Observable type is observable for free, because reading it reads the stored properties it depends on. A results property that filters books by query is one example. But it is recomputed on every read, by every reader, whether or not books or query changed,
and chains of derived values multiply this. Caching by hand is error-prone: a cache in an
@ObservationIgnored property doesn't know when to invalidate, and one invalidated from didSet on each input recomputes eagerly on every write even if nobody reads it. What's wanted is the derived value other existing systems provide: computed lazily, kept until something it read changes, and not propagated when it recomputes to an equal value.

Synchronous listeners can be quadratic over bursts of writes

A common synchronous observer reads a set of models to take a snapshot and wants to be told
before any of it changes. Today it must re-register from its own onChange handler. If the app
then writes to N models in a loop, the observer fires N times and each firing reads all N models:
N² reads. Measured with N models, a willSet observer that reads all of them, and one write to each:

With withObservationTracking(options: [.willSet]) re-armed in onChange, N=100 took 100 fires, 10 000 reads and 17.2 µs/write; N=1600 took 1 600 fires, 2 560 000 reads and 287 µs/write. withContinuousObservation(options: [.willSet]) fires once and reads N times.

The asynchronous APIs are linear because they run the observer after the burst, on an actor, which
is exactly what a snapshot observer can't accept: the pre-change state is gone. Re-registering is
also allocation-heavy (20–32 allocations per write), and there is no persistent synchronous
observer in public API.

Observable types must be classes

A registrar needs one identity for observers to register with, and a copy of a struct would share
its registrar with the original. A noncopyable struct doesn't have that problem: it has exactly one
owner and its registrar moves with it. Noncopyable models suit code that wants unique ownership
and deterministic destruction, but today such code has to give up either noncopyability or
observation.

Observation is unavailable in Embedded Swift

The registrar is built around key paths, which Embedded Swift doesn't support, so small devices can't use Observation.

Proposed solution

Property indices

ObservationRegistrar gains overloads of its four entry points taking a propertyIndex: argument:

registrar.access(self, keyPath: \.name, propertyIndex: 0)
registrar.withMutation(of: self, keyPath: \.name, propertyIndex: 0) { _name = newValue }

@Observable numbers each tracked stored property and its accessors call these overloads, so
existing types adopt the fast path by being recompiled. The property index is a hint: the key
path remains the property's identity, notifications are delivered exactly as for the key-path-only
entry points, and a wrong, conflicting or out-of-range index costs only the fast path, never
correctness. On older runtimes the overloads call the key-path-only entry points.

Event resolution

@Observable(resolution: .object)
final class Color {
  var red = 0.0
  var green = 0.0
  var blue = 0.0
}

@Observable
final class Layout {
  @ObservationTracked(group: 1) var width = 0.0
  @ObservationTracked(group: 1) var height = 0.0
  var title = ""
}

In Color, every tracked property shares one event. In Layout, width and height share an
event and title has its own. An observer that read any property of a shared event is notified
when any of them changes.

Derived properties

@Observable final class Library {
  var books: [Book] = []
  var query = ""

  @ObservationDerived var results: [Book] {
    input { (books, query) }
    update { books, query in
      books.filter { $0.title.localizedStandardContains(query) }
    }
  }
}

input reads what the value depends on and should be cheap. update computes the value from what input returned and must be pure. Reading results runs neither block if nothing observable has been written since it was last computed, and otherwise runs input and runs update only if something input read has changed. If the value is Equatable and recomputes to an equal value, derived properties that read it don't recompute. Observers of results are notified when books or query changes, exactly as for a computed property.

Persistent listeners

var listener = ObservationTracking.Listener(options: [.willSet, .coalesced]) { _ in
  scheduleRender()
}
listener.track { render(model) }

A Listener is a ~Copyable handle: dropping it cancels it. Each track(_:) replaces what it
tracks with what its closure reads, keeping unchanged registrations and allocating nothing when
the tracked properties are the same as last time. With .coalesced, the listener is told once
before the first change to anything it read, and not again until it tracks again outside its own
handler, so a burst of N writes costs one delivery.

Noncopyable observable types

@Observable
struct Counter: ~Copyable {
  var count = 0
  var step = 1
}

Its properties are tracked and notified as a class's are, for example when the struct is stored in
a class and read under withObservationTracking. No key path can name a property of a noncopyable type, so the expansion identifies each property by its property index alone, through new property index-only registrar entry points. An Event therefore can't say which property changed.

Embedded Swift

The Observation module builds for Embedded Swift. There, ObservationRegistrar's only entry
points are the property index-only ones, and @Observable's expansion uses them. Everything that
doesn't depend on key paths is available, including withObservationTracking, Listener and
@ObservationDerived.

Detailed design

Property indices

Registrar entry points

@available(SwiftStdlib 5.9, *)
extension ObservationRegistrar {
  /// - Parameter propertyIndex: A small non-negative integer identifying the property
  ///   within its type. Pass the same value for a given key path every time.
  @_alwaysEmitIntoClient
  public func access<Subject: Observable, Member>(
    _ subject: Subject,
    keyPath: KeyPath<Subject, Member>,
    propertyIndex: Int
  )

  @_alwaysEmitIntoClient
  public func willSet<Subject: Observable, Member>(
    _ subject: Subject,
    keyPath: KeyPath<Subject, Member>,
    propertyIndex: Int
  )

  @_alwaysEmitIntoClient
  public func didSet<Subject: Observable, Member>(
    _ subject: Subject,
    keyPath: KeyPath<Subject, Member>,
    propertyIndex: Int
  )

  @_alwaysEmitIntoClient
  public func withMutation<Subject: Observable, Member, T>(
    of subject: Subject,
    keyPath: KeyPath<Subject, Member>,
    propertyIndex: Int,
    _ mutation: () throws -> T
  ) rethrows -> T
}

Semantics. Each overload behaves as its key-path-only counterpart: the same observers are
notified, in the same order, with the same key path reported by Event.matches(_:), and observers registered through either form see mutations made through either. Hand-written key-path-only calls and macro-generated property index calls can be mixed freely on one type, as can observable subclasses whose registrars number their indices independently.

The property index contract.

  • A caller passes the same propertyIndex for a given key path every time. Macro-generated code does this by construction.
  • If a key path is seen with two different indices, the registrar stops using the fast path for it.
  • Two different key paths may share an index. That is correct, only slower; event resolution
    relies on it.
  • A negative index, or an index of 64 or more, never uses the fast path.

The fast path. Each registrar keeps a 64-bit observedMask. A clear bit s means no willSet or didSet observer observes any key path that has been mutated with index s; a set bit may be stale, which only costs the slow path. willSet, didSet and withMutation check the mask with a relaxed atomic load and, if the bit is clear, return (or just run mutation) without the lock. The mask is written only under the registrar's lock, whenever the set of observers changes and when the registrar first learns a key path's index from a tagged mutation. access records by key path as before; the index isn't needed until a mutation.

Back-deployment. The overloads are @_alwaysEmitIntoClient. They call two new @usableFromInline runtime entry points, _willSet(_:propertyIndex:) and _didSet(_:propertyIndex:), behind an availability check, and the key-path-only entry points otherwise.

Macro changes

@Observable assigns indices in declaration order to the tracked stored properties that are direct members of the type; @ObservationIgnored properties are skipped, and a property declared inside an #if block gets no index and uses the key-path-only form. @ObservationTracked gains parameters, which @Observable passes:

@available(SwiftStdlib 5.9, *)
@attached(accessor, names: named(init), named(get), named(set), named(_modify))
@attached(peer, names: prefixed(_))
public macro ObservationTracked(
  propertyIndex: Int = -1,
  group: Int? = nil,
  representative: String? = nil
) = #externalMacro(module: "ObservationMacros", type: "ObservationTrackedMacro")

group: is described under Event resolution; representative: names the property whose key path stands for a shared index. A negative propertyIndex means "none", and the accessors use the key-path-only entry points, so existing hand-written uses are unaffected.

@Observable also synthesizes access(keyPath:propertyIndex:) and withMutation(keyPath:propertyIndex:_:) members, and the accessors of each property pass its index, as in @ObservationTracked(propertyIndex: 0) var name = "" whose getter calls access(keyPath: \.name, propertyIndex: 0) and whose setter calls withMutation(keyPath: \.name, propertyIndex: 0). Everything else about the expansion is unchanged. A type that declares its own access(keyPath:)
or withMutation(keyPath:_:) to intercept observation keeps working: the synthesized index forms
forward to the customization, dropping the index.

Event resolution

@attached(member, names: named(_$observationRegistrar), named(access), named(withMutation), named(shouldNotifyObservers))
@attached(memberAttribute)
@attached(extension, conformances: Observable)
public macro Observable(resolution: ObservationResolution) =
  #externalMacro(module: "ObservationMacros", type: "ObservableMacro")

public enum ObservationResolution {
  case `default`  // Each tracked property is its own event source.
  case object     // All tracked properties share a single event source.
}

Observable(resolution:) is a separate declaration rather than a defaulted parameter so that @Observable keeps its original availability. Resolution is implemented entirely in the macro, on top of property indices:

  • With resolution: .object, every tracked property gets index 0.
  • With group: n, the properties of a group get the index of the first of them; ungrouped properties keep their own.
  • Properties sharing an index also share a key path: each passes the key path of a representative, the first property of the group. If the group's properties are isolated differently, the representative is the first nonisolated one; if there is none, the macro reports an error.

So Event.matches(_:) reports the representative's key path: matches(\.width) is true when height changes, and matches(\.height) is false. That is the intended trade: precision for fewer registrations, fewer notifications and lower per-write cost. resolution: and group: must be literals.

Derived properties

The macro

@attached(peer, names: prefixed(_))
@attached(body)
public macro ObservationDerived() =
  #externalMacro(module: "ObservationMacros", type: "ObservationDerivedMacro")

@ObservationDerived applies to a single instance var with a declared type, in a class or a
noncopyable struct, whose body is exactly input { ... } followed by update { ... }; anything
else is diagnosed. The macro splices the blocks into a call as closures. The example above expands to:

var results: [Book] {
  return _results.compute(input: {
    (books, query)
  }, update: { books, query in
    books.filter { $0.title.localizedStandardContains(query) }
  })
}

private let _results = Observation.ObservationDerivedValue<[Book]>()

The type doesn't need to be @Observable, but its inputs are tracked only if they are observable.

Storage

ObservationDerivedValue is the storage the macro declares. Declaring one by hand and calling
compute(input:update:) from a getter is equivalent, for properties that need their own accessors.

public struct ObservationDerivedValue<Value: Sendable>: Sendable, ~Copyable {
  public init()

  /// Returns the stored value, first recomputing it if what it depends on has
  /// changed; a recomputed value equal to the stored one is not a change.
  public func compute<Input>(
    input: () -> Input,
    update: (Input) -> Value
  ) -> Value where Value: Equatable
}

Without Equatable, every recomputation counts as a change. It is ~Copyable because a derived value has an identity: a copy would share no state with the original.

Semantics

The runtime keeps a process-wide epoch, advanced by a write only if its object has been read by some input in the current epoch. Each derived value records the epoch it was last validated in (visited) and the epoch its value last changed in (mutated). Reading a derived value:

  1. Clean. If it has a value and the epoch hasn't advanced since visited, it returns the value without running either block.
  2. Revalidate. Otherwise it runs input, recording the newest write epoch among the objects input reads. Reading another derived value counts as reading something written at that value's mutated epoch.
  3. Recompute. If anything input read is newer than visited, it runs update. If Value is Equatable and the result equals the stored value, mutated is left alone, so dependents don't recompute either; otherwise the result is stored and mutated advances.

Values are therefore lazy (writes run nothing) and incremental (recomputation stops at the first unchanged value); a diamond or chain recomputes each value at most once per change. A write to the input of a derived value costs 11–18 ns and runs no update. A clean read costs about 126 ns, mostly its lock, so a derived property pays off when update costs more than that or the value is read from several places; a cheap expression is better left a computed property.

Observers. A read inside withObservationTracking, a Listener's track, or any other tracking always runs input, so that tracking records the dependencies, but runs update only if an input changed. Observers of a derived property are notified when its inputs change, as for a computed property; the property doesn't itself filter notifications.

Contract.

  • input should be cheap: it runs on every read that isn't clean.
  • update must be pure, and should read no observable state, since such reads are not inputs.
  • Only observable reads are inputs; reads of @ObservationIgnored properties, globals or non-observable objects never cause a recomputation.
  • A derived value that depends on itself, directly or through others, traps.

Concurrency. Each derived value has its own lock, held while it runs input and update, so reads from several threads are serialized and a recomputation happens once. Lock order follows the acyclic dependency graph.

Persistent listeners

extension ObservationTracking {
  public struct Listener: ~Copyable, Sendable {
    public init(
      options: ObservationTracking.Options = .willSet,
      onChange: @escaping @Sendable (ObservationTracking.Event.Kind) -> Void
    )

    /// Calls the C function `onChange` with `context` and `willSetCode` or
    /// `didSetCode`, allocating nothing for the handler. `context` must stay
    /// valid until the listener is cancelled or dropped.
    public init(
      options: ObservationTracking.Options = .willSet,
      context: UnsafeMutableRawPointer?,
      onChange: @convention(c) (UnsafeMutableRawPointer?, Int) -> Void
    )

    public static var willSetCode: Int { get }
    public static var didSetCode: Int { get }

    /// Tracks the observable properties `apply` reads, in place of those the
    /// previous call tracked, and returns what `apply` returns.
    public mutating func track<T: ~Copyable, Failure: Error>(
      _ apply: () throws(Failure) -> T
    ) throws(Failure) -> T

    /// Stops the listener; it is not notified again.
    public consuming func cancel()
  }
}

extension ObservationTracking.Options {
  /// Delivers at most once per arming.
  public static var coalesced: ObservationTracking.Options { get }
}

Tracking in place. Per observed object, a registration from the previous track(_:) call is updated in place rather than cancelled and re-created, registrations of objects no longer read are cancelled, and the previous call's storage is reused. Re-tracking the same properties allocates nothing. The listener is notified, with the kinds its options name, until cancelled or dropped.

Coalescing. Without .coalesced, a listener is notified of every write to anything it tracks. With it, the listener has an armed flag:

  • track(_:) arms it, and a notification disarms it. Delivery claims the flag under the listener's lock, so concurrent writers deliver once.
  • A track(_:) made from inside the listener's own handler, on the thread running it, refreshes what is tracked without re-arming. This lets a handler take a fresh snapshot without re-opening the gate for the rest of a burst.
  • A call on any other thread arms it.

The contract is: a coalesced listener is told at least once before the first change to anything it read since its last notification. That is what invalidation- and snapshot-style consumers need, and not what a per-property consumer such as undo registration needs, which is why it is opt-in. Because the flag is keyed to the listener's own tracked reads, the writer's reads don't re-arm it: a += loop over N models delivers once.

Measured with the fixture from Motivation, a coalesced Listener re-tracked in its handler fires once, with 0 allocations, and its per-write cost stays flat as N grows (180 ns at N=100, 233 ns at N=1600), against 287 µs per write at N=1600 for the re-armed one-shot observation.

C handlers. The @convention(c) initializer exists for targets that must not allocate after start-up, such as Embedded configurations without a heap.

Noncopyable observable types

The Observable protocol

@available(SwiftStdlib 5.9, *)
public protocol Observable: ~Copyable { }

Every existing generic signature that mentions Observable, such as <Subject: Observable, Member> on the registrar's entry points, still implicitly requires Subject: Copyable, so none changes meaning or mangling.

Property index-only registrar entry points

extension ObservationRegistrar {
  public func access(propertyIndex: Int)
  public func willSet(propertyIndex: Int)
  public func didSet(propertyIndex: Int)

  public func withMutation<T, Failure: Error>(
    propertyIndex: Int,
    _ mutation: () throws(Failure) -> T
  ) throws(Failure) -> T
}

These are for types that can't form key paths. Here the index isn't a hint but the property's identity within its registrar:

  • A type passes a property's index the same way every time, and properties with the same index are the same event source (which is how group: and .object work for these types).
  • A property observed by index and one observed by key path are always different event sources, even on one registrar; a type uses one form per property.
  • These mutations name no property: Event.matches(_:) is false for them.
  • .deinit events, withObservationTracking, Listener, derived values and the observedMask fast path work as for key-path properties. The fast path is exact from the first registration.

These are the same signatures Embedded Swift uses, so @Observable emits a single form for noncopyable types in every build.

Macro changes

@Observable accepts a struct whose inheritance clause suppresses Copyable; copyable structs, enums and actors are still diagnosed. For a noncopyable struct the accessors make only index-only calls, and withMutation is synthesized as a mutating method that passes the struct to its closure inout, since a setter can't otherwise mutate self while a method borrows it:

The setter of count calls withMutation(propertyIndex: 0) { $0._count = newValue }.

@ObservationDerived may also be used in a noncopyable struct, where the noncopyable ObservationDerivedValue is stored inline.

Using noncopyable observable types

  • The closures of withObservationTracking and Listener.track(_:) don't escape, so they can borrow a noncopyable value directly; escaping closures, such as those of Observations, withContinuousObservation and Task, can't capture a noncopyable local, so such a model lives in a class, actor or global.
  • Tracked stored properties must be copyable; mark a noncopyable stored property @ObservationIgnored.
  • Without key paths, a type can't customize observation with its own access(keyPath:) or withMutation(keyPath:_:).

Embedded Swift

Under Embedded Swift, ObservationRegistrar has no key-path entry points; its only entry points are the index-only ones above, and observers are keyed by index directly. @Observable's accessors for a class contain both forms, selected with #if $Embedded; those for a noncopyable struct use the index-only form in every build. A property with no index (because it is declared in an #if) can't be tracked without a key path and is an error under Embedded and in a noncopyable struct, rather than silently untracked.

What depends on key paths or runtime features Embedded lacks is unavailable there: Event.matches(_:), the ObservationRegistrar: Codable conformance, and the key-path entry points.

Source compatibility

All of the changes are additive.

  • The new registrar overloads require the propertyIndex: label, so no existing call resolves
    differently.
  • @ObservationTracked's new parameters are defaulted, and @Observable(resolution:) is a new overload alongside @Observable().
  • The synthesized access(keyPath:propertyIndex:) and withMutation(keyPath:propertyIndex:_:) are
    added only when the type doesn't declare members with those signatures.
  • ObservationResolution and ObservationDerivedValue are new top-level names in a module SwiftUI re-exports; both carry the Observation prefix to stay out of the way of other modules.
  • Suppressing Copyable on Observable is source-compatible: existing generic parameters and extensions keep their implicit Copyable requirement.

The one exception is a development toolchain used with an older SDK, whose @ObservationTracked doesn't accept propertyIndex: and fails with "argument passed to macro expansion that takes no arguments". Toolchains and SDKs released together are unaffected. This is the main compatibility cost of the proposal.

ABI compatibility

This proposal is purely additive: no existing symbol is removed or changes meaning, and the exported symbols are a strict superset of the current SDK's.

  • The key-path overloads taking propertyIndex: are @_alwaysEmitIntoClient and add no public symbols; the runtime gains two @usableFromInline symbols, _willSet(_:propertyIndex:) and _didSet(_:propertyIndex:).
  • ObservationResolution, ObservationDerivedValue, ObservationTracking.Listener and ObservationTracking.Options.coalesced are new symbols with appropriate availability.
  • The index-only entry points are four new symbols. They can't be emitted into the client, because without a key path there is no older entry point to fall back to.
  • Macros have no ABI, and suppressing Copyable on Observable changes no symbol.

Existing binaries keep calling the key-path-only entry points and behave as before.

Implications on adoption

  • @Observable types adopt property indices by recompiling; code deployed to older OS versions keeps working with unchanged performance. Hand-written types can adopt by switching to the index-taking overloads.
  • @ObservationDerived, @Observable(resolution:), Listener and noncopyable observable types require the new runtime. The expansion for a noncopyable struct calls the index-only entry points unconditionally, so a module deploying to older runtimes must mark such a type @available.

Future directions

Parameterized derived values. A derived function, @ObservationDerived func row(at:), would cache one value per argument.

Observable actors. The same strategy of using exclusively indices could be applied to actors, but this needs more research into the impacts on isolation and concurrency.

Alternatives considered

Property indices

Runtime-only improvements. These are being made anyway and need no review, but can't help an unobserved property of an observed object, because the key path is all the registrar has.

Index only, no key path. The registrar couldn't turn an index back into a key path, which breaks Event.matches(_:) and the fallback on older runtimes.

A synthesized index-to-key-path map. It can only return PartialKeyPath<Subject>, while older runtimes accept only KeyPath<Subject, Member>, so the fallback needs a dynamic cast: 56–57 ns per mutation on older runtimes against 32–33 ns for today's expansion.

Event resolution

Each property passes its own key path with a shared index. This keeps Event.matches(_:) exact, but observers are identified by key path, so a group would still register one entry per property read.

Derived properties

The name. @ObservationCached described the mechanism rather than the guarantee; @ObservationComputed would be confused with computed properties. Derived state is the term SwiftUI developers and other reactive systems already use, and a bare @Derived would occupy a general name in every module that imports SwiftUI.

A notifying property. Notifying its own observers requires computing eagerly at willSet time on every input write, which defeats laziness and makes bursts quadratic.

Noncopyable observable types

Copyable structs. Copies would share a registrar, so mutating either notifies observers of both; giving each copy a fresh registrar would silently drop observers on every implicit copy.

Acknowledgments

Special thanks to Stefan Xenos for inspiration of the implementation changes and algorithmic backing adjustments.

37 Likes

Rhetoric question: In Derived properties, there are no generic requirements on the Input type on input: () -> Input, yet its evaluation is said to trigger a recomputation of update: (Input) -> Value only if the input changed. How then is it decided that the Input indeed changed? Notably tuples, which the example uses, don't (yet) conform to Equatable.

In fact I'm asking this because I'm guessing the implementation is going to use the same machinery to track the captured-and-read observable properties as Observation in general. Did I get that right?

And if that's the case, why do we even need a separate inputs closure to capture the inputs? Couldn't we just as well have update: () -> Value capture them the same way as the first parameter to withObservationTracking(...) always did? That way, we wouldn't need to match between the return value and arguments of the two closures, and the whole motivating example could be written as simple as this:

@Observable final class Library {
  var books: [Book] = []
  var query = ""

  @ObservationDerived var results: [Book] {
    books.filter { $0.title.localizedStandardContains(query) }
  }
}
4 Likes

Correct. The split is that can be split out as a cached item. The trivial examples that list easily in a pitch often eschew the reality - the intent is for the input to be very cheap (but observed properties or compositions that might have high frequency updates) Whereas the update is the pass that might have expensive events. That way a view can have a distinct observation on the expensive side and only get changes when it is composed as a full update. Think of it as a cached value + a specialized dirty bit.

This is FANTASTIC ※(^o^)/※

Just this week I was reviewing ElementaryUI's @Reactive / withReactiveTracking implementation (which I ported a while ago from Observation to work with Embedded Swift) to see where I could squeeze out some performance.

Persistent listeners(!) and @ObservationDerived are 100% exactly what I was dreaming of as a significant performance win (all those allocations that will be saved, beautiful!).

The Int property index mode for Embedded and ~Copyable, plus the added bonus of a lock-free fast path, are the sweet, sweet cherry on top.

Your pitch single-handedly ticks 110% of my wishlist, I can't wait to integrate this!

3 Likes

This is amazing!

We were just discussing something like an @ObservationDerived on Handling a specific kind of over-observing using @Observable

I could imagine using ObservationDerived for something that it may not have been intended for:
Observing changes to a specific part of a nested struct, where the entire value might change often but the part more rarely.

Perhaps ObservationDerived could have a shorthand that took a KeyPath to a property and transformed that to the form with input and update?

3 Likes

Is that last sentence meant as a rationale (s/counts/would count/) for why Value is required to be Equatable? Because now it reads to me as if there's another overload of compute(input:update:) where Value is not constrained to Equatable but every recomputation counts as a change. Which way is it?

It was a overzealous chop to reduce character count for a long post :melting_face:

/// Returns the stored value, first recomputing it if what it depends on
  /// has changed; a recomputed value equal to the stored one does not count
  /// as a change.
  public func compute<Input>(
    input: () -> Input,
    update: (Input) -> Value
  ) -> Value where Value: Equatable

  /// Returns the stored value, first recomputing it if what it depends on
  /// has changed.
  public func compute<Input>(
    input: () -> Input,
    update: (Input) -> Value
  ) -> Value

That is what the proposal ought to have included. However I am willing to be convinced that I should delete the non Equatable overload.

1 Like

Besides, forgot to say: What a great pitch, this fills a lot of missing pieces in the current Observation functionality and could turn out to be very useful for a whole variety of applications I have in mind! :heart:


I'm still thinking the @ObservationDerived use case could ideally be simpler expressed, and would like to better understand why not if not!

Could we clarify that with another example? So in the original example, the cached value of the returned tuple:

    input { (books, query) }

would of course change if either of books or query changed, so there's way really to avoid performing the "expensive" update { books, query in ... } computation, right?

But when you talk about cheap computation of inputs and distinct observation on the expensive side, are you referring to projections such as

    input { (books.count, query) }
    update { bookCount, query in ... }

or

    input { (books.map(\.id), query) }
    update { bookIDs, query in ... }

where it's not always the case that a change in one of the observed properties (books) results in a change in the compound value used as input to the update closure?

5 Likes

How should Input of ObservationDerived look like when set of effective inputs can be variable?

update {
    a ? b : c
}

or

update {
    observableList.map { item in
        item.observableField
    }
}

I think explicit list of inputs is a severe limitation that contradicts with previous design of @Observable that offered automatic discovery. Having to list inputs manually can also be a violation of encapsulation:

input { ??? }
update { whoKnowsWhatThisFunctionDependsOn() }

I think it should be possible to re-validate without explicit input. Implementation knows list of observed inputs accessed in the last call of update. It can check their epochs using this list, without calling update again.

4 Likes

This is where I was coming at as well:

1 Like

One of the original motivations to ship SE-0494 was to give engineers a performance hook to speed up memoization:

If we call our result function with an Array of values and then pass the same Array of values again, we might want to return our previous result without performing another O(n) operation. Because our result function is a pure function and free of side-effects, we can check the new input value against the last input value we used to compute our result. If the input values have not changed, the result value also must not have changed.

Here is an attempt to memoize our result:

final class Memoizer {
  private var input: [Int]?
  private var result: [Int]?
  
  func result(for input: [Int]) -> [Int] {
    if let result = self.result,
       self.input == input {
      return result
    } else {
      print("computing new result")
      self.input = input
      let result = input.filter {
        $0 % 2 == 0
      }
      self.result = result
      return result
    }
  }
}

When we pass input values we can see that a new result is not computed if we already computed a result for those same input values:

let memoizer = Memoizer()
let a = [1, 2, 3, 4]
print(memoizer.result(for: a))
// Prints "computing new result"
// Prints "[2, 4]"

let b = a
print(memoizer.result(for: b))
// Prints "[2, 4]"

let c = [1, 2, 3, 4]
print(memoizer.result(for: c))
// Prints "[2, 4]"

let d = [1, 2, 3, 4, 5, 6]
print(memoizer.result(for: d))
// Prints "computing new result"
// Prints "[2, 4, 6]"

let e = d
print(memoizer.result(for: e))
// Prints "[2, 4, 6]"

let f = [1, 2, 3, 4, 5, 6]
print(memoizer.result(for: f))
// Prints "[2, 4, 6]"

This looks like a big improvement… until we begin to investigate a little closer. There’s a subtle performance bottleneck here now from a different direction. Our memoization algorithm depends on the value equality of our input values — and this is also an O(n) operation. So while it is true that we have reduced the amount of O(n) operations that take place to compute our result values, we have added O(n) operations to determine value equality. As the amount of time spent computing value equality grows, we might no longer see any performance wins from memoization: it would be cheaper to just go ahead and compute a new result every time.

My advice would be to give product engineers the ability to construct ObservationDerived with their own algorithm to determine "what changed". The argument could be made that value-equality might be a good enough "default" for most situations… but that should not (IMO) imply that the API itself disallows asking product engineers what they want instead.

More Discussion including Self-Promotion

The MemoProperty repo builds a SwiftUI property wrapper to clone the useMemo hook from ReactJS. MemoProperty defines inputs and selector closures to determine what changes as a parameter pack.

MemoProperty

What we want is some place to put the business logic to memoize an output value from a set of input values. We want this to be flexible and not tied to any one specific operation or algorithm. Let’s think through the basic requirements to set up and define the business logic to memoize an operation:

  • We need to define a set of dependencies. These will be the values we pass to the operation to compute our output. Every dependency also needs a corresponding operation we use to determine whether or not two dependency values have changed. When two dependency values have changed that implies we should compute a new output value.
  • We need to define an operation to compute our output. We will pass our dependencies as an input.

Let’s define a new dynamic property named MemoProperty:

struct DependencySelector<Dependency> {
  let dependency: Dependency
  let didChange: (Dependency, Dependency) -> Bool
  
  init(
    dependency: Dependency,
    didChange: @escaping (Dependency, Dependency) -> Bool
  ) {
    self.dependency = dependency
    self.didChange = didChange
  }
}

struct OutputSelector<each Dependency, Output> {
  let output: (repeat each Dependency) -> Output
  
  init(output: @escaping (repeat each Dependency) -> Output) {
    self.output = output
  }
}

@propertyWrapper struct MemoProperty<each Dependency, Output> {
  private var _storage = State(initialValue: Storage())
  
  private var storage: Storage {
    self._storage.wrappedValue
  }
  
  private var dependencySelector: (repeat DependencySelector<each Dependency>)
  private var outputSelector: OutputSelector<repeat each Dependency, Output>
  
  init(
    dependencySelector: repeat DependencySelector<each Dependency>,
    outputSelector: OutputSelector<repeat each Dependency, Output>
  ) {
    self.dependencySelector = (repeat each dependencySelector)
    self.outputSelector = outputSelector
  }
  
  var wrappedValue: Output {
    self.storage.wrappedValue
  }
}

This is a neat idea - I will need to think on that for a bit to determine if it adds too much complexity.

2 Likes

I'm not sure I would use this because I feel an object observing itself is a bit of an anti-pattern. E.g. it would be better if the thing that needs to derived property stored the property itself.

However with regard to the design preventing the update closure from accessing instance properties, how about we declare the static compute func and the computed var is automatically generated? Using the function signature as the single source of truth for both the dependencies and the calculation logic eliminates variable repetition while keeping the dependency graph explicit.

@Observable final class Library {
  var books: [Book] = []
  var query = ""

  @DerivedProperty("results")
  private static func filterBooks(_ books: [Book], query: String) -> [Book] {
    books.filter { $0.title.localizedStandardContains(query) }
  }
}

// `var results: [Book]` automatically generated

edit: also if the static func is throws, e.g. for performing validation, then the results var type could be Result<[Book], Error>

I was playing with an implementation of your Listener design and stubbed my toe on the following:

I have this UI node type that runs its view body in a tracker - if anything the body accessed changes, it is scheduled for a re-run. So far, so obvious.

However, using the current API (with access lists and so on) I had this optimization:

  • run the body and collect the access list
  • a) if something was accessed: allocate a storage node, set up tracking for this storage node
  • b) if nothing was accessed: store the body inline, allocate nothing, set up nothing

The Listener API is great if you have something to observe, but iiuc there is no way to "do nothing" if there is no access. Since the initializer takes the closure / function pointer before you even ask what to track, you will need to pin down some shareable thing (most likely allocate something) up front.

I know supporting this will make for a quirkier API, but how do we feel about something like:

extension ObservationTracking {
    public struct AccessedDependencies: ~Copyable {
        // Opaque collected dependencies.
    }

    @frozen
    public struct TrackingResult<Value: ~Copyable>: ~Copyable {
        public var value: Value
        public var dependencies: AccessedDependencies?
    }

    public static func track<Value: ~Copyable, Failure: Error>(
        _ apply: () throws(Failure) -> Value
    ) throws(Failure) -> TrackingResult<Value>
}

extension ObservationTracking.Listener {
    public init(
        tracking dependencies: consuming ObservationTracking.AccessedDependencies,
        options: ObservationTracking.Options,
        onChange: @escaping @Sendable (ObservationTracking.Event.Kind) -> Void
    )
}

The collected dependencies would transfer into the listener, arming it with the initial registrations without running an apply closure again.

Then one could write code roughly like this:

let result = ObservationTracking.track {
    value.body
}

if let dependencies = result.dependencies {
    let box = SomeBox(result.value)
    box.setupListener(dependencies)
    storage = .box(box)
} else {
    storage = .inline(result.value)
}

According to the CHANGELOG entry of swiftlang/swift#91271:

Swift (next)

Embedded Swift now has complete support for key paths.

4 Likes

There were improvements made to key path but they fall just a bit short of what ideally would be needed - the proposed mechanism is considerably more efficient for embedded targets anyhow since it skips the allocation side completely.

6 Likes

Hi, I'm working on projects that will require embedded version of Observation, is there a public draft of the implementation I can poc with ?