Skip to content

Bound the cost of identify() on very large files - #77

Open
adepasquale wants to merge 4 commits into
CAPESandbox:masterfrom
adepasquale:feature/identify-perf
Open

Bound the cost of identify() on very large files#77
adepasquale wants to merge 4 commits into
CAPESandbox:masterfrom
adepasquale:feature/identify-perf

Conversation

@adepasquale

Copy link
Copy Markdown

Problem

sflock.ident.identify() takes ~190–195 seconds on a 250MB non-archive
sample. Callers reasonably expect file-type identification to be a fast,
near-constant-time operation.

This was root-caused in production via py-spy against a stuck CAPEv2 worker:
demux_sflock() in lib/cuckoo/common/demux.py calls sflock.unpack()
synchronously before task creation, and the thread was parked inside
ident.nodejs() at every sample point across a ~95s profiling window.

Two independent causes:

1. nodejs() accounted for ~95% of the runtime. It ran 24 re.search()
calls over the entire file buffer unconditionally, with the count >= 3
threshold only checked after the full sweep. The mechanism is specific: 20 of
the 24 patterns begin with \b
, which defeats CPython re's literal-prefix
optimization, so the regex VM steps every byte offset (measured ~60 MB/s per
pattern) instead of using a fast literal search (~3,500 MB/s for a plain
substring scan). That ~60x per-scan gap is why nodejs() alone dwarfed the
other ~20 content identifiers combined (~4.7s).

2. identify()'s two early-return fast paths read the whole file first.
is_executable() and the recognized-extension check are pure prefix tests, but
both went through f.contents, materializing the entire file before either
could return.

Changes

Four commits, each independently reviewable:

commit change
e25ac81 is_executable() and the extension fast path use f.header (first 1MB, already cached) instead of f.contents. Identical semantics — both are startswith() prefix tests.
6de0ff8 New File.scan_buffer accessor, mirroring the existing File.header pattern, bounded by MAX_IDENT_SCAN_SIZE (16MB, new constant in config.py).
7a84f97 Pair each nodejs() pattern with the literal substring(s) mandatory for any string it can match; run the regex only when the cheap literal check passes. Switch to scan_buffer, and return as soon as count >= 3.
31a4a02 Regression tests.

File.contents is deliberately not capped — it is load-bearing for
File.stream, the public File.read(), unpack/mso.py, unpack/eml.py, and a
number of existing test assertions that check exact lengths and hashes. Hence
the separate accessor.

Results

  • nodejs() on the 250MB sample: ~86–100s → 0.265s
  • identify() end to end, worst case (file falls through to the full identifier
    loop): ~190s → ~4.7s
  • identify() on a large PE/ELF or any file with a recognized extension:
    returns without reading past the first 1MB

The residual ~4.7s is the other substring-based identifiers (visualbasic,
javascript, powershell, and the .count()-based ones), untouched here.

Behavior notes for reviewers

  • The nodejs() literal gating is exactly behavior-preserving: the literals
    are mandatory substrings, so gating cannot change which patterns match. The
    early return is equivalent because the function only ever compares count
    against 3.
  • The one intentional semantic change: nodejs() now inspects at most the first
    16MB. A file whose only Node.js markers appear past 16MB is no longer
    detected. This is the accuracy-for-bounded-runtime tradeoff, and 16MB is
    configurable in one place.

is_executable() and the recognized-extension check in identify() only
need a prefix to decide (a magic-byte startswith), but both read
f.contents, which materializes the entire file into memory. Use the
already-cached f.header (first 1MB) instead, so a large executable or
a large file with a recognized extension no longer forces a full read
before identify() returns.
File.contents reads and caches the entire file; several identifiers in
ident.py only need to sniff the head of a file to classify it, but
capping .contents itself is not safe (File.stream, File.read(), and
several unpackers/tests rely on it being the full buffer). Add a
separate accessor, mirroring the existing File.header pattern, that
never reads past MAX_IDENT_SCAN_SIZE regardless of file size.

Not wired into any identifier yet.
nodejs() ran 24 unbounded regex scans over the entire file for every
non-executable, non-trusted-archive file that reaches identify(). Most
of these patterns are \b-anchored, which defeats re's literal-prefix
optimization, so the regex VM steps through every byte offset (~60
MB/s) instead of skipping via a fast literal search (~3500+ MB/s).
Measured on a 250MB sample, nodejs() alone accounted for ~95% of
identify()'s ~190s runtime.

Pair each pattern with the literal substring(s) that must appear in
any string it can match, and only run the regex when that cheap check
passes first. This is behavior-preserving (identical results, same
count>=3 threshold) and drops nodejs()'s cost to near zero on files
that don't contain any node.js markers, which is the overwhelming
majority of large files reaching this identifier.

Also switch nodejs() to the new capped f.scan_buffer instead of
f.contents, so a match late in a very large file can't force a full
read, and pattern matching stays bounded even if new patterns are
added later.
- test_nodejs_literal_prefilter: for every (literals, pattern) pair in
  nodejs_patterns, checks a minimal matching sample passes the literal
  gate and the regex itself, and that every pattern is covered. Guards
  against a pattern being added/edited without a matching literal,
  which would silently disable it.
- test_nodejs_scan_buffer_cap_contents / _stream: verify nodejs()
  correctly returns "nodejs" for markers within MAX_IDENT_SCAN_SIZE and
  None for markers placed past the cap, via both the in-memory
  contents path and the on-disk stream path of File.scan_buffer.
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