Skip to content

rules: index extracted bytes by fixed-length prefix buckets for O(1) candidate selection - #2928

Merged
williballenthin merged 7 commits into
mandiant:masterfrom
devs6186:fix/2128-bytes-length-prefix-index
Mar 20, 2026
Merged

rules: index extracted bytes by fixed-length prefix buckets for O(1) candidate selection#2928
williballenthin merged 7 commits into
mandiant:masterfrom
devs6186:fix/2128-bytes-length-prefix-index

Conversation

@devs6186

Copy link
Copy Markdown
Contributor

Summary

Closes #2128.

Bytes feature matching currently scans all extracted Bytes features linearly for each rule pattern, calling feature.value.startswith(pattern) on every one. A binary's instruction operands can produce hundreds of bytes features; with the default rule set, this means millions of startswith comparisons on a moderately complex sample (see the evaluation counts cited in #2128).

A bytes pattern of length L can only match extracted bytes whose length is ≥ L and whose first L bytes equal the pattern. By pre-indexing extracted bytes at a small set of fixed prefix lengths (4, 8, 16, 32, 64, 128, 256 bytes), we can reduce the comparison set from all extracted bytes to only those sharing the same fixed-length prefix — typically a handful of candidates.

What changed

capa/rules/__init__.py

Module-level constant _BYTES_BUCKET_SIZES = (4, 8, 16, 32, 64, 128, 256) defines the prefix bucket sizes.

In RuleSet._match():

  • After filtering bytes_features from the full feature set, a prefix index bytes_prefix_index: dict[int, dict[bytes, list[...]]] is built in a single pass. Each extracted bytes feature of length ≥ N is registered in the bucket for N under its first-N-byte prefix.
  • For each pattern of length L, the lookup uses the largest bucket ≤ L. Only candidates that share the same fixed-length prefix are compared with startswith, bringing the inner loop down from O(all_bytes) to O(candidates_with_same_prefix).
  • If the pattern is shorter than the smallest bucket (< 4 bytes), the original evaluate() linear scan is used as a fallback so correctness is preserved for tiny patterns.

Correctness

The prefix index is a refinement of the original linear scan — it can only reduce the candidate set, never miss a match, because two bytes sequences must share the same fixed-length prefix before startswith is applied. Semantic behaviour is identical to the previous implementation.

Performance note

The gain is greatest for patterns whose common prefix lengths (16, 32 bytes — GUIDs, S-boxes, public-key material) are well-separated from the bulk of extracted bytes features. The bucket index is rebuilt per scope call, so its overhead is bounded to O(n·7) construction plus O(1) lookups per pattern.

Tests

New test test_bytes_prefix_index_correctness in tests/test_match.py covers:

  • 16-byte exact match (pattern == extracted bytes)
  • 32-byte startswith match (extracted bytes longer than pattern)
  • Non-matching bytes of the same length
  • Extracted bytes shorter than the pattern (should not match)

Test plan

  • pytest tests/test_match.py -v — all 22 tests pass
  • pytest tests/test_rules.py -v — all existing tests pass
  • pre-commit run --all-files — isort, black, ruff all pass

@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 introduces a significant performance enhancement to capa's rule engine by optimizing how byte patterns are matched against extracted features. Previously, the system performed a linear scan, which became a bottleneck for complex samples with many byte features. The new approach leverages a fixed-length prefix indexing strategy, categorizing extracted bytes into buckets based on their initial segments. This allows for near-constant time (O(1)) candidate selection, drastically reducing the number of comparisons needed and improving overall rule evaluation speed, particularly for common pattern lengths like GUIDs or S-boxes, all while maintaining semantic correctness.

Highlights

  • Performance Optimization: Implemented a prefix-based indexing mechanism for extracted byte features to significantly improve the performance of byte pattern matching, changing candidate selection from a linear scan (O(N)) to an O(1) lookup.
  • New Configuration: Introduced _BYTES_BUCKET_SIZES (4, 8, 16, 32, 64, 128, 256 bytes) as a module-level constant in capa/rules/__init__.py to define the fixed-length prefix buckets.
  • Refactored Matching Logic: Modified the RuleSet._match() method to build and utilize a bytes_prefix_index for efficient lookup, reducing the comparison set to only those bytes sharing the same fixed-length prefix.
  • Correctness Assurance: Ensured correctness by falling back to the original linear scan for patterns shorter than the smallest bucket (4 bytes) and by designing the prefix index as a refinement that never misses a match.
  • New Test Coverage: Added test_bytes_prefix_index_correctness in tests/test_match.py to validate the new byte prefix indexing logic across various matching scenarios.

