Skip to content

A coarser observation channel for a reading of a whole list: tracking a 2,354-entry MediaListCollection registers about 95,000 field reads #53

Closed as not planned

Description

@shergin

Summary

We are building Yakitate, a native macOS client for AniList, on Baton 0.16.0. Its model derives what the panel shows from the user's whole list, so it reads every entry's facts out of the generated lenses inside Observations { ... }. At 2,354 entries that reading registers about 95,000 field reads, one per slot, and tracking them costs several times the read itself: 48 to 56 ms in the closure and 35 to 40 ms more before Observations delivers the value, against 12 to 13 ms for the same pass untracked. Requested: a second, coarser channel that a reader of a whole operation can register on once, beside the per-slot channel views use, or a statement that composing it in the app, as we now do, is the intended answer.

Current behavior

  • A read registers per field. docs/principles/store-is-the-ui-state.md, "The idea": "a lens accessor registers the read of one field of one record through a static key path"; "Spelled today": "Record is the observable object, with one invalidation channel per slot". The Invalidation channel entry (docs/terminology.md, "Store"): "what a read of a slot registers on and only a change of that slot notifies", in Swift "an Observation key path, one per slot index and shared by every record". So a reading makes one access on a record's registrar per field it reads.
  • The cost is stated as Observation's: "a tracked field read costs what an @Observable property costs; an untracked one less" (same principle, "Consequences"). The README's comparison table gives the Swift read back as "28 ns a field, 0.54 µs a field in a view body".
  • No channel coarser than a slot exists for data, by design. The same principle leaves Relay's "snapshots, seen-record sets, re-reads on overlap" to Observation, and lists "Query-level watchers that re-execute an operation and emit a tree" under "Not this". A handle's phase reads its root's verdict, settled "after a batch that changed a null, a link, an error or a deletion" (Phase and Operation handle, docs/terminology.md, "Generated"), so a reader of the phase is not told of a changed scalar below the root.
  • The list is MediaListCollection, fetched as six chunks, each chunk one operation value with its own handle. Each entry's reading touches about 40 fields across its MediaList, Media, MediaTitle, MediaCoverImage and AiringSchedule records. At launch the six roots come from the image (Hydration and Persistence, image, docs/terminology.md, "Store").

What we measured

Release build, Apple M1 Pro, macOS 26.5.2, Baton 0.16.0; a public AniList list of 2,354 entries in six MediaListCollection chunks; three runs each.

Step Time
One reading over the six handles inside Observations { ... }: the closure 48 to 56 ms
The same reading: from the closure's return to the value delivered, the observers installed on about 95,000 slot reads 35 to 40 ms
The same pass outside any tracking scope 12 to 13 ms
The lens pass alone (an earlier measurement, 2,075 entries) 4.1 ms
Launch from the image, session start to the list in the UI, the reading run twice 258 to 291 ms
of which the image's hydration of the six roots 65 to 100 ms
Launch from the image with the composition below, session start to the list in hand 95 to 129 ms, about two thirds of it hydration
One chunk of 400 entries re-read under its own Observations when one of its records changes about 15 ms
  • The reading ran twice at launch because one observation covered every handle: once for the list, and again when a further operation, the week's airing schedule, was attached and the observation restarted.
  • Derived, not measured: the tracked closure is 48 to 56 ms over about 95,000 registrations, 0.5 to 0.6 µs each, in line with the README's 0.54 µs a field in a view body; counting the 35 to 40 ms of installation and taking away the 12 to 13 ms the untracked pass costs, tracking adds about 0.7 to 0.9 µs per field read. Either way, about 0.5 to 0.9 µs per access, which at this size is most of the reading.

What we do today

One Observations stream per operation handle, so a change in one chunk re-reads that chunk; a reading is applied only when it differs from the last; and the first reading at launch is taken untracked and synchronously, before the streams start. The tracked readings still run once per chunk after the list is in hand. This is a composition in the app and needs nothing new from Baton. What it keeps is the cost of a change: a +1 on one entry costs a chunk's re-read, about 15 ms, rather than a field's.

Requested behavior

Per-slot registration is what buys the principle's first sentence, "A view body that read a field is invalidated when that field of that record changes and at no other time", and the README's "Only changed views re-render". We are not asking to replace it. The request is for a second, coarser channel for code that reads a large operation whole and would rather register once. Options, in Relay's words where it has them; the shape and the word are yours:

  1. An operation-level channel. A reader registers once, on the handle or its root, and is told when a commit changes a record its reading reached; with it, a way to read the operation's data without registering each field. This is the shape of Relay's subscribe on a snapshot, whose seenRecords decide whether a commit's updated records concern it: by record, not by field. We know the principle names seen-record sets among what Baton leaves to Observation; this would be opt-in for whole-operation readers, not what views use.
  2. Registration per record rather than per slot, for such a reading: one channel per record, told when any of its slots changes, so an entry registers at most five times rather than about forty, while a view body keeps the per-slot channel.
  3. Nothing in the runtime: the composition above as the intended answer, said where the docs speak of reading outside views, with a bench row for a tracked pass of this size so an adopter can budget it before building.

