Skip to content

perf: eliminate O(n²) tuple growth and reduce per-match overhead - #2890

Merged
mike-hunhoff merged 3 commits into
mandiant:masterfrom
devs6186:perf/2880-reduce-hotspots
Mar 10, 2026
Merged

perf: eliminate O(n²) tuple growth and reduce per-match overhead#2890
mike-hunhoff merged 3 commits into
mandiant:masterfrom
devs6186:perf/2880-reduce-hotspots

Conversation

@devs6186

@devs6186 devs6186 commented Mar 2, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR implements data-driven performance improvements to capa's hot paths, motivated by issue #2880 (profiling capa with Scalene to identify and fix hotspots).

Five algorithmic fixes, each targeting a different part of the pipeline:

1. O(n²) → O(n): Tuple concatenation in capability finders

Files: capa/capabilities/static.py, capa/capabilities/dynamic.py

feature_counts.functions += (item,) and library_functions += (item,) copied the entire tuple on every function iteration. Since tuples are immutable, each += allocates a new tuple and copies all existing elements. For a binary with N functions this is O(N²) total allocations.

Fix: accumulate into a list, convert to tuple once after the loop.

2. O(n) × M → O(1) × M: RuleSet._match — pre-compute rule_index_by_rule_name

File: capa/rules/__init__.py

_match() is called once per instruction / basic-block / function scope (potentially millions of times on a real binary). Previously it rebuilt {rule.name: i for i, rule in enumerate(rules)} on every call. The ordering of rules_by_scope never changes after __init__, so the dict is now computed once and cached as _rule_index_by_scope.

3. O(n) → O(1): candidate_rules.pop(0)deque.popleft()

File: capa/rules/__init__.py

list.pop(0) shifts every remaining element, O(n). Replaced with collections.deque and popleft() for O(1) removal from the front.

4. O(n²) → O(n): _extract_subscope_rules BFS queue

File: capa/rules/__init__.py

Same issue: the BFS that extracts subscope rules used list.pop(0), making the whole traversal O(n²). Fixed with a deque queue.

5. O((k+m) log(k+m)) → O(m log m + k+m): sorted merge for new rule candidates

File: capa/rules/__init__.py

When a matched rule introduces new dependent candidates into the work queue, the previous approach converted the deque to a list, extended it with the new items, and re-sorted the entire collection. Since the existing deque is already topologically sorted, only the new additions need to be sorted, then merged with the existing sorted deque using heapq.merge.

Test plan

  • tests/test_rules.py — 29 tests pass
  • tests/test_match.py — 20 tests pass (including all test_index_features_* unstable tests)
  • tests/test_engine.py — 8 tests pass
  • tests/test_optimizer.py — 1 test passes
  • Manual smoke-test: RuleSet instantiates, match() returns correct results for matching/non-matching feature sets, dependent-rule chains resolve correctly
  • No behaviour change: correctness is unchanged, only allocation and iteration patterns

Closes #2880

@github-actions github-actions 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.

Please add bug fixes, new features, breaking changes and anything else you think is worthwhile mentioning to the master (unreleased) section of CHANGELOG.md. If no CHANGELOG update is needed add the following to the PR description: [x] No CHANGELOG update needed

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the performance of capa's core matching engine by addressing several algorithmic inefficiencies. The changes focus on reducing quadratic time complexity operations, particularly those involving tuple concatenations and list manipulations, and optimizing frequently called methods by pre-computing data structures. The overall impact is a more efficient and scalable rule matching process without altering the correctness of the results.

Highlights

  • O(n²) tuple growth eliminated: Tuple concatenation in find_static_capabilities and find_dynamic_capabilities was replaced with list accumulation, then conversion to a tuple, to avoid quadratic allocation complexity.
  • Pre-computed rule index mapping: The rule_index_by_rule_name dictionary is now pre-computed once during RuleSet initialization and cached, preventing its recreation on every call to _match.
  • O(n) list.pop(0) replaced with O(1) deque.popleft(): The _match method now uses collections.deque for candidate_rules to achieve O(1) removal from the front of the queue, instead of the O(n) list.pop(0).
  • O(n²) list.pop(0) in BFS traversal fixed: The _extract_subscope_rules function, which performs a BFS, was updated to use collections.deque and popleft() to resolve an O(n²) complexity issue caused by list.pop(0).

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • capa/capabilities/dynamic.py
    • Replaced direct tuple concatenation for process_feature_counts with appending to a temporary list.
    • Converted the accumulated list of process feature counts to a tuple at the end of the processing loop.
    • Added comments explaining the performance rationale for using lists over tuples for accumulation.
  • capa/capabilities/static.py
    • Replaced direct tuple concatenation for function_feature_counts and library_functions with appending to temporary lists.
    • Converted the accumulated lists of function and library function feature counts to tuples at the end of the processing loop.
    • Added comments explaining the performance rationale for using lists over tuples for accumulation.
  • capa/rules/init.py
    • Introduced _rule_index_by_scope to pre-compute rule name to index mappings during RuleSet initialization.
    • Modified _extract_subscope_rules to use collections.deque and popleft() for efficient queue operations.
    • Updated _match to utilize the pre-computed _rule_index_by_scope.
    • Refactored _match to use collections.deque and popleft() for processing candidate_rules.
    • Adjusted _match logic to handle merging new candidates into the deque by converting to a list, extending, re-sorting, and recreating the deque.
