Skip to content

Refresh large hierarchies in about 2 s instead of 20 - #43

Merged
Mx-Iris merged 10 commits into
mainfrom
perf/refresh-measurement
Oct 19, 2026
Merged

Mx-Iris merged 10 commits into
mainfrom
perf/refresh-measurement

Conversation

@Mx-Iris

@Mx-Iris Mx-Iris commented Oct 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Refreshing an app with a few thousand layers took about 20 seconds, and for long stretches of it the window did not respond. Most of that time went into work nobody looked at. With fast mode off (the default), the host asked the Server in one request for every item's group screenshot, every expandable item's solo screenshot and every item's attributes: 6,594 detail tasks for the 68 rows a 4,500-item hierarchy displays. Several host-side passes also grew with the square of the item count.

A refresh now asks for what the displayed rows show and is done when that is in; the rest of the screenshots follow in the background. Measured with a Debug build against a test app, refreshing an open window twice after the first load:

Before After
Refresh in an open window, iOS Simulator, 4,517 items 21.5 s 2.25 s
Refresh in an open window, macOS app, 6,133 items 18.1 s 2.3 s
First load, iOS Simulator 23.9 s 2.8 s
First load, macOS app 22.2 s 3.0 s

After a refresh, the background prefetch takes another 11.8 s on the iOS Simulator and 2.9 s on macOS. It reports no progress and does not hold up a refresh, and while it runs the main thread never stays away from its run loop for more than about 0.15 s. Before and after used the same Server build, so the difference is all the host's.

What changes for users

  • A refresh is done once the displayed rows have their screenshots and attributes. The progress bar and the reload button's stop work as before, for that part.
  • Fast mode now only means "no background prefetch". With it off, once the displayed rows are in, the host fetches what expanding the folded rows would show, breadth first from the rows on screen, in packages of at most 20 tasks. The prefetch steps aside, and later resumes from what is still missing, whenever the displayed rows need something, a reload starts, or another detail request needs the connection.
  • Attributes are fetched only for items that have been displayed. The attributes panel is unaffected, since a selection is always a displayed row or plane. The MCP bridge's attributes.read, which only reads what is cached, answers detailsCached: false more often for items never displayed; details.read fetches them.

Commits

  1. Signposts. Each stage of a refresh runs in a signpost interval under subsystem com.lookinside.app, category RefreshPerformance: the hierarchy and detail requests, unarchiving each response frame, the data source reload, building the detail tasks, applying each reply, the preview render, the z-position pass and the outline reload. They show in Instruments' os_signpost instrument or with log stream --signpost.
  2. Measure refreshes in an open window. The DEBUG end-to-end dump writes timings.json, and with LOOKINSIDE_DEBUG_E2E_REFRESH_COUNT it reloads the open window that many more times, the way the toolbar button does.
  3. MessageAttribute subscriptions stay linear. Every preview node subscribes to three shared preference attributes, and the duplicate check scanned every subscriber: 94% of the first preview render (2.9 s for 4,500 items on iOS, 5.8 s for 6,100 on macOS). Subscriptions are now keyed by target, action and related object, and subscribers whose target went away are pruned as they pile up rather than only when the value changes. A refresh reuses the preview nodes, so this helps the first load.
  4. Preview z order. The z-position pass recomputed both frames to the root for every pair of planes it compared, and ran again for each frame, hidden or alpha change while details were applied. PreviewZOrdering (in LookInsideHostCore) computes each frame once and keeps the same rules, and those changes now ask for one pass per main-queue turn.
  5. Paced frame delivery. Dispatch I/O calls its read handler on a serial queue that targets the main queue, and that queue keeps draining while frames keep arriving, so a fast Server could keep the main thread from its run loop for a whole multi-frame reply (2.4 s for one detail request from a Mac app). ServerChannel now queues frames in PacedMainQueueDelivery, which hands them on for at most 8 ms per main-queue turn, in order, with the end after every frame.
  6. Displayed items first, then the prefetch. DetailFetchPlanning (in LookInsideHostCore) plans the two kinds of request, and StaticAsyncUpdateManager sends them one at a time, since a newer detail request makes the connection discard the older one. Building tasks reads the host version and the platform once per batch, and asks the SwiftUI license gatekeeper once per batch rather than once per task.
  7. The dump waits for the prefetch before it refreshes again or writes its snapshot, and marks the details phase with signposts, so the longest gap between its 0.25 s ticks shows how long the main thread stayed away from its run loop.
  8. CI runs Scripts/test-message-attribute.sh and the LookInsideHostCore tests.

