This document explains the math behind the "find shortest unique signature for
the current function" search, how it relates to known string algorithms, and why
the Cython _speedups extension is what makes it practical. It's for someone who
wants to understand why the search is fast, not just what the code does.
GitHub renders LaTeX in Markdown, so the math below is in $...$ (inline) and
$$...$$ (block) form.
- 1. The problem
- 2. The naive cost, and where 462 seconds went
- 3. Monotonic shrinkage: seed once, then refine
- 4. The 2-byte position index (counting sort)
- 5. Dynamic Seed Selection (1-byte or 2-byte) from one index
- 6. In-place refinement on a typed buffer
- 7. Complexity summary
- 8. Relationship to known string algorithms
- 9. What is novel here
- 10. Future algorithmic directions
- 11. Rejected optimizations
- 12. How Cython makes it work
- 13. References
A database
(Here
This algorithm operates on full exact bytes and full-byte wildcards only: every
token mask 0xFF or 0x00. The lower-level SIMD scanner can
represent partial nibble masks for the separate signature search feature, but
the signature generator and the index-backed search described here neither
produce nor rely on them, and the seed selector anchors only on fully exact bytes.
For a function at address
Scope. This document is about one problem: finding the shortest signature that is unique in the current database under a fixed wildcard policy. A separate and harder question, which bytes should be left exact so a signature survives a recompile (cross-build robustness), is a different optimization and is deliberately out of scope. The wildcard policy is an input to this algorithm, not something it chooses.
The obvious method: for
When the search has to consider several anchors (growing outside the function
boundary, or comparing several candidate start points), it becomes
The match set is monotonically non-increasing in
So there is no reason to recompute
This is the seed-then-refine recurrence. We pay for one initial match set (the "seed"), then each subsequent length is
work, proportional to the current candidate count, not
That monotonicity is why the search is fast in practice, but on its own it does
not give an unconditional telescoping bound. If the seed set has size
The stronger "almost
Wildcard bytes have
With that qualification, the total per-anchor cost is
Building the seed by scanning
For each 2-byte key
We store every window start grouped by key in one flat array positions, with a
heads offset array (a CSR / counting-sort layout):
-
heads[k]is the start of bucket$k$ insidepositions; - bucket
$k$ is the slicepositions[heads[k] : heads[k+1]]; - the bucket size
$|B_k|$ isheads[k+1] - heads[k], available in$O(1)$ time.
Construction is a textbook counting sort over the
-
Count. For each window key
$k$ , incrementheads[k+1]. -
Prefix-sum
headssoheads[k]becomes bucket$k$ 's start, then scatter: place each window offset$i$ atpositions[w[k]++]using a copy$w$ of the bucket starts.
That work pays off because building the index is
and then we refine against the remaining tokens (Section 3). The full
The seed cost is
(a) Choose the smallest bucket. A pattern usually has several exact 2-byte runs. Pick the one minimizing bucket size.
(b) A single rare byte can beat a common pair. e8 (a call opcode) is
everywhere; a lone 0f followed by a common byte may be more selective as just
the rare byte alone. We therefore also consider 1-byte runs, and we get them
for free from the same index. Because keys are ordered positions. Hence the 1-byte
bucket is a telescoping sum that collapses to a single subtraction:
and the 1-byte candidate list is the single slice
positions[heads[b<<8] : heads[(b+1)<<8]], with no second index, no extra memory,
and no rescan. (One boundary fix: position
Seed selection then minimizes selectivity over the union of both widths:
Since the 1-byte options are a superset of the candidate seeds, the chosen seed bucket is never worse than a 2-byte-only choice:
A smaller seed means fewer candidates entering the refine chain, which improves
both the worst-case
The selector deliberately uses only contiguous, fully exact 1-byte and 2-byte anchors. Richer seed families (longer runs, spaced or gapped seeds, multi-context seeds) are intentionally not used: those techniques pay off in read mapping because the mismatch positions are unknown at search time, whereas here the wildcard positions are fixed by the policy before the search begins. The seed is only an anchor; every other exact byte is already exploited by refinement, so a richer seed family would add index complexity without new selectivity. It is worth revisiting only if profiling ever shows seed enumeration, not refinement, to be the bottleneck (see Section 10).
Deferred seeding. Two cases skip the index seed and wait. First, for a pattern
shorter than MIN_USEFUL_SIG_BYTES
Each refine step (Section 3) is a filter over the candidate array. We represent
candidates as a typed uint32 buffer and compact survivors in place with a
two-pointer scan. With read index
for r in 0 .. count-1:
c = cands[r]
if c + j < N and (D[c + j] & m) == τ:
cands[w] = cands[r] # keep
w += 1
return w # new count
Because
| Stage | Cost | Frequency |
|---|---|---|
| Index build (counting sort) | once per search | |
| Seed selection |
|
per anchor |
| Seed enumeration | per anchor | |
| Refine, all informative steps | $O(\sum_r | M_r |
Per search:
versus the naive
None of this is an empty corner of the literature; the design sits next to several well-studied lines of work.
The exact, unmasked version of the problem is essentially left-bounded shortest unique substring (LSUS): for a fixed start position, find the shortest substring beginning there that occurs once. Recent LSUS work gives linear-time suffix-array/LCP baselines, and shortest-unique/absent-substring (SUS/SAS) algorithms remain active, especially on packed small-alphabet strings. Those are the right reference points for the wildcard-free case and for an answer-length sanity check.
The masked case connects to wildcard pattern matching and longest common extensions with wildcards, whose recurring lesson is the same one we lean on: anchor on informative non-wildcard positions instead of treating all positions uniformly. Internal pattern matching is the natural primitive if one ever wants sublinear repeated-substring queries inside a fixed text rather than a per-search rebuilt index.
The closest practical neighbor is in the same domain. YARA's atom-based scanning picks a short, rare, non-wildcard substring of a rule, finds its occurrences (classically via Aho-Corasick multi-pattern matching), and then verifies the full masked pattern at each hit. That is seed-then-refine for binary signatures. The idea here is similar, but backwards: instead of matching a known pattern, it grows the shortest pattern that is unique, using a byte-window index as the atom oracle and monotone in-place refinement as the verifier.
The bioinformatics seed-design literature (spaced, gapped, sampled, and multi-context seeds) does not transfer cleanly, for the reason in Section 5: it hedges against unknown mismatch positions, while here the wildcard positions are known before the search.
This is a novel application. The literature has the individual primitives, but the composition that solves this problem, growing the shortest masked byte signature that is unique in a live database, does not come pre-packaged anywhere we found. The key use case is concrete: a reverse engineer relocating a function across rebuilds of a binary, interactively, in the disassembler. That is what turns a 7.7-minute search into seconds (Section 2).
What we do not claim is a new general theory of shortest unique substrings or wildcard matching; the primitives (inverted byte buckets, seed/filter/verify, monotone candidate filtering) are individually standard. The contribution is the specialization, and how cheaply the pieces combine for masked function signatures:
-
The 1-byte index is free. This is the one part that isn't obvious. A single counting-sort layout over adjacent byte pairs yields the exact 2-byte buckets, and because the buckets are stored in key order, every 1-byte bucket is just a contiguous marginal of that same
headsarray: a range view, with no second index and no extra memory. -
Mixed-width seed selection from that one structure. Dynamic Seed Selection compares fully exact 1-byte and 2-byte anchors by bucket size and picks the most selective seed currently available, so the candidate set entering refinement is as small as the pattern allows.
-
Monotone in-place refinement. Once seeded, candidate offsets live in one
uint32buffer that only shrinks; refinement touches surviving candidates instead of rescanning the database for every length. -
A reverse-engineering-specific fit. The index is far cheaper to build and discard per search than a suffix-family structure, and far faster than repeated full rescans, which is what an interactive IDA workflow actually needs.
The current implementation is intentionally conservative. The instrumentation this
section used to call for has since been done: seed bucket size
- Exact LSUS baseline. When wildcarding is disabled, or a long exact region dominates the signature, a suffix-array/LCP LSUS baseline is a useful reference for both answer length and runtime.
A couple of ideas looked good on paper, and the reasoning for skipping them is more useful than the verdict, so it is worth writing down.
Both got the same treatment: profile the worst functions, then build a small
adversarial benchmark that deliberately constructs each idea's best case and check
whether it actually wins. Neither did, because the profile kept pointing somewhere
else: not the index build (~0.05 s), and after the seed map was moved into C, not
the refine kernel either (~0.1 s), but two boring things we had left on the table:
an
Block refinement. The obvious next idea is to group the exact bytes into runs
and compare a whole run at once with a wide uint64 or SIMD load, skipping the
wildcard gaps, on the theory that fewer instructions means less time. The benchmark
says otherwise: refinement is bound by memory bandwidth, not instruction count. It
is already a tight, linear, stride-1 sweep, which is the access pattern a CPU
streams fastest, so wider-but-fewer compares do nothing for a loop that is waiting
on memory rather than on the ALU. The premise was also weaker than it looked, since
the expensive filtering pass already skips wildcards (Section 3). With refinement
sitting around 0.1 s, there was simply nothing here worth chasing.
Spaced-seed intersection. The index has a tempting property: each bucket's
positions come out already sorted, because we fill them in one left-to-right pass
over the database. So for a spaced pattern like 8B ?? ?? 45 you could grab both
byte buckets and intersect them with a two-pointer merge. The problem is that we
already do exactly this, just more cheaply: seeding from the rarest byte and
refining against the rest is that intersection, and it only ever touches the
smaller bucket. An explicit merge has to read both buckets end to end, which is
strictly more work the moment one of them is a common byte with millions of
entries. And once deferred seeding keeps the starting set small, the merge is pure
overhead; no input in the benchmark ever reached the point where it paid off.
Put simply, the wins were never algorithmic. They were "stop running
this loop in Python" and "don't scan the whole database for a prefix that can't
anchor anything", the kind of thing you only find by measuring, not by reaching for
a cleverer data structure. The microbenchmark stays in the tree with a --check
mode, so if some later change pushes the bottleneck back onto refinement, it will
fail loudly and these two ideas get a fresh hearing.
The math above is correct in pure Python too, but it would not be fast in pure
Python. The hot kernels (the index build, the seed-candidate map, and the per-step
refine) are memory-bound, branchy loops over millions of bytes, and that is where
CPython's per-element overhead (boxed integers, attribute lookups, interpreter
dispatch, bounds checks) costs 50-100x. The _speedups extension compiles them to
tight C:
-
build_byte_indexis the counting sort of Section 4, written as C loops over aconst unsigned char[:]typed memoryview, running undernogilso the IDA UI stays responsive during the$O(N)$ build. -
refine_offsetsis the in-place compaction of Section 6: anogiltwo-pointer loop over aunsigned int[:]candidate view and theconst unsigned char[:]data view, with no allocation. Moving this one function into Cython dropped the refinement time on the largest test module from ~14 s (a Python list comprehension called ~165k times) to ~0.28 s, roughly 50x. -
seed_offsetsis the candidate-mapping kernel of Section 5: anogilloop that turns a seed bucket into thearray.array('I')of pattern starts (thep - sshift, the fit guard, the$N-1$ boundary case) in C. This was the lastO(C_0)loop left in Python, a generator expression that boxed and walked the entire bucket; moving it into Cython cut the worst observed function search from ~12 s to ~1 s. It is the same playbook asrefine_offsets, cross-checked against the Python version for byte-identical output. -
array.array('I')is the bridge. A candidate set is simultaneously a first-class Python object the orchestration layer can slice and return, and a zero-copyunsigned int[:]typed memoryview inside Cython. The same buffer is the Python-visible candidate list and the C-leveluint32*thatrefine_offsetscompacts in place, so candidates cross the Python/C boundary with no marshalling and no per-call allocation. That is what lets Section 6's "allocate once, only shrink" hold across the whole search. -
nogilon both kernels means the heavy work runs without holding the interpreter lock, which keeps the UI live and leaves headroom for the SIMD scan path used when the index is unavailable.
When the extension is absent or incompatible, SigMaker automatically selects pure-Python fallbacks with identical results (cross-checked in the test suite); the plugin still works, just without the speedups.
In simd_scan.pyx you will see both, sharing the name array:
from cpython cimport array # compile-time: C-level array.array type + array.clone
import array as py_stdlib_arr_mod # run-time: the Python array module (constructor)This is not a collision or a bug; it is the documented Cython idiom for
working with array.array efficiently, and the two lines do different jobs. We
give the run-time module the alias py_stdlib_arr_mod to make the split obvious at
every call site:
-
from cpython cimport arrayis compile-time only. It pulls in the C-level declarations from Cython's bundledcpython/array.pxd: thearray.arrayextension type (socdef array.array xis a statically typed C variable) and inline C functions such asarray.clone(allocate a sibling buffer without going through the Python constructor). Acimportcreates no runtime name binding. -
import array as py_stdlib_arr_modis the ordinary run-time import of the Pythonarraymodule. It is what makes the constructor callpy_stdlib_arr_mod.array('I')resolve at run time (for example, the template argument toarray.clone).
So every use is unambiguous by name: array.* (cdef array.array,
array.clone(...)) is the cimported C-level API, and py_stdlib_arr_mod.array(...) is
the run-time Python constructor. They no longer share a name, so there is nothing
to "override". (The canonical Cython array tutorial shows both lines sharing the
name array; aliasing one side is the same idiom, just spelled out.)
- Larissa L. M. Aguiar and Felipe A. Louza, "Faster computation of left-bounded shortest unique substrings", Algorithms for Molecular Biology, 2025.
- Panagiotis Charalampopoulos, Manal Mohamed, Solon P. Pissis, Hilde Verbeek, and Wiktor Zuba, "Faster Algorithms for Shortest Unique or Absent Substrings", arXiv, 2026.
- Gabriel Bathie, Panagiotis Charalampopoulos, and Tatiana Starikovskaya, "Pattern Matching with Mismatches and Wildcards", ESA 2024.
- Gabriel Bathie, Panagiotis Charalampopoulos, and Tatiana Starikovskaya, "Longest Common Extensions with Wildcards: Trade-Off and Applications", ESA 2024.
- Tomasz Kociumaka, Jakub Radoszewski, Wojciech Rytter, and Tomasz Waleń, "Internal Pattern Matching Queries in a Text and Applications", SODA 2015.
- Alfred V. Aho and Margaret J. Corasick, "Efficient string matching: an aid to bibliographic search", Communications of the ACM, 1975.
- VirusTotal, YARA: The pattern matching swiss knife.