Activity
  • No human activity (comments, reviews) was recorded for this pull request.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces several well-reasoned performance improvements by addressing algorithmic complexities in hot paths. The changes from O(n²) tuple concatenation to list appends, pre-computation of rule indices, and the use of collections.deque to avoid O(n) pop(0) operations are all excellent optimizations. The code is clearer and more efficient. I have one suggestion for a further micro-optimization in the rule matching logic.

Comment thread capa/rules/__init__.py Outdated
Comment on lines +2099 to +2102
remaining = list(candidate_rules_deque)
remaining.extend([self.rules[rule_name] for rule_name in new_candidates])
RuleSet._sort_rules_by_index(rule_index_by_rule_name, remaining)
candidate_rules_deque = collections.deque(remaining)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Since candidate_rules_deque is already sorted topologically, you could achieve better performance here. Instead of converting the deque to a list, extending it, and then re-sorting the entire collection (O(N log N)), you could sort only the new_candidates and then merge the two sorted lists. A merge operation would be closer to O(N), where N is the total number of candidates. Given this is a performance-focused PR, this micro-optimization might be worth considering.

Four data-driven performance improvements identified by profiling
the hot paths in capa's rule-matching and capability-finding pipeline:

1. find_static_capabilities / find_dynamic_capabilities (O(n²) → O(n))
   Tuple concatenation with `t += (item,)` copies the entire tuple on
   every iteration. For a binary with N functions this allocates O(N²)
   total objects. Replace with list accumulation and a single
   `tuple(list)` conversion at the end.

2. RuleSet._match: pre-compute rule_index_by_rule_name (O(n) → O(1))
   `_match` is called once per instruction / basic-block / function scope
   (potentially millions of times). Previously it rebuilt the name→index
   dict on every call. The dict is now computed once in `__init__` and
   stored as `_rule_index_by_scope`, reducing each call to a dict lookup.

3. RuleSet._match: candidate_rules.pop(0) → deque.popleft() (O(n) → O(1))
   `list.pop(0)` is O(n) because it shifts every remaining element.
   Switch to `collections.deque` for O(1) left-side consumption.

4. RuleSet._extract_subscope_rules: list.pop(0) → deque.popleft() (O(n²) → O(n))
   Same issue: BFS over rules used list.pop(0), making the whole loop
   quadratic. Changed to a deque queue for linear-time processing.

Fixes mandiant#2880
@devs6186
devs6186 force-pushed the perf/2880-reduce-hotspots branch from 4a16dd5 to b995a4f Compare March 2, 2026 14:45
When a rule matches and introduces new dependent candidates into
_match's work queue, the previous approach converted the deque to a
list, extended it with the new items, and re-sorted the whole
collection — O((k+m) log(k+m)).

Because the existing deque is already topologically sorted, we only
need to sort the new additions — O(m log m) — and then merge the two
sorted sequences in O(k+m) using heapq.merge.

Also adds a CHANGELOG entry for the performance improvements in mandiant#2890.
@github-actions
github-actions Bot dismissed their stale review March 2, 2026 14:48

CHANGELOG updated or no update needed, thanks! 😄

@williballenthin

Copy link
Copy Markdown
Collaborator

do you have a sense for how performance improves in the real world? like if you run capa against mimikatz before/after the changes, how does the wall time differ?

anyways, nice finds!

@williballenthin

Copy link
Copy Markdown
Collaborator

my intuition is that (2) is possibly a significant improvement, while the others are algorithmically correct, but possibly a wash because n is expected to be small (eg candidate rules might be like 10, so the overhead of using a deque may dominate).

still, we should write the code in a way that best conveys the meaning, so most of these changes look good to me

Comment thread capa/rules/__init__.py
done = []

# use a queue of rules, because we'll be modifying the list (appending new items) as we go.
while rules:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

we could possibly use a list and both append and pop from the end. im not sure this has to be a FIFO quite.