🧠 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
  • CHANGELOG.md
    • Added a changelog entry for the new byte feature indexing rule optimization.
  • capa/rules/init.py
    • Defined _BYTES_BUCKET_SIZES for prefix indexing.
    • Implemented byte prefix indexing within RuleSet._match() for faster pattern matching.
    • Added logic to fall back to linear scan for patterns shorter than the smallest bucket.
  • tests/test_match.py
    • Added test_bytes_prefix_index_correctness to validate the byte prefix indexing logic.
Activity
  • No specific activity (comments, reviews, or progress updates) has been recorded for this pull request yet.
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 a significant performance optimization for matching bytes features by replacing a linear scan with a prefix-based bucketing strategy. The implementation is sound and includes a fallback for short patterns and new tests to ensure correctness. I have one suggestion to make the bucket lookup slightly more efficient and idiomatic.

Comment thread capa/rules/__init__.py Outdated

@williballenthin williballenthin left a comment

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.

addressing this TODO is much more about doing the work to profile and benchmark and find the right balance, versus writing the naive code. @devs6186 are you up for it?

otherwise, i don't want to merge this because its more code with unclear real-world performance impact.

Comment thread capa/rules/__init__.py Outdated
Comment thread capa/rules/__init__.py Outdated
Comment thread capa/rules/__init__.py Outdated
…ction

Instead of iterating all extracted Bytes features for every bytes-based rule,
build a prefix index keyed by fixed bucket sizes (4, 8, 16, 32, 64, 128, 256)
once per scope evaluation.  Each bytes pattern is looked up in the largest
bucket that fits its length, then only candidates sharing that prefix are
compared, replacing the previous O(n) linear scan with an O(1) hash lookup.
Patterns shorter than the minimum bucket still fall back to the full scan.
Adds a test to verify correctness for exact match, startswith match, mismatch,
and short-bytes cases.

Closes: mandiant#2128
@devs6186
devs6186 force-pushed the fix/2128-bytes-length-prefix-index branch from 68bb178 to b14dcf6 Compare March 15, 2026 22:53
@devs6186

devs6186 commented Mar 16, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the feedback @williballenthin , I reworked this to prioritize simplicity and measurable impact.
I replaced the multi-bucket approach with a single 4-byte prefix pre-filter:

  • one index entry per extracted Bytes feature (no bucket fan-out),
  • fallback to linear evaluate() for patterns shorter than 4 bytes,
  • prefix key extraction via struct.unpack_from(">I", ...) to avoid transient prefix-slice allocations.

I also expanded tests in tests/test_match.py for:

  • correctness parity,
  • same-prefix collision handling,
  • short-pattern fallback behavior.

Benchmark results (scripts/profile-time.py, 30 repeats, same sample/rules):

  • master-linear: evaluate.feature=2376, avg 0.03s
  • previous multi-bucket: evaluate.feature=1488, avg 0.03s
  • current single-prefix: evaluate.feature=1488, avg 0.04s

Memory profile (scripts/profile-memory.py) was effectively unchanged between multi and single variants in this run.
So we keep the candidate-reduction benefit while significantly reducing complexity and maintenance overhead. please let me know what you think about this?

…) call

The previous implementation rebuilt a `defaultdict` mapping byte prefixes
to extracted feature values inside `_match()`, which is called per
function/basic-block/instruction. Moving the rule-side index build to
`_index_rules_by_feature()` (called once at RuleSet construction) eliminates
this per-call allocation and O(R) rule iteration from the hot path.

`_match()` now looks up candidate rules via the pre-built `bytes_prefix_index`
stored in `_RuleFeatureIndex`, iterating only extracted byte features to
compute their prefixes.
@devs6186

devs6186 commented Mar 19, 2026

Copy link
Copy Markdown
Contributor Author

did some more digging The root cause was that the prefix index was being rebuilt on every _match() call (once per function/basic-block/instruction), which dominated the O(1) lookup savings.