Testing

  • LookInsideHostCore: 139 tests in 26 suites pass. New among them: DetailFetchPlanningTests, PreviewZOrderingTests, which compare with the old algorithm on 40 random trees, and PacedMainQueueDeliveryTests, two of which fail when the delivery stops yielding.
  • Scripts/test-message-attribute.sh passes. Before the fix, subscribing 20,000 targets took 3.1 s and it failed.
  • The numbers above come from the DEBUG end-to-end dump against a test app, on an iOS Simulator and on macOS.

Not measured on a USB device, and not checked by hand in the running app:

  • After a refresh, expanding a folded row shows its children's screenshots without a wait.
  • Turning fast mode on stops a running prefetch; turning it off resumes it.
  • A refresh started during the prefetch starts at once.
  • Modifying an attribute still updates the screenshots of the item and its ancestors.
  • Refreshing an app on a USB device.

The host's share of a slow refresh was invisible. Each stage now runs in
a signpost interval under subsystem com.lookinside.app, category
RefreshPerformance (FrameworkToolbox's @Signpostable / #signpostInterval):
the hierarchy and detail requests from send to the moment they leave the
channel, unarchiving each response frame (with its type and size as an
event), the data source reload, building the detail tasks, applying each
detail reply, the preview render, the z-position pass and the outline
reload.

A request's interval ends in ServerChannelConnectionState.remove(_:),
the one place every way a request ends goes through — completion,
failure, timeout, discard, cancel.

The DEBUG end-to-end dump also writes timings.json next to the snapshot:
the hierarchy request, the reload, the detail fetch (to the first idle
tick of the update manager) and the item count.
The first load of a document builds a preview node for every item; a
refresh in an open window reuses them, so the two cost differently and
the dump only measured the first. With LOOKINSIDE_DEBUG_E2E_REFRESH_COUNT
it now reloads the hierarchy that many more times through the window
controller, the toolbar button's path, each once the previous details
are in, and adds a `refreshes` entry per reload to timings.json.
Every preview node subscribes to three shared preference attributes, and
subscribe(_:action:relatedObject:) scanned every existing subscriber for
a duplicate, so opening a document with n items cost O(n²): 94% of the
first preview render (2.9 s for a 4,500-item iOS hierarchy, 5.8 s for a
6,100-item Mac one). Subscribers whose target went away were only dropped
when the value changed, so every document opened made the next one
slower.

Subscriptions are now keyed by target, action and related object, and a
hit only counts as a duplicate while its weak target is still that
object, so a reused address is not taken for the old one. Dead
subscribers are pruned once the subscriptions since the last pruning
reach half the list (at least 1,024).

Scripts/test-message-attribute.sh builds the class with its tests into a
standalone binary, and Scripts/test.sh runs it. Before the change,
subscribing 20,000 targets took 3.1 s and the test failed.
The z-position pass compared every displayed plane with every plane
before it and recomputed both frames to the root inside the comparison,
O(d·n·depth), and it ran again for every frame, hidden or alpha change
while details were applied.

PreviewZOrdering (LookInsideHostCore) takes each plane's frame once and
compares by index, with the same rules; a test checks it against the old
algorithm on 40 random trees. Changes while details are applied now ask
for one pass on the next main-queue turn instead of one each.
The host reads a channel with dispatch I/O on the main queue, and
dispatch I/O calls its read handler on a serial queue that targets the
main queue (op_q in libdispatch's io.c). A serial queue goes on draining
for as long as new items arrive in time, and each frame was unarchived
and applied inside the handler. So whenever a Server answered faster
than the host applied its replies, a whole multi-frame response ran
without the main thread getting back to its run loop: no events, no
timers, no drawing. A 7,000-task detail request from a Mac app held it
for 2.4 s.

ServerChannel now queues the frames and the end in
PacedMainQueueDelivery (LookInsideHostCore), which hands them to the
delegate for up to 8 ms per main-queue turn and leaves the rest to a
later turn. The main queue only runs the blocks it held when its drain
began, so the run loop gets a pass in between. Order is kept, the end
comes after every frame, and frames read but not delivered when the host
closes the channel are dropped, as the frame channel stops reading. A
frame header is still accepted as it is read; a frame accepted for a
request that ends before it is delivered is dropped by the connection
manager, as one arriving after a timeout already was.

Two of the delivery's tests fail when it stops yielding.
With fast mode off, the update manager asked for every item's group
screenshot, every expandable item's solo screenshot and every item's
attributes at once (6,594 tasks for the 68 rows a 4,500-item hierarchy
displays) and deduplicated them with Array.contains, which alone took
3.1 s. The window could not refresh until all of it was in.

Requests now come in two kinds, which DetailFetchPlanning
(LookInsideHostCore) plans:

- Foreground: what the displayed items miss of the screenshot the
  preview shows for them, and their attributes. The window shows its
  progress and the reload button stops it, as before.
- Prefetch: unless fast mode is on, once the foreground request is
  answered, breadth first from the folded displayed items, each one's
  solo screenshot and its children's group screenshots, without
  attributes, in packages of at most 20 tasks and 500,000 pt². It
  reports no progress and steps aside, resuming later from what is still
  missing, whenever the displayed items need something, a reload starts
  (before its hierarchy request is sent) or another detail request needs
  the channel. Fast mode now means no prefetch.

Only one detail request can be in flight on a channel, so both kinds go
through the manager. A request another detail request discards (the MCP
bridge asks for screenshots that way) is retried a second later without
an alert. Attributes now come only for displayed items, so the MCP
bridge's attributes.read, which reads what is cached, answers
detailsCached false more often; details.read fetches.

Building tasks takes the host version and the platform once per batch,
and asks the SwiftUI license gatekeeper once per batch instead of once
per task; for a Mac app that cut building 7,000 prefetch tasks from
365 ms to 195 ms, the rest being the per-item SwiftUI check of an
unlicensed host. Each step runs in a signpost interval.

With the changes before it, a refresh in an open window went from
21.5 s to 2.25 s on the iOS Simulator (4,517 items; the prefetch then
takes 11.8 s in the background) and from 18.1 s to 2.3 s on macOS
(6,133 items; 2.9 s of prefetch).
…e dump

A refresh started while the previous load still prefetched would cancel
that prefetch and measure something else, so the DEBUG end-to-end dump
now waits for the prefetch too before the next refresh or the snapshot.

While details load, each tick of the dump is a "Driver tick" signpost
event inside a "Driver loading details" interval: a gap between ticks
longer than the 0.25 s tick interval is a time the main thread kept its
run loop waiting. The first load enters that phase after its reload, as
a refresh does, so the interval holds no reload work.
The build matrix ran neither: Scripts/test-message-attribute.sh is new,
and LookInsideHostCore's swift-testing suite, which now also covers the
detail fetch planning, the preview z order and the paced frame
delivery, only ran locally.
Copilot AI balanced review requested due to automatic review settings October 18, 2026 06:15

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

The review of this branch found five problems it introduced:

- The prefetch retried a second after another client's 203 discarded
  it, and the retry discarded that client's request in turn, so an MCP
  read that took longer than a second could fail over and over. The
  inspector's own detail requests now yield: one that finds a 203 in
  flight on the channel is not sent, and the fetch resumes once the
  channel is free. A cancel reaches only the request it was issued
  for, including the one a SwiftUI mode switch sends, which cancelled
  whichever 203 came first; a request cancelled while still pinging is
  no longer sent.
- Export, Save As and Open in New Window wrote only what had been
  fetched, so in the default mode the file lacked the attributes of
  rows never shown and the screenshots the prefetch had not reached.
  They now fetch everything the tree still lacks first, with progress
  in the window and the refresh button to stop it.
- A reply that had arrived but still waited in the paced main-queue
  delivery could time out, since the delivery hands the main thread
  back between slices. The countdown now starts over while a frame for
  its request is pending.
- A reload stopped only the prefetch, so the last package of an older
  foreground request could mark the new tree's tasks as answered. A
  reload now stops every request, and an answer counts only toward the
  tree it was asked for.
- Collapsing an expanded row waited a round trip for its group
  screenshot. The prefetch now ends with those.

The decisions moved into LookInsideHostCore (ActiveRequests,
ResponseCountdown, AnsweredTaskRecord and
DetailFetchPlanning.wholeTreeRequests), and each test failed before its
fix. The DEBUG end-to-end dump can fetch the whole tree after its loads
(LOOKINSIDE_DEBUG_E2E_WHOLE_TREE=1) to time that.
modify.attributeNotFound told the agent to call read_attributes or to
open the view in the inspector, and the stale-object errors said to
refresh via get_hierarchy or to reload the inspector. read_attributes
and get_hierarchy only read what the host already holds, so an agent
that took the advice got the same error back. read_view_details and
refresh_hierarchy, which fetch from the app, landed alongside or after
those messages, and the messages were never updated. Since this branch
fetches attributes only for the rows the inspector has shown, agents
reach the missing-attribute error routinely.

The hints now live in MCPBridgeRecoveryHint and name the fetching tool
for each case. A view whose attributes are already in no longer sends
the agent to fetch them again: the error says the view has no such
attribute. Scripts/test-mcp-bridge.sh compiles the hints together with
MCPBridgeRecoveryHintTests, which failed on the old wording.
@Mx-Iris
Mx-Iris merged commit ece48ce into main Oct 19, 2026
2 checks passed
@Mx-Iris
Mx-Iris deleted the perf/refresh-measurement branch October 25, 2026 13:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants