Skip to content

perf(size): Parallelize Apple binary analysis across processes - #663

Merged
NicoHinderling merged 19 commits into
mainfrom
perf/parallel-binary-analysis
Sep 3, 2026
Merged

perf(size): Parallelize Apple binary analysis across processes#663
NicoHinderling merged 19 commits into
mainfrom
perf/parallel-binary-analysis

Conversation

@NicoHinderling

@NicoHinderling NicoHinderling commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

The per-binary Mach-O analysis loop ran serially and dominated wall-clock time on a large anonymized iOS app (hundreds of binaries) — a major contributor to hitting the 15-minute deadline.

The work is CPU-bound Python (LIEF, symbol sizing, demangling), so this parallelizes it with a ProcessPoolExecutor to get past the GIL (threads were tried first and regressed). Workers parse from disk and results stay in input order, so binary_analysis and the treemap are unchanged. Worker count is a fixed 4 to match the pods' CPU request — prod sets no CPU limit, so os.cpu_count() would read node cores and oversubscribe — overridable via LAUNCHPAD_BINARY_ANALYSIS_WORKERS, where 0 runs in-process with no pool at all (the rollback switch). Demangling runs serial inside workers to cap subprocess fan-out, and the LIEF pre-parse cache is removed since workers couldn't use it anyway.

Workers start with a bare interpreter, so each configures its own logging and initializes its own Sentry client with the parent's config, continuing the parent's trace from propagated headers. Per-binary started/completed logs (with elapsed_s) fire inside the worker at the moment of the work and reach both stdout and Sentry Logs under the build's trace. Workers also return start/end timestamps and the parent rebuilds the per-binary span from them, so the trace waterfall shows real overlap.

Timeout safety was the main review concern, since a worker wedged in native LIEF ignores SIGTERM. We traced how the taskbroker enforces the deadline: it's an in-process SIGALRM raising ProcessingDeadlineExceeded inside the task process — there's no external kill. Because the main process is idle in executor.map when it fires, our except calls executor.kill_workers() (new in 3.14), which SIGKILLs the wedged workers directly (verified under forkserver, prod's start method). If the main process is hard-killed instead (e.g. OOM), it's the container's PID 1, so the runtime tears down the whole cgroup and takes the workers with it. Either way, no orphaned binary-analysis subprocesses.

Benchmark — large anonymized app, 8 workers on a 10-core dev box (shipped default is 4, so real-world gain is a bit smaller):

Metric Serial This PR
Binary-analysis phase ~255s ~44s (~5.8×)
Total wall clock ~461s ~250s (~1.8×)

Verified with in-process-vs-parallel equivalence, worker-count, worker-logging (stdout with request id, third-party suppression, and delivery to a local Sentry ingest stub under the parent's trace), and rebuilt-span tests.

@sentry

sentry Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📲 Install Builds

iOS

🔗 App Name App ID Version Configuration
HackerNews com.emergetools.hackernews 3.8 (1) Release

Android

🔗 App Name App ID Version Configuration
Hacker News com.emergetools.hackernews 1.0.2 (13) Release

⚙️ launchpad-test-android Build Distribution Settings

@sentry

sentry Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Size Analysis

1 component analyzed, 1 component processing

iOS Builds

Name Configuration Version Download Size Install Size
HackerNews (iOS)
com.emergetools.hackernews
Release 3.8 (1) 6.5 MB (N/A) 9.7 MB (N/A)

Android Builds

Name Configuration Version Download Size Uncompressed Size
Hacker News (Android)
com.emergetools.hackernews
Release 1.0.2 (13) Processing... (-) Processing... (-)

Configure launchpad-test-ios status check rules

@NicoHinderling
NicoHinderling force-pushed the perf/parallel-binary-analysis branch from d5a4cfc to 23b4533 Compare September 2, 2026 17:48
@NicoHinderling NicoHinderling changed the title perf(size): Parallelize Apple binary analysis loop perf(size): Parallelize Apple binary analysis across processes Sep 2, 2026
Comment thread src/launchpad/size/analyzers/apple.py Outdated
Comment thread src/launchpad/size/analyzers/apple.py Outdated
Comment thread src/launchpad/size/analyzers/apple.py Outdated
Comment thread src/launchpad/size/analyzers/apple.py Outdated
@NicoHinderling
NicoHinderling force-pushed the perf/parallel-binary-analysis branch 4 times, most recently from 975e07a to 08d44f0 Compare September 2, 2026 19:48
Comment thread src/launchpad/size/analyzers/apple.py Outdated
Comment thread src/launchpad/size/analyzers/apple.py
Comment thread src/launchpad/size/analyzers/apple.py Outdated
@NicoHinderling

Copy link
Copy Markdown
Contributor Author

Resolved in f81d124

Bug: The size.apple.binary_analysis_started log events are now emitted in a single batch before analysis begins, making it impossible to measure per-binary analysis duration.Severity: LOW

Suggested Fix
Move the _log_binary_started call back inside the main analysis loop, immediately before the _analyze_binary call for each binary. This will restore the original behavior of bracketing each binary's analysis with 'started' and 'completed' log events, allowing for accurate duration measurement.

Prompt for AI Agent

Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/launchpad/size/analyzers/apple.py#L190-L191

Potential issue: The refactoring introduced a new loop that emits all
`size.apple.binary_analysis_started` log events before any binary analysis starts.
Previously, the 'started' and 'completed' logs for each binary were emitted
sequentially, bracketing the analysis work. With this change, all 'started' events have
nearly identical timestamps, fired before the actual analysis loop begins. This makes it
impossible to use these logs for per-binary progress tracking or to calculate the
duration of individual binary analyses, which is a regression in observability.

i did a simple solution for this for now: "Time each binary's analysis inside the worker that runs it and return that duration with the result, so the parent's per-binary "completed" log carries an accurate elapsed_s even though the log lines are emitted together."

If we do land this PR, I'd plan to cut a followup PR to make sure that all the subprocess logs are piped back to the parent process for o11y convenience (ex. all trace info would stay intact)

@NicoHinderling
NicoHinderling force-pushed the perf/parallel-binary-analysis branch from e1d2f05 to d14c6f9 Compare September 2, 2026 23:27
Comment thread src/launchpad/size/analyzers/apple.py Outdated
Comment thread src/launchpad/size/analyzers/apple.py Outdated
binary = self._analyze_binary(binary_info, app_bundle_path, lief_cache)
self._log_binary_started(binary_info, app_bundle_path, extract_dir)

if workers > 1:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Does this happen frequently? I imagine basically all apps at this point have more than one binary in them and we don't need to special-case this which will help simplify things a bit.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The more relevant part is whether we want to slow roll this out and keep the old logic side-by-side. I didn't see mention of a rollout plan in the description, are we going to hard cut over?

@NicoHinderling NicoHinderling Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

as for rollout, I was planning to merge but pause the pipeline so it only deploys to s4s2 and do some manual testing before fully rolling it out

The more relevant part is whether we want to slow roll this out and keep the old logic side-by-side.

I don't plan on doing a slow rollout. I've tweaked the logic to keep the old path if LAUNCHPAD_BINARY_ANALYSIS_WORKERS=0

that way we can killswitch it if we need to by setting that env var to 0

if all goes well, i'll clean up the old path in a week or something

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

before kicking this back for you to review again though, ill investigate wiring up sentry-options into launchpad :loading:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

#668
https://github.com/getsentry/sentry-options-automator/pull/9596
https://github.com/getsentry/ops/pull/23267

i've posted and sent ken these PRs to enable sentry-options within launchpad. Assuming he reviews them before eod, I'll merge and then switch the code to use this instead of the env var for convenience

I wont merge until eod but for now the env var = 0 killswitch should be valid

Comment thread src/launchpad/size/analyzers/apple.py Outdated
Comment thread src/launchpad/size/analyzers/apple.py Outdated
Comment thread src/launchpad/size/analyzers/apple.py Outdated
f"Found dSYM file for {binary_info.name} at {binary_info.dsym_path.relative_to(artifact.get_extract_dir())}"
)
binary = self._analyze_binary(binary_info, app_bundle_path, lief_cache)
self._log_binary_started(binary_info, app_bundle_path, extract_dir)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This logging is kind of awkward now since this for loop is purely to log everything at once. Could we maybe push these logs into _run_and_time itself so they come in when the analyzing actually happens? Otherwise I could see it being confusing that they don't actually align with when the work actually happened.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

i was considering doing this as a follow up pr but ended up just doing the bigger change now

workers now get a QueueHandler and the parent drains the queue and re-emits through its own handlers with the request id attached. So the per-binary started/completed logs fire inside the worker at the moment of the work, and both parent-side loops are gone.

Workers also return start/end timestamps and the parent rebuilds the per-binary span from them, so the trace waterfall shows real overlap.

Comment thread src/launchpad/size/analyzers/apple.py Outdated
},
)
gc.collect()
self._log_binary_completed(binary_info, binary, elapsed)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same with this logging.

Comment thread src/launchpad/size/analyzers/apple.py Outdated
Comment thread src/launchpad/size/analyzers/apple.py Outdated
logger.debug(f"Analyzing binaries in parallel with {workers} processes")
artifact.get_lief_cache().clear()
analyze = partial(self._analyze_binary, app_bundle_path=app_bundle_path, lief_cache=None)
executor = ProcessPoolExecutor(max_workers=workers, initializer=_binary_worker_init)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we confirm the peak memory usage here too, I would need to dig further but if it forks the parent process it could get pretty high.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

