← Blogs

Why concurrency didn't speed up our macOS accessibility-tree reads (and how we proved it)

July 2026

Hunch drives a Mac for an LLM without touching the screen. To do that, it reads the macOS accessibility tree, the same structured description of every window and control that VoiceOver uses, and turns it into a compact text outline the model can point at. The whole interaction is a loop: read the tree, act on an element, read the tree again. So the read has to be fast, because the agent does it constantly.

We had a nice optimization for that read: a concurrent prefetch that fanned the accessibility API calls out across a thread pool. It looked obviously right. It had a clean design, a fallback path, and a comment explaining how it beat the IPC bottleneck.

Then someone asked whether it had actually been benchmarked. It hadn't. So we measured it, and the optimization turned out to be a ~20-26% slowdown. This post is the investigation that followed: how we confirmed the regression, and the sequence of experiments that traced it from "concurrency doesn't help" all the way down to the one line of macOS architecture that made it impossible. Along the way we killed our own leading hypothesis with a measurement.

The cost model: every attribute read is a round-trip

The macOS accessibility API (AXUIElement) is not a struct you read from memory. Every attribute you fetch is a synchronous Mach message to the process that owns the element. Ask Finder for a file cell's title, and you send a message, block, and wait for Finder to answer. One attribute, one cross-process round-trip.

That is fine for a dialog with a dozen controls. It is catastrophic for a real tree. Our first probe: generate a folder of 800 files, open it in Finder's list view, and walk it. The tree had 823 rows and 8,159 nodes, and a naive walk reading attributes one at a time is issuing tens of thousands of blocking round-trips in series. This is the "reading the Mail inbox takes minutes" problem, and for an agent that wants to read, act, and re-read in a loop, minutes is the same as broken.

There are only two honest ways to make N serialized round-trips faster: issue fewer of them, or stop serializing them. We tried both. Only one worked.

Lever one: batching (this is the whole win)

macOS ships a batched accessor, AXUIElementCopyMultipleAttributeValues, that returns several attributes of one element in a single round-trip. Our reader needs eight attributes per node (role, title, description, value, enabled, position, size, children), so batching collapses eight round-trips into one.

The effect is not subtle:

tree one-attr-at-a-time batched (one call per node) speedup
Finder window (~281-node compact walk) 4.6s 0.90s 5.1x
Mail inbox 4.2s 0.60s 7.1x

That is the optimization. Batching does not fight the round-trip cost, it removes round-trips, and it does it without any concurrency, thread-safety worries, or extra moving parts.

Lever two: the concurrency bet

Batching still leaves you with one round-trip per node, issued serially by a depth-first walk. The obvious next move: overlap them. Reading node A's attributes and node B's attributes are independent operations, both bound on IPC latency, so fire them concurrently and hide the waiting.

So we built a two-phase walk. Phase A, a wave of breadth-first prefetching that submitted every node's batched read to a ThreadPoolExecutor and cached the results. Phase B, the normal depth-first assembly, now reading attributes out of the cache instead of blocking on IPC. The theory was multiplicative: 5-7x from batching, times some N from concurrency.

It shipped as the default (HUNCH_WALK_WORKERS=8). It had a graceful serial fallback. It had a confident code comment. It had never been benchmarked against the serial path. That last part is the whole story.

Confirming the regression: the traversal benchmark

The first experiment was a straight three-way comparison on the same real tree, with three read strategies holding everything else constant:

  • naive: one attribute per call, serial.
  • batched-serial: batched reads, one worker (no concurrency).
  • batched-wN: batched reads across N threads (the prefetch).

To keep it honest we ran interleaved trials (rotating the config order each round so app-side warmup spread evenly), and asserted the emitted tree text was byte-identical across every config, so we were comparing speed on identical output.

The result, on Finder and on a real Mail inbox:

  • batched-serial beat naive by 5-7x (the batching win, confirmed).
  • batched-w8 (the shipping default) came in at 0.79x to 0.9x versus batched-serial, at every tree size we tried.

Not "no faster." Slower. Adding eight threads to the batched read consistently gave back a fifth to a quarter of the speed. The optimization we were shipping by default was a regression against the one-line change it was built on top of.

But a slow end-to-end number does not tell you why. The concurrent path does more than just thread the reads: it also prefetches breadth-first, over-fetching some nodes the depth-first walk never needs. Was the loss from the threading, or from the over-fetching? Time to isolate.

Isolating the primitive: the parallelism probe

To separate "threading doesn't help" from "the prefetch does extra work," we stripped the traversal away entirely and measured the raw primitive. Gather a fixed flat list of 4,000 real accessibility elements once, then read all their attributes two ways: serially, and across a thread pool. Same elements, same reads, no prefetch, no assembly, no over-fetching. Just: does threading these reads make them faster?

Reading 4,000 elements: speedup vs thread count 1.0× 1.00×serial 1.19×w2 1.15×w4 1.10×w8 1.05×w16 1.06×w32
Serial 2.686s baseline. Speedup peaks at 1.19x (2 workers) and degrades toward 1.05x as threads are added.

The primitive barely parallelizes: a best case of 1.19x at two workers, sliding down to ~1.05x by 32 workers. That downward slope is itself a fingerprint. It says you have run out of parallelism to exploit and are now just paying for threads (scheduling, contention, futures overhead). For reference, thread-pool submission overhead measured at 6.5µs per task against a 140µs read, so the pool machinery itself is cheap; the problem is that there is nothing to overlap.

So the end-to-end regression was over-determined: the prefetch over-fetches ~6x the nodes the walk needs, and threading can't overlap the reads, so you do more work at no parallelism and come out behind. But we still did not know the mechanism. Two suspects remained, and they call for completely different fixes:

  1. Client-side (the GIL): pyobjc holds Python's Global Interpreter Lock through the call, so the threads serialize on the interpreter. Fixable with free-threaded Python or a native reader.
  2. Server-side: the target app answers accessibility queries on one thread, so the requests queue there no matter what the client does. Not fixable from our side at all.

We assumed it was the GIL. We were wrong, and here is how we found out.

Killing the GIL hypothesis: a bracket test

To ask "does this call release the GIL?" directly, we built a bracket. Run a pure-Python counter thread that does nothing but increment in a loop (pure GIL-bound work), and measure its throughput two ways: alone, and while a second thread runs some competitor workload. The ratio (paired throughput / solo throughput) tells you what fraction of the competitor's runtime the GIL was available to other threads.

The trick is calibration. Bracket the accessibility call between two known quantities:

  • A competitor that is a pure Python spin loop (definitely holds the GIL). Two CPU-bound threads share the interpreter roughly 50/50, so the counter should run at about half speed. This is the floor.
  • A competitor that calls time.sleep in a loop (definitely releases the GIL while blocked). The counter should run at nearly full speed. This is the ceiling.

Then drop the real accessibility reads in and see where they land.

GIL availability during the AX call (paired / solo) 1.0 0.5 0.51python spin 0.98AX reads 1.01time.sleep
Floor 0.51 (GIL held), ceiling 1.01 (GIL free). Accessibility reads land at 0.98, right at the ceiling.

The accessibility reads came in at 0.98, essentially at the GIL-free ceiling. While one thread hammered accessibility reads, the pure-Python counter thread ran at 98% of its solo speed. That can only happen if the reading thread spends almost all of its time not holding the GIL, blocked in the Mach call with the lock released.

So the GIL was not the problem. Our leading hypothesis, the one we had half-written into the code comments, was measurably false. And that single number reframed everything: if the GIL is released but eight threads still don't overlap (the parallelism probe), then the serialization has to be somewhere the threads all share. Somewhere outside our process.

The positive control: reading many apps at once

By elimination the suspect was the target app's single-threaded accessibility responder. But elimination is not proof. We wanted a positive control: a setup where, if the theory is right, concurrency should scale, next to one where it should not.

The design: hold the total read volume constant, and distribute it two ways.

  • Across apps: gather elements from five different applications and read each app's elements on its own thread. Five different processes, five different main threads. If the bottleneck is per-app, this should scale.
  • Within one app (the control): take one app's elements and split them across the same number of threads. One process, one main thread. This should not scale.
Same reads, distributed two ways (speedup) 1.05×within 1 app (5 threads) 4.94×across 5 apps
685 reads total. Split across five processes: 0.354s to 0.072s (4.94x). Split across five threads on one process: 0.277s to 0.264s (1.05x).

The identical read volume scaled 4.94x across five processes and 1.05x within one. Two facts drop out of it at once: Python threads absolutely can overlap accessibility reads (or the across-apps case could not hit ~5x), and the serialization is strictly per-process. When the reads target one app, its single main thread answers them one at a time and the threads just queue at its door.

The root cause, and why no client-side fix helps

The reason the responder is single-threaded is not laziness, it is correctness. AppKit UI state is owned by the main thread. Servicing an accessibility query means reading live UI state, so it happens on the main run loop, because answering on a background thread would race the app mutating its own interface. The single-threaded accessibility responder is load-bearing for consistency.

This is why the distinction between the two hypotheses mattered so much. If it had been the GIL, we would have had options: a free-threaded Python build, a native reader in Rust or Swift, a leaner binding. Because it is the server, none of those help. Free-threaded Python removes your interpreter lock, but the bottleneck is in Finder's process, not yours. You cannot make another app answer two accessibility queries at the same time by changing your own runtime. Concurrency can only ever help by reading different apps at once, which is not what walking one window's tree does.

Why batching is the right lever and threading is the wrong one

Step back and the whole result has a clean shape. Batching and concurrency attack the same cost, round-trip latency, in opposite ways:

  • Concurrency tries to overlap the serialized operations. It gets blocked, because the operations are serialized twice: at the interpreter (weakly) and at the target app's main thread (completely).
  • Batching reduces the number of serialized operations. Eight attribute-messages become one. It never fights the serialization, it just sends fewer messages to the single-threaded server.

The right move against a serialized bottleneck is to do less of it, not to try to parallelize it. That is a general lesson worth carrying past this specific API: when a resource is fundamentally serial, throughput comes from shrinking the work, not from adding workers.

The unglamorous part: the fixtures fought back

The measurements above look clean. Getting them clean was most of the actual work, and if you go to reproduce this, these are the traps.

  • A virtualization red herring. Our first Finder fixture reported the same node count (~281) regardless of folder size, which looked like Finder was capping the tree. It was not; a bug in our harness was resolving the wrong window. The lesson was to print and assert the fixture's actual size before trusting a single measurement.
  • Window stacking and focus theft. The first harness opened a new Finder window per fixture size without closing the previous one, so windows layered up and stole focus, and the "focused window" we resolved was ambiguous. A benchmark for a focus-free system should not itself be raising windows.
  • Background accessibility teardown. When we switched to opening windows in the background to avoid the focus theft, the numbers went haywire, with element handles flickering between full and empty across trials. macOS tears down a backgrounded window's accessibility subtree (the same thing Electron and Chromium apps do), so the tree kept vanishing mid-measurement. The fix was to keep the fixture window frontmost and stable, and to discard any trial whose node count collapsed.

None of that changed the conclusion, because the decisive experiment (the parallelism probe) held a fixed list of element handles and never depended on window state. But it is a good reminder that with UI automation, the system under test moves while you measure it, and a lot of "surprising" performance data is really the fixture misbehaving.

What we did, and what it left behind

We deleted the concurrent prefetch. The whole thread-pool machinery, the cache, the worker knob, and the fetch plumbing threaded through the walk came out, about 128 lines net, and the tree walk is serial-only now. The output is byte-identical, snapshots are ~20-26% faster in the default configuration than what we had been shipping, and the traversal code is easier to read because every attribute read is just a direct batched call with no indirection.

The thing that started all of it was a single question: is this actually tested? It was a plausible optimization, written with care, shipped as the default, and it was a regression the whole time. The only reason we know is that we stopped trusting the design and measured it, then kept measuring until we understood not just that it was slow but why, precisely enough to know that no clever fix would rescue it.

If you are optimizing against an OS API you did not write, that is the pattern worth stealing. Isolate the primitive so you are measuring one thing. Calibrate against known extremes so a single number can falsify a hypothesis (the bracket test is the move here). And build a positive control, a setup where the effect should appear, so that its presence in one case and absence in another localizes the cause. We went in assuming a client-side GIL problem and came out with a server-side architectural ceiling, and the only thing that turned the assumption into knowledge was refusing to stop at the first slow number.

← Blogs