A function-by-function pass over every component in the Analyze flow and
its satellites, with time complexity and heap-allocation behaviour per call,
so the places worth optimizing are visible at a glance. Numbers in the
"Measured" section come from the benchmark corpus in bench/; everything
else is read from the code. The tables describe the current tree; the
ledger at the end records what was changed from the
first version of this analysis and what is deliberately left.
go test -bench 'BenchmarkAnalyze|BenchmarkNewEngine' -benchmem in bench/,
Apple M4 Max, Go 1.26, default English engine, no NLP backend:
Size / density
ns/op (before → after)
MB/s
B/op (before → after)
allocs/op (before → after)
results/op
100B / none
110 k → 2.8 k
0.91 → 35.6
1 470 → 10
53 → 0
0
100B / dense
139 k → 34 k
0.72 → 2.9
2 216 → 591
64 → 5
0.6
1KB / none
991 k → 25 k
1.03 → 40.2
1 470 → 195
53 → 0
0
1KB / dense
1.05 M → 761 k
0.97 → 1.35
16 417 → 3 529
153 → 36
8.8
10KB / none
10.3 M → 277 k
0.99 → 37.0
1 613 → 421
53 → 0
0
10KB / sparse
10.7 M → 8.2 M
0.95 → 1.25
22 084 → 4 375
191 → 48
12.3
10KB / dense
11.2 M → 8.5 M
0.91 → 1.20
153 919 → 36 012
596 → 171
94
1MB / none
1.10 s → 28.5 ms
0.96 → 36.8
1 338 → 40
53 → 0
0
1MB / sparse
1.20 s → 846 ms
0.88 → 1.24
1.8 M → 462 k
4 671 → 1 269
—
1MB / dense
1.65 s → 899 ms
0.64 → 1.17
15.5 M → 4.67 M
36 834 → 9 776
9 575
OCR line / prose (60 B, 51-entity filter)
— → 2.0 k
29.6
195
0
0
OCR line / digits
— → 50.6 k
1.19
666
10
—
NewEngine()
169 k → 97 k
—
459 224 → 177 672
3 797 → 2 362
—
"Before" is the first version of this analysis; "after" is the current
tree, which the per-function tables describe. The parity run in bench/
(benchgo -mode detect, 23 851 detections) is byte-identical across the
two.
What the table shows:
Prose is ~37 MB/s and allocation-free. A text with no digit skips
every numeric pattern before any regex runs; what remains is one byte
pass for the class mask and a handful of literal-prefixed patterns.
Dense text is bounded by the stdlib regexp scan. At 10KB/dense,
80 % of CPU is regexp.(*Regexp).find for the ~55 patterns that are not
bare digit runs, and 80 % of the remaining allocations are the one
[]int per match that FindAllStringSubmatchIndex returns. Everything
on this side of that API is gone.
Match density no longer has a quadratic term. 1MB/dense is 0.90 s
against 0.85 s for 1MB/sparse; the pre-change gap (1.65 s vs 1.20 s) was
the O(M²) de-duplication.
Engine.Analyze(text, o)
│ Registry.Recognizers(lang, o.Entities) O(1) map hit; O(R·E) on first use of a filter
│ [NlpEngine.ProcessText] C(n) only with ner/pfilter attached
└─ analyzeWithArtifacts
│ byteClasses(text) O(n) 0 allocs; one table lookup per byte
├─ for each recognizer: PatternRecognizer.appendResults
│ for each pattern:
│ skip if text lacks a required class O(1)
│ \b\d{n}\b pattern → shared digit runs O(n) once per call, then O(runs)
│ shared regex source → scanCache hit O(1)
│ else stdMatcher.FindAll O(n·|re|) 1 + m allocs ← 80 % CPU on dense
│ validator(match) O(s) 0 allocs
│ contextValidator(text, start, end) O(1) 0 allocs
├─ WordContextEnhancer.Enhance O(M·k) 1 map + 1 window slice per call
├─ removeDuplicates (all results) O(M log M) in place; exported RemoveDuplicates copies first
├─ threshold filter O(M) in place
├─ allowList.apply O(M) prepared list reused across calls
└─ fill Result.Text O(M) substrings, no copy
O(langs · (R + P)) + one regexp.Compile per distinct regex source, process-wide
measured 178 KB / 2 362 allocs for en
compileProgram caches programs by source, so a second language reuses the compiled regexes; the Pattern and recognizer structs are still rebuilt per language.
analyzeWithArtifacts(text, o, recs, artifacts) :158
hot
byteClasses O(n) + Σ appendResults + Enhance + removeDuplicates O(M log M) + O(M) threshold + O(M) allow list
one scanCache on the stack (escape analysis verified); all grows by append as recognizers write into it (~log₂ M reallocs); non-pattern recognizers' slices are still copied in
The engine-level dedup subsumes the per-recognizer one, which is skipped on this path.
prepareAllowList(o) :226
hot when AllowList set
O(A) element-wise compare on a hit; O(A) + regexp.Compile on a miss
hit: 0; miss: slices.Clone of the list + compiled regexp or map[string]struct{}
Single cached entry (atomic.Pointer): the common case is one list on every call. Alternating lists rebuild each time, as before.
Conservative: optional parts, alternation branches that disagree, case-fold orbits with non-ASCII members and any non-ASCII rune require nothing. TestPrefilterNeverSkipsCorpusMatch checks every built-in pattern against the bench corpus.
Match holds the stdlib start/end pair slice; group g is loc[2g], loc[2g+1]. Breaking for custom Matchers: the exported Groups [][2]int field is gone; build matches with NewMatch (see lookaround).
compileProgram(regex) :75
setup
map hit O(1), else regexp.Compile
program on first use; the process-wide map stops growing at programCacheSize (1 024)
Patterns past the bound still compile and work, they just do not share a scan.
stdMatcher.FindAll(text) :100
hot, per non-shared regex pattern
O(n·|re|); bitstate backtracker under Go's size cap, NFA above. \b-prefixed patterns get no literal-prefix skip.
FindAllStringSubmatchIndex: outer slice (1) + one []int per match (m); []Match (1) adopting them. m + 2 allocs, no re-copy. The per-match []int is stdlib-internal.
80 % of CPU at 10KB/dense. Unused capture groups are now non-capturing, so each row is 2 ints.
parseDigitRun(re) :126
setup
O(1) on the parse tree
0
Accepts exactly Concat[\b, Repeat{min≥1,max}([0-9]), \b] or \b\d+\b.
hot, once per Analyze when any digit-run pattern survives the class filter
O(n) byte walk
0 into the stack buffer (32 runs); heap slice only beyond that
Maximal digit runs with no ASCII word byte on either side — exactly \b\d+\b. TestDigitRunsMatchRegex checks equivalence against regexp on 2 000 random texts.
< 2 results: O(1). Else O(M log M): stable sort by (Start, −End), one sweep with a per-type last-kept table (linear, T types), stable sort by (−Score, Start, −Len)
0: in place on the input slice (slices.SortStableFunc, no reflection); the per-type table is a 16-entry stack array, heap only beyond
Types are not part of the sort key, so results tied on score, start and length keep their input order across types — the anonymizer resolves such overlaps by order. Kept spans of a type have strictly increasing ends, so containment in any kept span of that type implies containment in its last one. Identical spans keep the higher score, earlier on a tie.
contextWords + Σ over boostable results of (appendWordsBefore + appendWordsAfter + supported) = O(R + M·(k·w̄ + C·k)); a huge token before a match makes one window O(n)
one window slice (k strings) + contextWords + foldWord lower-casing only for words with upper-case bytes and ≤ maxWord
Bounded against the base64-blob case (see comment in source).
contextWords(recs, results) :172
hot (when M > 0)
O(M + R)
byRecognizer map (1 + growth)
*PatternRecognizer supplies pre-split phrases; any other ContextualRecognizer is split per call via splitContext.
splitContext(entries) :213
setup (via WithContext)
O(Σ|entry|)
phrases + lower-cased words
-
supported(window, phrases) :229
hot
O(C · k · |phrase|)
0
-
containsPhrase(window, phrase) :240
hot
O(k · |phrase|)
0
-
wordMatches(word, context) :270
hot
O(|context|)
0
-
foldWord(s, maxWord) :297
hot
O(|s|) if folded, O(1) otherwise
0–1 (ToLower, only when needed)
-
appendWordsBefore(dst, text, end, n, maxWord) :307
hot
O(bytes walked back to the k-th word boundary); rune-decoded
0 own + foldWord
ASCII fast path for isWordRune would cut the per-rune unicode lookups (undone, minor).
Interfaces and plain structs only (Token, NerSpan, NlpArtifacts,
NlpEngine, BatchNlpEngine, ArtifactRecognizer). No runtime cost; the
implementations are in ner and pfilter below.
All 52 constructors are setup: each calls analyzer.MustPattern, so the
patterns are built at All()/LoadDefaults time, not at package init, once
per language loaded; the compiled regex behind each source is cached
process-wide, so a second language pays only the Pattern and recognizer
structs. The table below lists what matters at runtime: pattern count, base
score, and the validator that runs per match. Capture groups not consumed by
WithGroup are non-capturing.
2 (0.7 / 0.3 \b[A-Z0-9]{7}\b — very high m on code-like text)
none
ITDriverLicense / ITPassport
italy.go
1 each (0.6)
none
ESNIF
spain.go:14
1 (0.7)
validateESNIF
ESNIE
spain.go:34
1 (0.7)
validateESNIE
SGFIN
singapore.go:10
1 (0.7)
validateSGFIN
SGUEN
singapore.go:46
2 (0.5 / 0.7)
none
BRCPF
brazil.go:13
2 (0.4 / 0.3 \b\d{11}\b)
validateBRCPF
BRCNPJ
brazil.go:36
2 (0.4 / 0.3)
validateBRCNPJ
BRRG
brazil.go:62
1 (0.4)
none
BRCNH
brazil.go:73
1 (0.3 \b\d{11}\b)
validateBRCNH
BRPIS
brazil.go:113
2 (0.4 / 0.3)
validateBRPIS
BRCNS
brazil.go:167
2 (0.4 / 0.3)
validateBRCNS
BRTitulo
brazil.go:225
2 (0.4 / 0.3)
validateBRTitulo
BRRenavam
brazil.go:271
1 (0.3 \b\d{11}\b)
validateBRRenavam (same math as PIS)
BRCEP
brazil.go:305
1 (0.5)
none
BRCEPUnformatted
brazil.go:327
1 (0.8 \b\d{8}\b)
context validator labelBefore on every 8-digit run
BRPlaca
brazil.go:345
2 (0.6, (?i))
none
BRPixKey
brazil.go:367
1 (0.8, UUID)
context validator labelBefore on every UUID
PLPESEL
others.go:11
1 (0.3 \b\d{11}\b)
validatePESEL
KRRRN
others.go:34
1 (0.7)
validateKRRRN
FIPersonalCode
others.go:57
1 (0.85)
validateFIPersonalCode
THTNIN
others.go:83
2 (0.3 \b\d{13}\b / 0.85)
validateTHTNIN
Shared digit runs. Sixteen patterns are exactly \b\d{n}\b or
\b\d{a,b}\b (\b\d{11}\b ×6, \b\d{9}\b ×3, and one each of 8, 8–17,
12, 13, 14, 15). None of them runs a regex: the engine finds every bare
digit run once per Analyze and each pattern selects by length
(analyzer/matcher.go). The remaining digit-bearing patterns that share a
source (AUTFN = AUACN) share one compiled program and one scan per call.
Every row is testing.AllocsPerRun == 0, pinned by
TestValidatorsDoNotAllocate; the rewrites were differential-fuzzed against
the previous implementations on every regex-reachable input.
Function
File
Time
Allocations
Note
validateIBAN / mod97Push
generic.go:135-183
O(s)
0
Streams mod-97 over the original bytes, s[4:] then s[:4], skipping spaces; letters expand to two digits inline.
ssnDigits / validateSSN / validateITIN
us.go:23-76
O(s)
0
Byte walk into a [9]int.
validateABA
us.go:132
O(s)
0
-
validateNHS
uk.go:19
O(s)
0
-
validateNINO
uk.go:46
O(s)
0
Rune walk skipping unicode.IsSpace into a [9]byte.
spans copy; 2 reflection sorts (closure + Swapper each); per (span, kept) pair []Result{s} / next slices → O(m²) small allocs even when nothing overlaps
Check disjointness before allocating; slices.SortStableFunc; keep kept sorted by Start and binary-search candidates.
subtract(p, k) :148
per pair
O(1)
1–2 (always allocates, even for the no-overlap case)
Hot path per Analyze: ProcessText → ProcessTexts → fold → segment →
window → hugot pipeline → span conversion → merge → snap. The model forward
pass dominates everything else by orders of magnitude; Go-side work matters
for GC pressure and for short texts under SegmentFields.
Function
File:line
Path
Time
Allocations
Note
New(ctx, cfg)
ner.go:54
setup
model download/verify (sha256 over ~260 MB) + ONNX session load
session, pipeline, optional second tokenizer
-
ensureModel / Close / Config
ner.go:158-188
setup
I/O / O(1)
-
-
ProcessText(text, lang)
ner.go:199
hot
= ProcessTexts with T = 1
[]string{text}
single-text fast path (minor)
inferenceRows(texts, folded, offsets)
ner.go:227
hot
O(N) + tokenization of every segment (see tokenWindows)
rows (grows by doubling), windowed []bool, per segment a 1-elem []segment and []textWindow
short-circuit when len(folded)+specials ≤ tokenBudget — every content token covers ≥ 1 byte, so one window is provably enough and the tokenizer pass is skipped for every short cell/line
windowTokenizer / loadWindowTokenizer
windows.go:74-86
setup
tokenizer.json parse
vocab maps (tens of MB)
-
tokenWindows(tk, folded, budget)
windows.go:112
hot per segment
full EncodeWithAnnotations O(n) always, + per window a re-Encode with shrink retries ≈ 1.15·t tokens
encoding (IDs, token strings, spans, mask — ~5 slices + t strings), contentSpans copy (16·t bytes, built even when the result fits one window), wins growth, per-retry encodings
move the contentSpans copy after the budget check; count-only encode if the API allows
contentSpans(enc)
windows.go:153
hot per segment
O(t)
1 (16·t bytes)
build lazily
byteWindows(n, size, overlap)
windows.go:170
fallback only
O(n/step)
growth
pre-size
mergeSpans(spans)
windows.go:195
hot (windowed texts)
O(m log m)
sort.Slice closure + Swapper (2) + widest map
slices.SortFunc, linear type list
wordRune(r)
words.go:38
hot per edge rune
O(1), three unicode lookups
0
ASCII fast path
wordBounds(s, i)
words.go:50
hot ≤ 2m
O(min(word, 100))
0
bounded by maxWordChars (benchmarked: 17 ms → < 1 µs on a 4 MiB blob)
P × pf_load (full GGUF per context: memory = P × model)
engine, config map, contexts, pool
-
Close / Config
pfilter.go:38-43
—
O(P) / O(1)
0
-
ProcessText(text, lang)
pfilter.go:51
hot
C(n) + O(m·I)
NlpArtifacts + Ents append growth + classify
make(Ents, 0, len(raw))
toNerSpan(text, ent)
pfilter.go:70
hot ×m
O(I) ×2 + O(1) + byteSpan
0 when the label is mapped; 2 string allocs via normalizeLabel when unmapped (46 of 54 multilingual labels by default)
lazy per-label cache
ignored(label)
pfilter.go:100
hot ×2m
O(I)
0
-
Recognizer(language)
pfilter.go:113
setup
O(1)
1
-
Recognizer.SupportedEntities()
pfilter.go:131
hot when Entities set
O(I + L + S log S)
2 maps + slice + sort per call
memoize at New
Recognizer.Analyze / AnalyzeWithArtifacts
pfilter.go:139-151
hot
O(E·S) + ProcessText / O(m·E)
results growth
pre-size
contains / supportsAny
pfilter.go:168-177
hot
O(list) / O(have·want)
0
-
goString(p)
ffi.go:57
hot ×m
O(ℓ) strlen + copy
1 string per label
zero-copy unsafe.String for the map lookup; copy only unmapped labels
loadLibrary(path)
ffi.go:80
setup (cached per path)
dlopen + 7 RegisterLibFunc
reflect closures; MkdirAll under the cache dir even when nothing is downloaded
-
newFFIClassifier(cfg)
ffi.go:143
setup
P × pf_load
contexts, C strings
-
ffiClassifier.classify(text, threshold)
ffi.go:180
hot
C(n) + purego trampoline + pool wait
strings.CString(text) copies the whole text (n+1 bytes) although pf_classify takes an explicit length; reflect boxing of 6 args; &out, &n escape (2); results (48·m bytes); m × goString
pass unsafe.StringData(text) + runtime.KeepAlive; reuse an out-struct per context
ctxPool.acquire / release
binding.go:45-50
hot
O(1) channel ops; acquire blocks unboundedly when all P contexts are busy
runeToByteMapper O(n) + regexp2 []rune(s) O(n) + backtracking scan: O(r·|p|) typical, exponential worst case, bounded only by MatchTimeout (1 s default, per match attempt, so m matches can take m × 1 s)
byte table []int (8·(n+1) bytes) + closure; regexp2 []rune (4·r bytes); mutex per run; per match: fresh *Match (3 + g allocs), Groups() builds and then copies a []Group (≈ 2g allocs, ~160·g bytes), spans [][2]int → ≈ 3g + 5 allocs per match vs 1 for stdMatcher
ASCII fast path (identity mapping, no table); []int32; iterate GroupByNumber instead of Groups(); consider a lower default timeout for untrusted patterns
Ranked by measured impact on the default (pattern-only) Analyze path, then
by the per-call allocation count they remove. Each line names the function,
the evidence, the change, and whether it has landed.
74 full-text regex passes — stdMatcher.FindAll via PatternRecognizer.Analyze.
91 % of CPU at 10KB/dense, 69 % at 1MB/dense; throughput was a flat
~1 MB/s. Options, cheapest first:
✅ Duplicate digit-run patterns share one compiled program and one scan
per Analyze (program, scanCache in analyzer/matcher.go).
✅ Unused capture groups are non-capturing (Email, CreditCard,
DateTime, USITIN, IBAN (spaces)).
✅ Per-text byte-class prefilter (analyzer/classes.go): a pattern
whose matches must contain a class the text lacks is not scanned. This
is the 35× on prose.
✅ Shared digit-run scan: the 16 patterns of the exact form
\b\d{min,max}\b are served from one byte pass per Analyze
(digitRuns, matchDigitRuns), no regex at all.
TestDigitRunsMatchRegex proves equivalence against regexp on
2 000 random texts. A general combined scan for the other patterns
stays undone: it would change boundary semantics.
⬜ What is left is the stdlib regexp scan for the ~55 remaining
patterns: 80 % of CPU at 10KB/dense. Further gains need fewer or
narrower patterns, not engine work.
✅ RemoveDuplicates is O(M log M) — sort by (Start, −End), one
sweep with a per-type last-kept table, in place; was 27 % of CPU at
1MB/dense. The type is not in the sort key so ties across types keep
input order (the anonymizer depends on it). Per-recognizer dedup is
skipped on the engine path since the engine's pass subsumes it.
✅ labelBefore — allocation-free byte walk; differential-fuzzed
against the old implementation (300 k windows, 0 mismatches).
✅ Allow list — prepared once (prepareAllowList) and reused while
Options.AllowList is unchanged; 22 allocs/call → 1.
✅ RemoveDuplicates closure on empty input — early return.
✅ stdMatcher.FindAll double copy — Match adopts the stdlib []int.
Breaking: the exported Match.Groups field is gone; custom
Matchers build matches with NewMatch(loc []int) (lookaround is
the reference). The exported RemoveDuplicates still copies its
input; the engine uses the in-place removeDuplicates.
✅ stripSeparators / strings.Replacer per credit-card match — deleted.
✅ digitValues — append-style into a caller's stack array; every
validator is AllocsPerRun == 0 (TestValidatorsDoNotAllocate).
✅ contextWords — phrases split once in WithContext.
✅ PatternRecognizer.SupportedEntities — slice stored on the struct;
Registry.Recognizers memoized per filter.
✅ analyzeWithArtifacts — recognizers append into one engine-owned
slice (appendResults).
✅ Validator string building — validateIBAN streams mod-97 over the
original bytes; validateNINO, validateSSN/validateITIN,
INAadhaar, validateESNIE, validateFIPersonalCode, validateSGFIN
index bytes directly.
⬜ The one []int per match inside regexp.FindAllStringSubmatchIndex
(80 % of remaining allocations on dense text) — stdlib-internal; no
public API returns matches into a caller-owned buffer.
◐ NewEngine() = 97 µs, 178 KB, 2 362 allocs (was 169 µs / 459 KB /
3 797): compileProgram caches compiled regexes by source, so a second
language reuses them; the Pattern/recognizer structs are still rebuilt
per language.
ner: ✅ windows() short-circuits a segment whose byte count plus the
special tokens fits the budget, so short lines and cells skip the sizing
tokenizer pass; ✅ SupportedEntities is computed once per Recognizer.
⬜ Rows that do need windowing are still tokenized twice (sizing, then
inside hugot) and windowed rows three times; ⬜ foldASCII on non-ASCII
text costs 8 bytes per input byte plus two allocations per non-ASCII
rune; ⬜ reflect-based sorts in mergeSpans/snapToWords.
pfilter: classify copies the whole text to a C string although the
C API takes a length; goString allocates per label just to key a map;
Config.SupportedEntities is rebuilt per filtered Analyze.
lookaround: two O(n) decodes per pattern per call and ~3g + 5 allocs
per match; worst-case exponential time with a 1 s timeout per match
attempt. ASCII fast path and GroupByNumber iteration are the cheap
fixes.
anonymizer: AnonymizeWith moves O(K·n) bytes and resolve makes
O(m²) allocations even when nothing overlaps; a forward Builder pass and
a disjointness check before allocating fix both.
cmd/alcatraz: maskString is O(m·n) in copying; writeFindings
does ~12 allocs and one syscall per finding on unbuffered stdout;
scanDiff runs a regexp on every line of an active file.
CPU and allocation profiles were taken with a small harness that loads
bench/corpus.jsonl, warms the engine, then runs Analyze under
runtime/pprof with MemProfileRate = 1; go tool pprof -top and
-list on analyzer.RemoveDuplicates, stdMatcher.FindAll and
PatternRecognizer.Analyze give the per-line attributions quoted above.