it doesn't fork the parent. agent test in the exact prod image that Python 3.14 on Linux defaults to forkserver, and nothing in launchpad overrides the start method. Forkserver workers are forked from a small helper process that starts fresh, not from the parent, so they inherit none of the parent's heap. I measured an idle worker at 15 MB earlier today. Each worker's memory is just the binary it's parsing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Measured peak memory properly this time: sum of RSS across the parent and every descendant, sampled every 0.5s, on the large app.

Path Peak tree RSS
In-process (LAUNCHPAD_BINARY_ANALYSIS_WORKERS=0) 2.44 GB
4 workers (default) 2.53 GB

~90 MB more, under 4%. The peak is dominated by parsing the single largest binary, which happens in one process either way; the other binaries in flight are small next to it.

@NicoHinderling
NicoHinderling force-pushed the perf/parallel-binary-analysis branch from 27cd376 to 40f4f41 Compare September 3, 2026 18:01
Comment thread src/launchpad/size/analyzers/apple.py
Comment thread src/launchpad/size/analyzers/apple.py

@cursor cursor Bot 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 876afea. Configure here.

Comment thread src/launchpad/size/analyzers/apple.py Outdated
Comment thread src/launchpad/size/analyzers/apple.py
Comment thread src/launchpad/size/analyzers/apple.py Outdated

class _TimedBinary(NamedTuple):
binary: MachOBinaryAnalysis | None
started_at: float

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: is float right? i think what you had before with the monotomic timing was correct

Comment thread src/launchpad/size/analyzers/apple.py Outdated
if self._thread.is_alive():
logger.warning("Worker log relay did not stop; a worker was likely killed mid-write")

def _drain(self) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I feel like there's got to be a much simpler way to do this but not sure the best practice. Like could we just pipe the stderr and stdout back to the main process and append the logs?

Comment thread src/launchpad/size/analyzers/apple.py Outdated
The per-binary Mach-O analysis loop ran strictly serially, so on apps with
hundreds of embedded binaries it dominated wall-clock time. The per-binary
work is CPU-bound Python (LIEF parsing, symbol sizing, Swift demangling),
so it is parallelized with a ProcessPoolExecutor to bypass the GIL — a
thread pool was tried first and regressed, since threads can't parallelize
GIL-bound Python.

Worker count defaults to min(8, cpu_count), overridable via
LAUNCHPAD_BINARY_ANALYSIS_WORKERS (1 forces the old serial path). Workers
re-parse each binary from disk (the LIEF cache is not picklable); results
are collected in input order, so binary_analysis and the treemap are
unchanged (new serial-vs-parallel equivalence test).

Timeout safety: a worker wedged in a native LIEF call ignores SIGTERM, so
the pool's graceful shutdown cannot stop it. On timeout/error we SIGKILL
workers directly, and on Linux each worker sets PR_SET_PDEATHSIG=SIGKILL so
it dies with the parent if the job is force-killed — no orphaned workers.
Also fixes a latent crash where the completion log dereferenced a None
result. Trade-off: peak memory rises with worker count.
Addresses review feedback on the parallel binary-analysis path:

- Pin the outer worker default to 4 (the pod's whole-core CPU request)
  instead of min(8, os.cpu_count()); there is no CPU limit, so cpu_count
  reads node cores and oversubscribes. Env override still wins.
- Disable per-binary demangle parallelism inside workers, so the outer
  pool can't multiply into dozens of concurrent cwl-demangle subprocesses.
- Clear the parent's LIEF cache before the pool runs; workers re-parse
  from disk, so the retained trees were dead weight held for the whole run.

Add a forkserver test so the prod start method (which pickles the analyzer
and binaries) is exercised; the fork default masked pickling. Harden the
force-kill test's wait to poll rather than assume a single 5s window.
The backstop was meant to reap a LIEF-wedged worker if the parent died
without running cleanup. It does nothing under the forkserver start method
that prod uses (Python 3.14): the worker's parent is the forkserver helper,
not the main process, so main's death never triggers the death signal.
Verified on Linux/3.14 — the worker orphans with or without it.

It was never load-bearing anyway. The 15-min deadline is an in-process
SIGALRM raising ProcessingDeadlineExceeded in the task process, which is
blocked in executor.map (pure Python, not wedged in C) and so always lands;
our except BaseException runs _force_kill_pool, which SIGKILLs the workers
directly regardless of start method. Keep the initializer for the demangle
env var.
_force_kill_pool runs from the except block that re-raises the original
failure (e.g. ProcessingDeadlineExceeded). Its executor.shutdown() was
unguarded, so if it raised it would replace that exception. Workers are
already SIGKILLed by then, so make the shutdown best-effort.
Parallelizing moved per-binary work into workers, so the started/completed
log timestamps are now batched and per-binary duration couldn't be read
from them. Time each binary inside the worker that runs it and surface the
elapsed seconds in size.apple.binary_analysis_completed, so per-binary
durations remain measurable in both the parallel and serial paths.
Python 3.14 exposes kill_workers(), which does exactly what the hand-rolled
_force_kill_pool did (non-blocking shutdown, then SIGKILL each live worker).
Use the stdlib method and drop the helper and its test.
Read the env override with a default of 4 and clamp the result instead
of branching on whether the override is set.
The parallel path cannot use it (LIEF objects do not pickle and forkserver
workers do not inherit parent state), which left it as dead weight the
parent had to clear by hand. Only single-binary apps and the workers=1
override still read it, and there the cost is one extra parse per dSYM
binary. Remove it and let every path parse on demand.
Drop the in-process fallback for a single binary or workers=1. A
one-worker pool costs about half a second to start under forkserver,
which is noise against any binary's analysis, and one code path is
easier to reason about than two. The env override remains as a plain
worker-count knob.
Setting LAUNCHPAD_BINARY_ANALYSIS_WORKERS=0 runs binary analysis in the
task process with no subprocess layer at all, matching the pre-parallel
behavior. This gives ops a kill switch that removes the process pool
entirely rather than just shrinking it to one worker.
Pool workers start with a bare interpreter, so their log records were
dropped and the parent had to log on their behalf: a burst of started
lines before the pool and a burst of completed lines after it. Give each
worker a QueueHandler and drain the queue in the parent, re-emitting
records through its own handlers with the parent's request id attached.
The per-binary started and completed logs now fire inside the worker at
the moment the work happens, and the parent-side logging loops are gone.
The relay stopped its drain thread by putting a sentinel on the queue
from the parent. That first put starts a feeder thread which must take
the queue's shared write lock, and a worker SIGKILLed while holding that
lock would block the feeder forever; multiprocessing then joins it with
no timeout at interpreter exit, hanging the task process. Stop the drain
thread with an Event and a polling get instead, so the parent never
writes and no feeder thread is ever started.
Pool workers have no Sentry client, so the span @sentry_sdk.trace put on
_analyze_binary vanished from the task transaction on the parallel path.
Have the worker return start and end timestamps with each result and
open the equivalent span in the parent with those timestamps, so the
transaction waterfall shows every binary and their real overlap. The
in-process path still produces the decorator's own span.
Logger.handle skips the logger's own level check, so a worker's INFO
records from third-party loggers such as lief sailed past the WARNING
suppression setup_logging applies in the parent. Gate re-emission on
isEnabledFor. Also isolate per-record failures so one undecodable record
cannot stop the relay for the rest of the run, and warn if the drain
thread fails to stop after the pool is torn down.
Keep elapsed_s on time.monotonic so a clock adjustment cannot skew it,
and use a single wall-clock reading only to anchor the rebuilt Sentry
span, deriving its end from the monotonic duration.
Prototype of the simpler design suggested in review. Pool workers inherit
the task's stdout, so each worker configures its own logging with
setup_logging, adopts the parent's request id, and initializes its own
Sentry client with the parent's config, continuing the parent's trace
from propagated headers. Worker logs reach GCP/Datadog via stdout and
Sentry Logs via the worker's client, both under the build's trace, and
the queue-based relay, its drain thread, and its teardown edge cases go
away. Each worker flushes its client after every binary so a deadline
kill loses at most the binary in flight.
Move the per-binary gc.collect into the shared wrapper so the pool path
bounds memory like the in-process path, drop the LAUNCHPAD_ENV guard now
that any launchpad-initialized Sentry client implies it is set, annotate
extract_dir as the SafeDirectory it receives, hoist the default worker
count to a constant, and fix wording left over from the LIEF cache
removal. Fold the worker-count tests into one, drop two tests subsumed
by the stdout and ingest tests, and assert workers emit no transactions.
@NicoHinderling
NicoHinderling force-pushed the perf/parallel-binary-analysis branch from 8462c22 to f3e6354 Compare September 3, 2026 22:18
Comment thread src/launchpad/size/analyzers/apple.py
@NicoHinderling
NicoHinderling merged commit a37dd71 into main Sep 3, 2026
26 checks passed
@NicoHinderling
NicoHinderling deleted the perf/parallel-binary-analysis branch September 3, 2026 22:31
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