Comment thread capa/rules/__init__.py Outdated
key=lambda r: rule_index_by_rule_name[r.name],
)
candidate_rules_deque = collections.deque(
heapq.merge(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this reads more complicated. unless it makes a noticeable difference, i'm inclined to prefer the old code for simplicity

Comment thread capa/rules/__init__.py Outdated
rule = candidate_rules.pop(0)
# Use a deque so that consuming rules from the front is O(1) rather than O(n).
# list.pop(0) shifts every remaining element; deque.popleft() does not.
candidate_rules_deque: collections.deque[Rule] = collections.deque(candidate_rules)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

likewise maybe this can be a LIFO list

Address reviewer feedback:
- Replace deque+popleft with list+pop (LIFO stack) in _extract_subscope_rules;
  processing order doesn't affect correctness, and list.pop() is O(1).
- Replace deque+popleft with list+pop (LIFO stack) in _match; sort candidate
  rules descending so pop() from the end yields the topologically-first rule.
- Revert heapq.merge back to the simpler extend+re-sort pattern; the added
  complexity wasn't justified given the typically small candidate set.
- Remove now-unused `import heapq`.
@devs6186

devs6186 commented Mar 4, 2026

Copy link
Copy Markdown
Contributor Author

hey @williballenthin

switched both deques to plain list stacks as you suggested — order doesn't matter in
_extract_subscope_rules so LIFO is a clean simplification there, and for _match I just sorted it in descending
so pop() from the end always gives the topologically-first rule. also reverted the heapq.merge back to
the simple extend + re-sort, agreed it wasn't worth the added complexity.

Ran a quick benchmark on cmd.exe (803 functions, 3 runs each):

AVERAGE
master = 52.4s
this PR = 53.4s

basically a wash, which matches your intuition. the pre-computed rule index is probably where the real
gain is since _match runs so many times, but it won't show up clearly on a small binary like this.
would need something with thousands of functions and heavy rule dependencies to see a meaningful delta.

@mike-hunhoff
mike-hunhoff merged commit 2c9e30c into mandiant:master Mar 10, 2026
34 checks passed
saniyafatima07 pushed a commit to saniyafatima07/capa that referenced this pull request Jun 17, 2026
…diant#2890)

* perf: eliminate O(n²) tuple growth and reduce per-match overhead

Four data-driven performance improvements identified by profiling
the hot paths in capa's rule-matching and capability-finding pipeline:

1. find_static_capabilities / find_dynamic_capabilities (O(n²) → O(n))
   Tuple concatenation with `t += (item,)` copies the entire tuple on
   every iteration. For a binary with N functions this allocates O(N²)
   total objects. Replace with list accumulation and a single
   `tuple(list)` conversion at the end.

2. RuleSet._match: pre-compute rule_index_by_rule_name (O(n) → O(1))
   `_match` is called once per instruction / basic-block / function scope
   (potentially millions of times). Previously it rebuilt the name→index
   dict on every call. The dict is now computed once in `__init__` and
   stored as `_rule_index_by_scope`, reducing each call to a dict lookup.

3. RuleSet._match: candidate_rules.pop(0) → deque.popleft() (O(n) → O(1))
   `list.pop(0)` is O(n) because it shifts every remaining element.
   Switch to `collections.deque` for O(1) left-side consumption.

4. RuleSet._extract_subscope_rules: list.pop(0) → deque.popleft() (O(n²) → O(n))
   Same issue: BFS over rules used list.pop(0), making the whole loop
   quadratic. Changed to a deque queue for linear-time processing.

Fixes mandiant#2880

* perf: use sorted merge instead of full re-sort for new rule candidates

When a rule matches and introduces new dependent candidates into
_match's work queue, the previous approach converted the deque to a
list, extended it with the new items, and re-sorted the whole
collection — O((k+m) log(k+m)).

Because the existing deque is already topologically sorted, we only
need to sort the new additions — O(m log m) — and then merge the two
sorted sequences in O(k+m) using heapq.merge.

Also adds a CHANGELOG entry for the performance improvements in mandiant#2890.

* perf: simplify candidate_rules to LIFO list, revert heapq.merge

Address reviewer feedback:
- Replace deque+popleft with list+pop (LIFO stack) in _extract_subscope_rules;
  processing order doesn't affect correctness, and list.pop() is O(1).
- Replace deque+popleft with list+pop (LIFO stack) in _match; sort candidate
  rules descending so pop() from the end yields the topologically-first rule.
- Revert heapq.merge back to the simpler extend+re-sort pattern; the added
  complexity wasn't justified given the typically small candidate set.
- Remove now-unused `import heapq`.
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.

performance profile using Scalene

3 participants