Benchmarking Ghidra Headless Analysis: Decompilation Throughput Under Multi-Core Parallel Load
When building automated malware analysis pipelines or CI/CD security gates that decompile binaries at scale, Ghidra's headless mode becomes the workhorse. But how does it perform under sustained concurrent load? I ran a series of stress tests to measure throughput, memory consumption, and tail latency.
Test Topology
Hardware: AWS c5.4xlarge (16 vCPU, 32GB RAM)
Ghidra Version: 10.4
Test Harness: Python concurrent.futures.ProcessPoolExecutor driving headless analysis
Sample Binaries: 50 ELF executables (2–8 MB each, mixed architectures: x86-64, ARM, MIPS)
Concurrency Levels: 1, 4, 8, 12, 16 parallel workers
Measured Metrics: Total runtime, per-binary p95/p99 latency, peak RSS memory
Benchmark Results
| Workers | Throughput (binaries/min) | p95 Latency (s) | p99 Latency (s) | Peak Memory (GB) |
|---|---|---|---|---|
| 1 | 3.2 | 18.4 | 21.2 | 4.1 |
| 4 | 11.8 | 20.1 | 24.6 | 14.3 |
| 8 | 19.4 | 23.7 | 29.8 | 26.7 |
| 12 | 22.1 | 28.3 | 36.4 | 30.2 |
| 16 | 23.5 | 32.1 | 42.7 | 31.8 |
Key Observations:
- Near-linear scaling up to 8 workers: Throughput increased 6× when moving from 1 to 8 workers. Beyond 12 workers, diminishing returns kicked in—CPU contention and JVM GC overhead dominated.
- Memory explodes with concurrency: Each Ghidra headless instance consumes 2–4 GB base JVM heap. At 16 workers, we hit 32 GB total, saturating the instance.
- Tail latency degrades sharply: p99 latency doubled from 8 to 16 workers due to thread pool starvation and disk I/O contention when writing analysis databases.
Production Configuration
Based on these results, the optimal configuration for a dedicated analysis fleet:
import subprocess
import concurrent.futures
import time
def analyze_binary(binary_path, project_dir):
start = time.time()
result = subprocess.run(
[
"/opt/ghidra/support/analyzeHeadless",
project_dir,
"AutoAnalysis",
"-import", binary_path,
"-scriptPath", "/opt/scripts",
"-postScript", "ExportDecompiled.py",
"-deleteProject", # Clean up intermediate databases
],
capture_output=True,
timeout=60, # Circuit breaker: kill runaway analysis
)
elapsed = time.time() - start
return {"binary": binary_path, "duration": elapsed, "returncode": result.returncode}
binaries = ["/samples/malware_{:03d}.elf".format(i) for i in range(50)]
# Sweet spot: 8 workers per 16-vCPU instance
with concurrent.futures.ProcessPoolExecutor(max_workers=8) as executor:
futures = [executor.submit(analyze_binary, b, "/tmp/ghidra_projects") for b in binaries]
for future in concurrent.futures.as_completed(futures):
result = future.result()
print(f"Analyzed {result['binary']} in {result['duration']:.2f}s")
Critical Timeout Guard: The 60-second timeout acts as a circuit breaker. Without it, pathological binaries with recursive call graphs can hang Ghidra indefinitely, blocking worker slots.
Distributed Rate Limiting Layer
When horizontally scaling across multiple EC2 instances, unconstrained parallel requests to shared storage (S3 for binary ingestion, PostgreSQL for metadata) create thundering herd problems. I added a Redis-backed distributed rate limiter:
import redis
import time
redis_client = redis.Redis(host="analysis-cache.abc123.ng.0001.use1.cache.amazonaws.com")
def acquire_analysis_slot(binary_hash, max_global_rate=100):
"""
Sliding window rate limiter using Redis INCR + EXPIRE atomic ops.
Limits global cluster to 100 concurrent analysis jobs.
"""
key = f"analysis:slots:{int(time.time())}" # 1-second window
current = redis_client.incr(key)
if current == 1:
redis_client.expire(key, 2) # Prevent memory leak
if current > max_global_rate:
return False # Reject: cluster saturated
return True
# Wrap subprocess call
if acquire_analysis_slot(binary_hash):
analyze_binary(binary_path, project_dir)
else:
# Exponential backoff retry
time.sleep(2 ** retry_count + random.uniform(0, 1))
This prevents database connection pool exhaustion when 50 EC2 instances simultaneously boot after an autoscaling event.
Production Threshold Decision
When does Ghidra headless make architectural sense?
- Below 500 binaries/day: Single-instance manual analysis or on-demand Lambda invocations suffice.
- 500–10,000 binaries/day: Dedicated fleet of 4–8 c5.4xlarge instances with the rate limiter above.
- Above 10,000 binaries/day: Consider caching decompilation results in a content-addressable store (SHA256 → decompiled IR). In our production pipeline, 60% of enterprise software binaries are duplicates across versions—cache hit rate eliminated redundant analysis.
Memory optimization: Use -enableHeadlessPropagation flag sparingly. It improves decompilation accuracy but doubles memory usage, pushing the worker count down to 4 per instance.
Failure Modes Observed
-
JVM OutOfMemoryError under sustained load: Ghidra's default 4 GB heap is insufficient for complex C++ binaries with RTTI. Bumped to
-Xmx6Gper worker. -
Zombie processes after timeout kills:
subprocess.run()withtimeout=sends SIGTERM, but Ghidra's Java process sometimes ignores it. Added explicitsubprocess.Popen.kill()fallback. -
Database lock contention: Even with
-deleteProject, temp SQLite databases in/tmpweren't cleaned up fast enough. Switched to ramdisk mount (tmpfs) for 15% latency improvement.
If you're integrating binary analysis into security CI/CD gates or malware triage workflows, these benchmarks provide realistic capacity planning numbers. The 8-worker configuration on 16-vCPU instances hits the price-performance sweet spot before memory walls and GC pauses erode throughput gains.
Top comments (0)