D216 - A pattern's size is computed once, not per candidate
decided ยท 2026-08-24
Pattern::len multiplied the lengths of every vocabulary
in the pattern, every time it was asked. The search asks it for every
pattern, for every candidate: once to locate which pattern a global
index falls in, and again inside write_at to bounds-check
the index the caller had just checked against that same number.
Over a 2.6-billion-candidate run against one module, that is roughly
thirty dependent heap loads per candidate - parts is a
Vec<Vec<String>>, so each length lives in a
separate allocation - to rediscover eight numbers fixed at the moment
the grammar was parsed.
The number now lives in the struct, computed in
Pattern::new. parts is private, which is what
makes the cached value trustworthy: nothing can change the vocabularies
without going through the constructor.
What it was worth
Measured on this project's own workload - names against
libSceNpCppWebApi.prx, 2.6 billion candidates, sixteen
threads - as candidates per process-CPU-second rather than per
wall-second. Five runs per variant:
| candidates/CPU-second | ||
|---|---|---|
| before | 2,427,396 | |
| after | 2,717,500 | +11.9% |
Wall-clock was useless here and worth recording as a trap: on a machine sharing sixteen threads with other work, the same binary varied 48% run to run, which is ten times the effect being measured. Two early A/B rounds looked clean and consistent, and pointed the wrong way. CPU-time normalisation fixes this because a descheduled process accrues no CPU time - contention changes how long a run takes, not what it costs.
What was tried and rejected
A bitmap prefilter in front of Targets::wants, on the
reasoning that a NID is already a uniformly distributed hash and
HashSet was running SipHash over it for every
candidate.
It works - +5.6% on its own - and it is not worth having. A probe
that reduced wants to a single integer comparison, keeping
the SHA-1 alive so it could not be optimised away, measured +7.4%: that
is the ceiling on any lookup optimisation, and the prefilter
was already close to it. Stacked on top of this decision it added +0.8%,
inside the noise.
Both were relieving the same stall. While the core waited on the pointer chase this decision removes, the hash lookup's work was hiding in the shadow of that latency for free. Remove the chase and the lookup stops being on the critical path. Anyone tempted by a faster hasher, a perfect-hash table or a Bloom filter here should read that ceiling first: there is at most 7% in it, and this decision has already taken most of it.
What actually dominates
SHA-1, at roughly nine tenths of the per-candidate cost.
sha1 0.10 dispatches to a SHA-NI backend at runtime where
the CPU has one, and the development machine - a Coffee Lake part - does
not, so it takes the software path. The remaining multiples are in
hardware with SHA extensions, or in multi-buffer SIMD, not in this
file.