Latest commit moves the entire index build into _index_rules_by_feature(), which runs once at RuleSet construction time. _match() now reads directly from feature_index.bytes_prefix_index — no defaultdict allocation, no rule iteration in the hot path.

The struct:

@dataclass
class _RuleFeatureIndex:
    ...
    bytes_prefix_index: dict[int, list[tuple[str, bytes]]]

is populated once and shared across all _match() invocations for the lifetime of a RuleSet. Short-pattern rules (len < 4) go into key -1 and are still linearly scanned, but that scan is now over a pre-built list rather than reconstructed each call.

Happy to benchmark again if that would help — just wanted to get the structural fix in first.

@williballenthin

Copy link
Copy Markdown
Collaborator

Benchmark results (scripts/profile-time.py, 30 repeats, same sample/rules):

master-linear: evaluate.feature=2376, avg 0.03s
previous multi-bucket: evaluate.feature=1488, avg 0.03s
current single-prefix: evaluate.feature=1488, avg 0.04s

can you run capa against all the files in capa-testfiles before/after and share the results? or at least mimikatz.exe. the numbers you show above are too small to really feel confident about. i'm afraid there might be too much noise in the measurements.

When _RuleFeatureIndex gains a new field, pickle.loads() on an older
cached ruleset succeeds but the resulting objects silently lack the new
field — causing an AttributeError deep in _match() at runtime.

Extend load_cached_ruleset() to walk every _RuleFeatureIndex in the
loaded ruleset and verify each dataclass field is present on the
instance. On mismatch, delete the stale cache and return None so the
caller rebuilds from scratch. Production users are unaffected (the
version hash in the cache key already invalidates caches across
releases); this guard covers developer switching between branches.
- Change _match() guard from bytes_rules to bytes_prefix_index
  so the guard references the field actually used for candidate selection.
- Update stale comment to describe the prefix-bucket strategy.
- Clarify bytes_rules dataclass comment (retained for logging only).
- Add test_bytes_prefix_index_mixed_short_and_long_patterns covering
  rules with both short (<4B) and long (>=4B) patterns exercised together.
@devs6186

Copy link
Copy Markdown
Contributor Author

@williballenthin
I ran scripts/profile-time.py (30 repeats each) against the larger testfiles fixtures, before/after this branch. Here's what I got:

  1. kernel32.dll_ (this is the real stress test — tons of extracted bytes features):
Screenshot 2026-03-20 025811
  1. microsocks.elf_:
image
  1. Practical Malware Analysis Lab 16-01.exe_:
image

The evaluate.feature.bytes counter drops dramatically across all three — from 71k to 4.4k on kernel32 which confirms the prefix bucketing is cutting out the vast majority of pointless comparisons. Wall time improvement is ~29% on kernel32, ~34% on microsocks, ~31% on PMA 16-01.

I don't have mimikatz.exe_ in my local checkout (it's not in the default testfiles submodule). If you want mimikatz numbers specifically, could you point me to where I can grab it? Happy to rerun.

Also pushed a cleanup commit:

  • guard in _match() now checks bytes_prefix_index instead of the vestigial bytes_rules field
  • updated the stale "we may want to index bytes" comment
  • added a test covering a ruleset with both short (<4B) and long (>=4B) patterns exercised together,
    since the previous tests only hit each path in isolation.

@williballenthin

Copy link
Copy Markdown
Collaborator

wow, that's a huge improvement! something like 30% faster (wall clock) with this PR? thats well beyond my expectations.

do you mind also confirming the --json reports produced before/after are exactly the same?

i will review this PR carefully and make a plan for merging it. thank you @devs6186!

@williballenthin williballenthin left a comment

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.

the implementation looks good to me, some inline comments to address.

Comment thread capa/rules/__init__.py Outdated
Comment on lines +1657 to +1658
# Mapping from rule name to list of Bytes features that have to match.
# All these features will be evaluated whenever a Bytes feature is encountered.
# Retained for logging and the emptiness check; candidate selection uses bytes_prefix_index.

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.

i don't understand this comment. can we remove this structure completely?

lets phrase our comments to explain how the code currently is, not referring to changes/history. "retained" requires the reader to know the history of the file, rather than how it works today.

Comment thread capa/rules/__init__.py Outdated
# For long patterns, group extracted bytes by their 4-byte prefix and look up
# only the rules whose pattern prefix matches.
for feature in bytes_features:
assert isinstance(feature.value, bytes)

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.

can we get rid of this check? mypy should know about the type of value

Comment thread capa/rules/__init__.py Outdated
Comment on lines +2051 to +2057
# Short-pattern rules (key -1) require a linear scan against all extracted bytes.
for rule_name, pattern in feature_index.bytes_prefix_index.get(-1, ()):
for feature in bytes_features:
assert isinstance(feature.value, bytes)
if feature.value.startswith(pattern):
candidate_rule_names.add(rule_name)
break

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.

maybe we can put this behind a check of if -1 in feature_index.bytes_prefix_index to avoid the creation of temporary objects. i don't think there are any rules today that have such a short bytes feature, nor do i expect that we'll add any, so almost certainly this branch won't be taken.

Comment thread capa/rules/cache.py
Comment on lines -167 to +182
else:
return cache.ruleset

# Verify that every _RuleFeatureIndex in the loaded ruleset has all the fields
# that the current class definition declares. A cache built by an older dev build
# (before a new field was added to _RuleFeatureIndex) will be missing those fields
# on the unpickled objects even though pickle.loads() succeeds.
try:
for fi in cache.ruleset._feature_indexes_by_scopes.values():
for field in dataclasses.fields(fi):
getattr(fi, field.name)
except AttributeError:
logger.debug("rule set cache has incompatible structure (stale dev build): %s", path)
path.unlink()
return None

return cache.ruleset

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.

can you open a separate issue and PR about this? i'm not sure this is the way we want to detect this, and don't want to merge it as part of this PR.

Comment thread tests/test_match.py
Comment on lines -49 to +50
rule = textwrap.dedent("""
rule = textwrap.dedent(
"""

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.

there's a lot of extra whitespace changes here. would you please revert them?

Comment thread tests/test_match.py
Comment on lines +903 to +904


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.

these are good tests, thanks for including them

@devs6186

Copy link
Copy Markdown
Contributor Author

Hi @williballenthin ill address the issues shortly

- remove bytes_rules from _RuleFeatureIndex; bytes_prefix_index is the
  only structure needed for candidate selection
- build bytes_prefix_index directly in _index_rules_by_feature() instead
  of building bytes_rules then converting, removing one full pass
- add if -1 in bytes_prefix_index guard to avoid temporary object
  creation for the short-pattern fallback (almost never taken)
- remove assert isinstance(feature.value, bytes) checks in _match();
  add Bytes.value: bytes class-level annotation so mypy narrows the
  type without the runtime check
- remove cache structure compatibility block from cache.py per reviewer
  request to handle in a separate PR
- update test assertions from bytes_rules to bytes_prefix_index
@devs6186

Copy link
Copy Markdown
Contributor Author

Hi @williballenthin
I confirmed --json parity. Ran capa --json against the default rule set on three samples (master vs this branch, fresh cache for both runs), then compared canonicalized output , stripping meta.timestamp, meta.argv, and auto-generated subscope keys (/). Now why I did this-:

I stripped those fields to compare semantic output, not run-specific noise.

  • meta.timestamp and meta.argv vary every execution/environment.
  • / subscope suffixes are auto-generated IDs (UUID-like) for internal derived rules, so names can differ between runs even when matches are identical.
    Without removing them, you get false diffs that don’t reflect capability changes. So canonicalization ensures we’re validating the important claim: same matched capabilities/rules/addresses before vs after.
image

The Json input which came out identical-
image
image
image
image

Every rule that fired before fires after on the same set of addresses.
I'll open a separate issue to track the right cache invalidation strategy as discussed and then submit a pr.
Thank you !

@devs6186

devs6186 commented Mar 20, 2026

Copy link
Copy Markdown
Contributor Author

the implementation looks good to me, some inline comments to address.

Hey @williballenthin i have addressed all inline comments in the latest commit. Thank you !

@williballenthin
williballenthin merged commit c930891 into mandiant:master Mar 20, 2026
2 checks passed
@williballenthin

Copy link
Copy Markdown
Collaborator

thanks @devs6186 great work!

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.

hash lookup common bytes length prefixes

2 participants