Which would the library prefer, or is there a shape we have not seen? We measured the Swift runtime only. The Kotlin runtime's channel is snapshot state per slot (Invalidation channel: "a slot nobody read has none"), and we have no numbers for it.

Why this is needed

An app that opens a list of thousands of entries from the image at every launch and derives its panel from the whole list pays the per-field registration on every launch and on every change to the list. At 2,354 entries the two tracked readings were most of the launch, more than the hydration. The per-chunk composition brings launch under 130 ms, but each adopter with a list this size has to find it, and its granularity is a chunk. #51 asks for a bench of the same collection's ingest and launch; this is its read side. We can share the per-run timings.

Activity

  1. shergin commented on Oct 19, 2026

    @shergin
    OwnerAuthor

    Decided on 2026-10-18: no new observation channel. The composition you describe is the intended answer, now A derivation over a whole operation in the derived-state recipe, with bench rows for a tracked pass of this size on both runtimes. Option 3, then.

    Why no coarser channel

    • Per-slot registration is the mechanism the store is the UI's state rests on: a read registers one field of one record, and a commit notifies the changed fields someone read. Its Not this lists query-level watchers, and it leaves Relay's snapshots and seen-record sets to Observation.
    • Derived state is observed, not signaled already refused a commit signal for models that are not views. A channel a reader registers on once per handle is that signal, scoped to one operation.
    • Option 1 is Relay's subscribe on a snapshot and its seenRecords: the seen-record set the vision deletes, with a second way to read the data beside the lens.
    • Option 2 puts a branch on every tracked read, on the path a view body takes, to choose between two registrations, and it is a second way to read too. Relay registers per record and Baton per slot: per record is coarser for a reading of a whole list, per slot is finer for a view body, and per slot is what buys one row re-rendering for one field.
    • The cost you measured is Observation's registrar, not Baton's, and you derived it correctly from the published rows: 0.5 to 0.9 µs a registration against BENCHMARKS.md's 579 ns a tracked field. The new rows confirm it on a list of this shape: 647 ns a field in one tracking scope over the whole list, against 585 ns a field in a row body of eight in the same runs, and linear from one chunk to two. The Kotlin runtime's channel is the same, snapshot state per slot read, and on the JVM the same reading pays 57.3 ns a field, since Compose's read observer adds the state to a set. The difference is the platform's.

    The composition, as the recipe gives it

    • One Observations per operation handle (Kotlin: snapshotFlow), so a change in one chunk re-reads that chunk.
    • The first reading at launch untracked and synchronous, before the streams start: a handle the image answers is ready in the turn it is made.
    • A reading applied only when it differs from the last, so each stream's first value, equal to the untracked reading, changes nothing.
    • And the lever the numbers add: read what the derived value needs, not the row's whole fragment, since every field read registers.

    The model is compiled on both runtimes as ListModel beside the AniList tests (Swift, Kotlin), and ListModelTests beside each proves the whole list in hand in the turn the model is made with no request, the streams' first readings changing nothing, and a save of one entry changing that entry alone.

    The numbers. M1 Pro, macOS 26.5.2, Xcode 26.6, release build; the JVM is OpenJDK 21 on the same machine. The list is #51's recording under spec/anilist/, 1,000 entries, 44 slots an entry for the row's fragment and seven for the model's five values. Best of three Swift runs and two JVM runs; the entry is the AniList list, with the medians.

    Swift Kotlin, the JVM
    A field read, untracked 20.2 ns 13.6 ns
    A field read, tracked 647 ns 57.3 ns
    The whole list, untracked 0.89 ms 0.65 ms
    The whole list, tracked, through Observations or snapshotFlow to its first value 28.3 ms 2.16 ms
    One chunk of 500 entries, tracked, every field 14.2 ms 1.20 ms
    One chunk of 500 entries, tracked, five values an entry 2.57 ms 0.26 ms
    A one-field save, the commit to that chunk's reading delivered, every field 15.6 ms 1.16 ms
    The same, five values an entry 2.70 ms 0.31 ms

    A launch from the image reaches both chunks' handles ready in 21.6 ms in Swift. Your 15 ms re-read of a 400-entry chunk is the same order as the 15.6 ms here for 500 entries read whole; reading the five values a panel needs brings it to 2.7 ms.

    Reopen with a whole-operation derivation that the per-handle composition cannot keep within its budget at a measured size; the reopening line of the decision now says so, and its Not chosen names both options.

    Merged in #55 as 4bbdc78; not yet released. Closing as not planned.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions