Skip to content

Make dictation latency measurable and gated, and cut what the measurements justified - #1704

Open
r3dbars wants to merge 5 commits into
mainfrom
claude/tilde-performance-optimization-650442
Open

Make dictation latency measurable and gated, and cut what the measurements justified#1704
r3dbars wants to merge 5 commits into
mainfrom
claude/tilde-performance-optimization-650442

Conversation

@r3dbars

@r3dbars r3dbars commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Why

The app's dictation start path is 3-4x over its own latency budget in production, and the check that would have caught it was dark: the budgets in performance-budget.rb only evaluated inside if required_samples.positive?, --require-dictation-fast-start-samples defaults to 0, and build.sh never passed --events at all. On top of that, every stage timing on that path was being written to events.jsonl as [redacted-sensitive-value], so the tail could not be attributed to a stage even by hand.

Measured on this machine over 176-203 real dictations (v1.1.56):

metric p50 p95 p99 budget (p95)
request_to_recording_ms 107 1089 1685 250
start_to_first_sample_ms 198 814 1097 350
dictation fast-start 711 991 250
stop → text in editor 267 450 741 750
decode 148 307 451
launch → interactive 238 268 282 400 (target)
Home list @ 10k captures 183 750

Launch, the Home loader, and the transcription model are all comfortably inside budget. Dictation start is not, and at p95 that means losing most of a second of speech.

Product Impact

  • Affects: dictation
  • Lane: dictation reliability
  • Why this matters: start latency is the difference between the app catching your first words and eating them. It was unmeasurable and ungated; now it is both.

What changed

Five commits, each independently revertible.

  • Keep numeric measurements readable in the on-disk event log. The local sanitizer drops any key containing audio by substring, which swallowed audio_engine_start_ms, audio_tap_install_ms, audio_input_total_ms and seven more in 197/197 events. A bare number under a _ms/_s/_hz/_bytes key now survives; anything non-numeric still redacts, so audio_device_ms stays redacted. Local sink only — Sentry, Analytics, and reliability packets sanitize mergedContext separately through their own allowlists.
  • Cut two measured costs. wavData appended 2 bytes per sample through a generic helper (4.8M round trips at the 5-minute cap); one contiguous Int16 block is 21x faster (2.58 → 0.12 ms at 10s, 82.0 → 3.7 ms at 5 min) and byte-identical. AccessibilityBridge made 5 unbounded AX round trips on the main actor gating the overlay's first pixel; now capped at 2s on the application element.
  • Add repeatable benchmarks with p99bench-launch-latency.sh, latency-percentiles.py, bench-all.sh.
  • Actually enforce the latency budgets in build.sh.
  • Strip and prune the release bundle — 88.3 → 65.9 MiB.

On the gate's thresholds

Turning it on at the real targets fails immediately — that is the finding. So build.sh uses ratchet ceilings set just above today's measured latency (fast-start 900ms, request-to-recording 1300ms, start-to-first-sample 1000ms). It catches any further regression, which is strictly more than the nothing it caught before. The real targets stay as the defaults in performance-budget.rb, the gap is written down in one comment, and the ceilings may only ever move down. This matches how build.sh already overrides --max-app-mb and --max-launch-interactive-ms for its surface.

Two supporting changes were needed to make that safe:

  • A budget now scores itself once the log carries 20+ samples; below that it is skipped, not failed — a thin log means "not enough data to judge", not "regression".
  • --min-transcription-samples was an assertion, not a floor, so any sparse log hard-failed with Only 2 transcription samples, need at least 10. That would have broken build.sh on a fresh checkout or after a quiet fortnight. It is now a scoring floor, with --require-transcription-samples added for callers that want the assertion.

A failing run now prints the full summary on stdout before the reasons on stderr (needs an explicit flush — stdout is block-buffered when piped). Scoped to a 14-day window since events.jsonl is append-only across app versions. No log means no scoring, so CI and fresh checkouts stay green. TRANSCRIPTED_SKIP_RUNTIME_BUDGET=1 opts out.

How I checked it

  • scripts/dev/agent-preflight.sh
  • Selected checks from .agents/test-matrix.yml for the files changed
  • bash build.sh --no-open
  • bash run-tests.sh — 12,223 passed, 0 failed
  • Performance budget passed
  • bash run-integration-smoke.sh — n/a, no Sources/Meeting/ or Sources/TranscriptedCore/ changes
  • swift test — n/a, no Package.swift or core seam changes
  • Also run: bash build-deps.sh --force, SKIP_NOTARIZATION=1 bash build-beta.sh, python3 scripts/dev/check-build-source-lists.py, python3 scripts/dev/check-analytics-emitters.py, ruby -c, bash -n on both build scripts
  • Manual check — gate behavior matrix, all five as expected: passes on the ratchet; fails when a ceiling drops below measured; skips on an empty log; skips on 2 samples; fails on 2 samples with --require-transcription-samples 10
  • Manual check — byte-identity of the WAV encoder over 600,432 adversarial floats (signaling NaN, ±inf, subnormals, out-of-range, −0.0, every round-half tie), zero mismatches
  • Manual check — release bundle: strip -x 30,363,584 → 17,045,424 bytes, app and dSYM LC_UUID identical, codesign --verify --deep --strict clean, no leftover Headers/Modules

Caveat on the beta build: run as a thin build (REQUIRE_BUNDLED_PARAKEET_MODELS=0 BUNDLE_DIARIZER_MODELS=0) because this machine has no local Parakeet/diarizer model cache. Strip, framework prune, signing, and dSYM pairing are all verified; model bundling is not.

Risk Review

  • Privacy / local-first behavior reviewed — the sanitizer change is the only privacy-relevant edit. It is scoped to the on-disk sink, gated on both a measurement-suffixed key and a bare-numeric value, and covered by 11 new tests including negative cases (device labels, file paths, transcript text, unit-suffixed numbers, exponent notation, grouped digits under _ms keys). Off-device payloads are untouched.
  • Storage path or migration impact reviewed — none.
  • Public-facing copy stays concrete and matches current product scope — no user-facing copy changed.
  • Release/update impact reviewed — build-beta.sh changes are release path. LC_UUID preserved so Sentry symbolication and the dSYM-UUID gate still pass; helper binaries deliberately not stripped.
  • Agent PRs link the issue/workpad and stay draft until human review — no linked issue; opened as draft.
  • UI changes include sanitized .agent-review/visuals/ evidence — n/a, no UI changes.
  • No private transcripts, audio, tokens, personal paths, or customer data are included — the measurements quoted are aggregate percentiles only.

Notes

Six of the eight highest-ranked candidates were rejected under adversarial review, and that is worth as much as what landed:

  • Cache the mic TCC check — ranked refactor(ui): Split FloatingPanel.swift into 10 organized files #2 on a measured 15.5ms per call, twice per start. That measurement was taken from a Terminal child process; re-measured inside a LaunchServices-launched bundle the real steady-state cost is ~2.4µs. A ~6000x methodology error; the entire prize was ~7µs against a stale-denied bug.
  • Start hands-free dictation on key-down — nominally the largest win (80-250ms). Reverts 654aa15b under the defaults that motivated it and breaks two pinning tests. Hands-free is a toggle: copying push-to-talk's still-held gate makes a quick tap fail to stop a running dictation; dropping it spawns phantom dictations whenever ⌥M is held past 140ms. The "win" is the user's own finger dwell, and a 140ms disambiguation timer is slower than a typical 70-120ms tap.
  • AXUIElementSetMessagingTimeout on the paste-confirmation call — that one is on the system-wide element, where it sets the process-global default rather than bounding one call; and the query is a guard, so failing it fast routes into the branch that leaves the clipboard borrowed, turning rare stalls into systematic clipboard clobbering on slow Electron/JetBrains targets.
  • Drain audio before mic teardownstop_to_mic_stop is 38% of felt stop latency and produces nothing for the user, but the 50ms sleep inside it guards a documented fatal CoreAudio assertion (isSink || tap != nullptr) on a path no CI job exercises.
  • WAL + synchronous=OFF on the Home metadata cache — justified by a 200-row benchmark; the real cold-load workload is ~11 rows (meetingLimit = 10) on a background task.
  • Async runtime-diagnostics writes — coalescing lets a pending write land after markCleanShutdown() and rewrite cleanShutdown: false, poisoning a live release-health metric.

Known measurement gaps, not addressed here:

  • Stop latency is emitted only on the success branch, so every reported p99 (including the table above) is survivor-biased — the true tail is worse. Fixing it belongs in a separate dictation_stop_abandoned event, not by widening dictation_stop_latency_measured, which the release gate selects on.
  • stop_to_mic_stop_ms fuses a queue hop, the engine stop, the 50ms sleep, and a 1.5s CoreAudio restore into one field; paste_prepare_ms fuses the AX capture with the pasteboard snapshot. Those are the two largest p99 sources on the stop path and each is reported as a single number.
  • build.sh's launch smoke reported 758ms on builds where every isolated measurement landed at 220-290ms; I could not reproduce it across fresh HOME, fresh bundle copy, fresh code signature, or system load. swift-ci.yml:270 already documents a 48% swing on identical commits and suggests min-of-N sampling — bench-launch-latency.sh now does exactly that, so swapping the smoke for it is a small follow-up.

Next step worth more than any speculative optimization here: run a few dozen dictations on a build with the sanitizer fix, then read the start-path stage breakdown. It is measurable for the first time.

Agent handoff

COORD_DONE: GREEN | this PR | unredacted start-path timings, 21x WAV encode, bounded overlay AX query, live latency gate in build.sh with ratchet ceilings, release strip+prune (88.3->65.9 MiB), 3 new benchmark harnesses | no GitHub cleanup needed | decisions: ratchet ceilings vs hard-failing on the real targets, and whether to ship the survivor-bias fix as a separate event | checks: agent-preflight, build.sh --no-open, run-tests.sh (12223 pass), build-deps.sh --force, build-beta.sh thin, check-build-source-lists, check-analytics-emitters, ruby -c, bash -n, gate behavior matrix, WAV byte-identity over 600k adversarial floats | smallest next action: collect dictation samples on a build with the sanitizer fix and read the start-path stage breakdown

r3dbars and others added 5 commits August 24, 2026 19:49
The local sanitizer drops any context key containing "audio" via substring
match, which also swallowed every stage timing on the dictation start path:
audio_engine_start_ms, audio_tap_install_ms, audio_input_total_ms and seven
more were written to events.jsonl as "[redacted-sensitive-value]" in 197 of 197
events. The app measured the slowest user-visible path in the product and then
threw the measurement away, so the start-latency tail could not be attributed
to a stage.

A bare number under a duration/size/rate key carries no identifying content, so
keep the value. Both halves of the test matter: the suffix keeps the escape away
from identifier-shaped keys, and the numeric check keeps it away from any key
whose value is a device or file name — "audio_device_ms" still redacts.

Local sink only. Sentry, Analytics, and the reliability packets sanitize
mergedContext separately and through their own allowlists, so off-device
payloads are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wavData appended two bytes per sample through a generic helper that pays a
withUnsafeBytes closure and a Sequence append every time. At the 5-minute
dictation cap that is 4.8M round trips. Building one contiguous Int16 block and
appending it once measures 21x faster: 2.58 -> 0.12 ms at 10s of audio,
16.8 -> 0.75 ms at 60s, 82.0 -> 3.7 ms at 5 minutes. This runs on the awaited
recovery checkpoint, before the model wait, so it is on the path between the
user releasing the key and text appearing.

Output is byte-identical. Verified over 600,432 adversarial floats — NaN
payloads including signaling NaN, +/-infinity, subnormals, values outside
[-1,1], -0.0, and every representable round-half tie across the Int16 range —
with zero mismatches. The arithmetic is deliberately unchanged; the NaN guard,
the clamp, Float(Int16.max), and bare .rounded() are all load-bearing for that.

AccessibilityBridge makes five synchronous AX round trips into the frontmost
app with no messaging timeout, on the main actor, and gates the dictation
overlay's first pixel. A target whose main thread is wedged stalled the overlay
for the AX default. Cap it on the application element, which scopes the timeout
to messages for that app — deliberately not the system-wide element, where it
would replace the process-wide default for every AX call the app makes. 2s
matches the value already used in Tools/TranscriptedQA; the job is to bound a
hung app, not to fail fast on a slow one. A timeout degrades to the existing
placement fallback (anchor rect, then mouse location), which is cosmetic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The only launch measurement in the repo was a single sample taken by build.sh,
which cannot separate a regression from scheduler noise — swift-ci.yml already
documents a 48% swing on identical commits. And nothing aggregated the raw
per-stage timings that events.jsonl has been carrying all along.

- bench-launch-latency.sh runs N isolated launches against a copied bundle and
  reports min/p50/p90/p95/p99/max. Percentiles are nearest-rank so every
  reported number is a launch that actually happened, and the output says
  plainly when p99 of a small sample is really just the observed max.
- latency-percentiles.py reports the full distribution for every latency key in
  an events log, sorted by p99 so the stage owning the tail reads first. It
  also counts keys the sanitizer redacted, so an observability gap shows up as
  a gap instead of silently missing rows.
- bench-all.sh runs the launch, Home-loader, and real-usage benchmarks together
  and writes JSON per run for comparison. Each section records its own status
  so an unavailable benchmark reports as skipped rather than aborting the run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
performance-budget.rb has carried budgets for dictation fast-start,
request-to-recording, start-to-first-sample, and stop latency for a while, but
they only evaluated inside `if required_samples.positive?` — and
--require-dictation-fast-start-samples defaults to 0. Passing --events printed
the percentiles and enforced nothing, and build.sh never passed --events at
all. Scored against this machine's log, the budgets are currently exceeded by
3-4x (fast-start p95 711ms against 250ms, request-to-recording p95 1089ms
against 250ms, start-to-first-sample p95 814ms against 350ms).

- A budget now scores itself once the log carries MIN_DICTATION_LATENCY_SAMPLES
  (20). Below that it is skipped rather than failed, because a thin log means
  "not enough data to judge", not "regression". --require-*-samples now means
  only "also fail if the samples are missing".
- --min-transcription-samples was an assertion, not a floor, so any sparse log
  hard-failed with "Only 2 transcription samples, need at least 10". That would
  have broken build.sh on a fresh checkout or after a quiet fortnight. It is now
  a scoring floor, with --require-transcription-samples added for callers that
  genuinely need the assertion.
- The fallback/retry check gets a threshold flag instead of an implicit zero,
  and only runs once there is a fast-start population to compare against.
- p99 is reported beside p95 everywhere, and a failing run prints the full
  summary on stdout before the reasons on stderr. stdout is block-buffered when
  piped, so this needs an explicit flush or the reasons print above the
  measurements they refer to. A gate you have to act on is useless if failing it
  hides the numbers.
- build.sh passes --events when a local log exists, scoped to the last 14 days
  because events.jsonl is append-only across app versions. No log means no
  scoring, so CI and fresh checkouts stay green. TRANSCRIPTED_SKIP_RUNTIME_BUDGET=1
  opts out; TRANSCRIPTED_EVENTS_LOG points elsewhere.

The ceilings in build.sh are a ratchet, set just above today's measured latency
rather than at the targets, so the gate catches any further regression — which
is strictly more than the nothing it caught before. The targets remain the
defaults in performance-budget.rb, the gap between the two is written down in
one comment, and the ceilings may only ever move down. This matches how build.sh
already overrides --max-app-mb and --max-launch-interactive-ms for its surface.

CI is unaffected: swift-ci.yml does not pass --events.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app binary was never stripped and the vendor frameworks shipped their
build-time payload. Measured on this tree: strip -x takes the binary from
30,363,584 to 17,045,424 bytes (12.7 MiB, 43.9%), and the Headers/Modules
directories are another 9.7 MiB, nearly all of it Sentry's .swiftmodule. Bundle
goes 88.3 -> 65.9 MiB.

The strip sits inside the GENERATE_DSYM branch, after the dSYM-exists check.
That placement is deliberate: with GENERATE_DSYM=0 there is no dSYM, so the
binary's own symtab is the only symbolication source and must survive. -x keeps
external symbols and LC_UUID, so the dSYM pairing that
register-sentry-release.sh and the packaged-app dSYM-UUID gate depend on is
preserved — verified identical UUIDs on the built bundle.

The framework prune runs before sign_embedded_payloads, so the signature seals
the trimmed contents. The second rm handles versioned bundles, whose top-level
Headers/Modules are symlinks into Versions/Current; leaving them dangling trips
codesign --verify --strict.

Contents/Helpers/* is deliberately not stripped — bundle_mcp_server has no
dsymutil step, so stripping it would destroy the only symbolication that exists
for MCP helper crashes, to save 0.75 MB compressed.

This is installed footprint, not latency. dyld does not page the symbol table,
and the DMG delta is far smaller since zlib compresses symbol tables well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@r3dbars
r3dbars marked this pull request as ready for review August 25, 2026 01:52
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.

1 participant