Skip to content

Decoder 3-5x faster, encoder up to 3x, identical results - #39

Merged
paulmillr merged 37 commits into
paulmillr:mainfrom
shreeve:perf
Sep 21, 2026
Merged

paulmillr merged 37 commits into
paulmillr:mainfrom
shreeve:perf

Conversation

@shreeve

@shreeve shreeve commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

We should forget about small efficiencies, say about 97% of the time: premature
optimization is the root of all evil. Yet we should not pass up our opportunities
in that critical 3%. — Knuth

This is the 3%. The 0.7.0 architecture put the decoder on packed bitmaps, a
luma arena and a pyramid walk; what remains is a handful of inner loops
(luma conversion, binarizer, finder runs, grid sampling, Reed-Solomon) and
the per-call cost of arenas sized for Version 40. This PR squeezes exactly
those, one unit of work per commit, each commit carrying its own before
and after numbers. Every decode result, every encoded byte and the BoofCV
accuracy (57.5%, identical per category) are unchanged.

Numbers

Same machine (Apple M-series), main vs this branch, Node 26 (V8) and
Bun 1.4 (JSC), npm run benchmark.

Decode:

workload Node before Node after Bun before Bun after speedup
raster v1 127 µs 32 µs 150 µs 26 µs 4.0x / 5.7x
raster v18 348 µs 144 µs 439 µs 117 µs 2.4x / 3.8x
720p ok 2.19 ms 1.45 ms 1.91 ms 1.12 ms 1.5x / 1.7x
1080p ok 4.96 ms 3.49 ms 4.33 ms 2.73 ms 1.4x / 1.6x
12MP ok 23.2 ms 14.6 ms 19.3 ms 14.0 ms 1.6x / 1.4x
12MP, max effort 22.6 ms 14.5 ms 16.8 ms 10.1 ms 1.6x / 1.7x
1080p, no code 38 ms 8.3 ms 4.6x

Against the field (npm run benchmark:thirdparty, Node):

workload qr before qr after @zxing/library zxing-wasm
raster v1 125 µs 30 µs 55 µs 65 µs
raster v18 350 µs 146 µs 357 µs 465 µs
720p ok 2.26 ms 1.54 ms 3.33 ms 2.07 ms
1080p ok 4.89 ms 3.66 ms 9.46 ms 7.56 ms
12MP ok 22.9 ms 16.5 ms 61.8 ms 36.6 ms

BoofCV quality (npm run benchmark:quality): 57.5% → 57.5%, every category
identical, 10 → 6 ms per image.

Encode (byte-identical output):

output Node before Node after Bun before Bun after
raw v1 3.6 µs 2.8 µs 2.7 µs 2.2 µs
raw v3 7.1 µs 5.1 µs 5.6 µs 3.6 µs
raw v8 23.2 µs 15.2 µs 17.5 µs 11.2 µs
raw v18 68 µs 44 µs 49 µs 30 µs
ascii v18 70 µs 42 µs 48 µs 33 µs
gif v18 69 µs 44 µs 47 µs 29 µs
data-url v18 68 µs 44 µs 47 µs 30 µs
svg v1 11.9 µs 5.8 µs 6.7 µs 3.9 µs
svg v18 214 µs 70 µs 110 µs 73 µs

Commits

Every commit is one change with its own before/after numbers in the
message. Decoder results are identical throughout: the test suite, a
bitmap/luma probe over hundreds of sizes, formats and alignments, a
grid/codeword/result probe over every version, ECC level, mask and damage
level plus the 536 BoofCV photos, and the BoofCV accuracy run.

Decoder, pixel pipeline:

  • convert luma and build the pyramid a word at a time (TypedArray.set for
    planar input, (r + 2g + b) >> 2 straight from RGBA words, three-byte RGB
    from word triples, 2x2 box filter in 16-bit lanes)
  • binarize eight pixels a row with word-wise compares; slide the 5x5
    threshold smoother along each block row
  • keep bitmap words in Int32Array (no >>> 0 coercions anywhere)

Decoder, finder:

  • walk finder runs straight off the packed row, keep the current word
    resident across runs, walk vertical runs down the column without bit()
  • leave cross() as soon as a run cannot pass ratio(); test ratios on
    integer bounds (proved equal on 206 million tuples)

Decoder, symbol:

  • sample the module grid without per-module calls; walk codewords with
    packed column masks and whole-byte stores; read payload fields through
    a three-byte window
  • Reed-Solomon: settle intact blocks with the LFSR remainder, fold it four
    coefficients at a time from the encoder's packed table, and derive a
    damaged block's syndromes from the remainder register (correction path
    −35 to −42%)

Decoder, per-call cost:

  • create payload views on first use; size version scratch to the symbol
    actually attempted; grow finder records on demand; wipe each luma arena
    once in clean() (the reflective sweep stays)
  • opt-in nativeLimit (and nativeEvery in QRCanvas): skip the
    full-resolution finder search on big camera frames; default off

Encoder (byte-identical on 11,232 outputs across every format, ECC, mask,
version and option): Int32Array matrix words, rolled N3 penalty window,
72-entry mask table, SVG path commands cached per width, four ASCII glyphs
per concatenation, GIF rows copied whole then spread into LZW chunks,
Reed-Solomon remainder four coefficients a word, table-driven validation,
raw rows from packed words, data bits placed two at a time.

DOM: wait for readyState >= 2 before new VideoFrame (a stream started
before its first frame silently lost the native path for good), do not copy
a new frame into the arena while an async decode still reads it, set
muted through the property for mobile autoplay. Checked in Chrome on a
live camera: native VideoFrame path from a cold start, 4.7 ms per
1664x1248 frame end to end.

Where this came from

We ported qr 0.7.0 to Rip, a language we
are building that compiles to JavaScript (the older
rip-lang v3 still has most of the docs
and examples), as the QR half of a barcode package with Code 128 and
PDF417 readers. Chasing camera latency there is where each of these ideas
was found and measured; this PR brings them back to the original, in your
style, so both implementations stay fast together.

Notes

  • Minimal diffs: no renames, no restructuring, your names and comments kept.
  • Test changes: two dom mocks gained readyState: 2 (matching the other
    mocks in that file), the batch test's finder-capacity expectation follows
    the new on-demand sizing, one decode test and one dom test cover
    nativeLimit/nativeEvery.
  • README speed tables are left for your machine (they were measured on Zen 5).

Every consumer of the packed matrix is bitwise or popcount, so the sign
of a word never matters, but its representation does: a v1 symbol never
sets bit 31, so an engine that encodes v1 first specializes on int32,
and the first full word read back from a Uint32Array arrives as a double
that deoptimizes the mask race for the rest of the process. Signed words
stay int32 in every case, the masks become ~(-1 << bits) and -1, and the
>>> 0 coercions go.

Benchmark (bun, encode raw, ECC medium, v1 first in the process):
v1 2.73 -> 2.59 us, v3 5.58 -> 4.53, v8 17.5 -> 15.7, v18 48.9 -> 43.6.
finderPenaltyVertical reloaded eleven matrix words at every row step.
The ten words of the window now load once per word column and each
step loads one new word and shifts the rest, one load per row instead
of eleven, on the scan that dominates whenever the early-out does not
fire.

Benchmark (bun, encode raw, ECC medium): v1 2.59 -> 2.53 us,
v3 4.53 -> 4.18, v8 15.7 -> 14.1, v18 43.6 -> 41.5.
Every Table 10 mask predicate repeats every 6 columns and 12 rows, so
the 8-bit predicate vector for a module is a lookup filled once from
the arithmetic instead of six modulos per call. The encoder pays it
once per version when it builds the mask planes; the decoder pays it
per module when it unmasks a read grid, which is where it shows.

Benchmark (bun): decode raster v1 151 -> 141 us, raster v18 419 -> 377;
encode within noise.
renderSvg built both the absolute and the relative move string for
every dark module and compared their lengths, then allocated a point
per module to remember the previous one. The lengths are now counted
from the digit counts of the coordinates, only the winning command is
built, and the previous position is two numbers. Ties still go to the
relative move, so the output is byte-identical.

Benchmark (bun, encode svg, ECC medium): v1 5.73 -> 4.75 us,
v3 10.4 -> 8.62, v8 32.5 -> 27.3, v18 102 -> 85.5.
Each LZW span, at most 126 pixels, was copied through a subarray view,
one allocation per span. A byte loop copies the same bytes with none.

Benchmark (bun, encode gif, ECC medium): v1 2.96 -> 2.50 us,
v3 4.85 -> 4.08, v18 42.0 -> 40.8; v8 within noise.
Every scanner eagerly built one Uint8Array view per possible byte-segment
length (2957 of them for the version 40 arena), so each decodeQR call paid
for thousands of view objects it never touched. Create a view the first
time a segment of that length is decoded and keep it for reuse.

Before → after (bun, M-series):
  raster v1   149 µs → 63 µs
  raster v18  395 µs → 296 µs
  720p        1.92 ms → 1.66 ms
  1080p       4.01 ms → 3.79 ms
The packed bitmap lived in a Uint32Array, so every word with its top bit
set came back as a double and the run walker and bitmap writer paid for
`>>> 0` coercions on each step. Int32Array keeps every word a machine
integer end to end; the masks and shifts are unchanged in meaning, and
Math.clz32 already reads its argument as unsigned.

Before → after (bun, M-series):
  raster v1            63 µs → 58 µs
  raster v18          296 µs → 280 µs
  720p               1.66 ms → 1.61 ms
  1080p              3.79 ms → 3.74 ms
  1080p miss, noise  38.0 ms → 26.7 ms
  1080p miss, stripes 10.9 ms → 9.7 ms
The finder scan called run() once per run, re-deriving the row base and
bounds checks on every call and clamping through an Infinity cap. Measure
each run inline instead: isolate the word's opposite-color bits with
clz32 and consume whole words until a stop or the row's end. Also skip
ratio() when the center run is no longer than a neighbor, which ratio()
would reject anyway (center > 1.5 modules, neighbors < 1.5), so results
are identical.

Before → after (bun, M-series):
  raster v1            58 µs → 48 µs
  raster v18          280 µs → 230 µs
  720p               1.61 ms → 1.47 ms
  1080p              3.74 ms → 3.32 ms
  1080p miss, noise  26.7 ms → 11.2 ms
  1080p miss, stripes 9.7 ms → 5.5 ms
blocks(): read the eight pixels of a block row into locals and fold sum,
min and max with plain compares instead of an inner loop of Math.min and
Math.max calls. bitmap(): add the 5x5 smoother's row as five terms, and on
little-endian hosts with word-aligned rows compare four luma pixels per
32-bit word in two 16-bit lanes ((cut + 256) - v sets lane bit 8 exactly
when v <= cut, and no lane borrows because every difference stays
positive). Rows that are not word-aligned keep the scalar loop, and a
threshold that is not a number keeps it too, so the packed bitmap is
identical bit for bit on every probe image, tiny ones included.

Before → after (bun, M-series):
  raster v1            46 µs → 37 µs
  1080p miss, blank   4.9 ms → 3.7 ms
  1080p miss, stripes 5.5 ms → 4.4 ms
  raster v18, 720p, 1080p: within noise
copyLuma copied planar input byte by byte and folded four-byte pixels one
channel at a time. Planar rows now go through TypedArray.set (one memcpy
for a tight plane), and on little-endian hosts a word-aligned RGBA/BGRA/X
frame is folded four bytes per read as (r + 2g + b) >> 2 straight from the
signed word. The 2x2 pyramid box filter likewise sums one word from each
source row in two 16-bit lanes to emit two pixels. Rows that are not word
aligned keep the scalar loops; every layer's luma is identical bit for bit
on 576 format/stride/alignment probes.

Before → after (bun, M-series):
  raster v1            37 µs → 33 µs
  raster v18          225 µs → 206 µs
  720p               1.50 ms → 1.22 ms
  1080p              3.4 ms → 2.8 ms
  12MP               19.5 ms → 16.2 ms
  12MP, max effort   16.3 ms → 11.8 ms
  1080p miss, blank   3.7 ms → 2.9 ms
  1080p miss, noise  11.4 ms → 10.2 ms
Every block computed all of its syndromes through log/exp multiplies
before learning it had no errors, which is the common case for a clean
read. Divide the block by the generator first using the encoder's
coefficient*feedback products table (one byte lookup per step, shared via
the RS cache): a zero remainder and all-zero syndromes are the same
condition, so an intact block skips the syndrome loop entirely and a
damaged one proceeds exactly as before.

Before → after (bun, M-series):
  raster v18            206 µs → 179 µs
  raster 114px symbol    70 µs → 65 µs
  raster 202px symbol   177 µs → 157 µs
  raster 306px symbol   451 µs → 361 µs
new VideoFrame(video) throws while the element has no decoded frame yet
(readyState below HAVE_CURRENT_DATA). readFrame treated that throw as
missing WebCodecs support and cached the verdict for the whole source, so
a scanner started before the first frame arrived fell back to canvas
drawImage for the life of the stream and never took the native luma path.
Return early instead, exactly as draw() already does; the next frame
request finds the element ready.
With opts.async, decode() returns early when a decode is still pending,
but readFrame had already copied the new VideoFrame into the scanner's
luma arena by then, under the feet of the cooperative decode still
sampling it. Let the canvas reader report that a decode is pending and
leave the frame alone until it settles.
setAttribute('muted') only sets the element's default; the muted property
is the state that mobile autoplay policy checks. A video element created
after parsing keeps its property false, so an inline camera preview could
stay paused on iOS until the user tapped it. Set the property before the
stream attaches.
A vertical run kept calling bit() per step, re-deriving the word offset
and mask and re-checking four bounds each time. The column's word offset
and mask are fixed; only the row bound moves, so step the word index by
the row stride and test one bound. Same run lengths bit for bit.

Before → after (bun, M-series, alternating A/B):
  1080p miss, noise  10.9 ms → 9.9 ms
  raster v1            46 µs → 44 µs
  raster v18          248 µs → 237 µs
ratio() accepts only a center run over 1.5 modules with every other run
under that, so a center shorter than two bits, or any side run no shorter
than the center, can never pass. Measure the center first (both
directions), then check each side run as it is measured. No run's start
or length changes; on a 1080p noise frame 18,700 of 27,000 vertical
cross-checks now leave early and vertical runs drop from 163k to 106k.

Before → after (bun, M-series, alternating A/B):
  1080p miss, noise  10.5 ms → 9.7 ms
  raster v1, v18: within noise
Runs are integers, so the half-module tolerance |ms - a| < ms / 2 with
ms = total / 7 is exactly total < 14a < 3 * total, and the center test is
3 * total < 14c < 9 * total. Replace the division and five Math.abs calls
with integer multiplies and compares; the returned pitch is still
total / 7. Verified equal on every (a, b, d, e) up to 36 with c up to 108
plus two million random tuples: 206,283,549 cases, no differences.

Before → after (bun, M-series, alternating A/B):
  1080p miss, noise  10.1 ms → 9.7 ms
  raster: within noise
Each run recomputed x & 31 and x >>> 5 and reloaded its word. Carry the
word and its consumed-bit shift from run to run and load the next word
only when a run crosses a word boundary. Same run lengths bit for bit.

Before → after (bun, M-series, alternating A/B):
  1080p miss, noise  9.9 ms → 9.2 ms
  raster v1, v18: within noise
projectQuad called read() through a mapPoint scratch object for every
module. Hoist the plane fields and the nine homography entries, compute
the three row products once per row and sum them in read()'s exact order,
and sample inline; the tile bounds become explicit parameters. Same
samples bit for bit (grid, codewords and results identical on 7,247
symbol variants and all 536 BoofCV photos).

Before → after (node, min of alternating rounds):
  projectQuad v10   6.1 µs → 5.7 µs
  projectQuad v18  29.1 µs → 22.5 µs
  projectQuad v40  77.9 µs → 60.4 µs
  bun: neutral (JSC already inlines the call)
The extraction walk evaluated maskBits() per module and OR-ed single
bits into the codeword array. Mask predicates repeat every twelve rows,
so pack one period per column into a word (24 maskBits calls per column
pair instead of one per module), track y mod 12 incrementally, and
accumulate eight bits before storing each byte whole; every byte in the
codeword range is written, so the clearing fill goes.

Before → after (decodeGrid, clean symbol, min of alternating rounds):
  v10  bun 5.8 µs → 5.3 µs   node 9.5 µs → 6.9 µs
  v18  bun 20.5 µs → 17.4 µs node 37.2 µs → 26.4 µs
  v40  bun 56.0 µs → 46.6 µs node 105 µs → 72 µs
Payload.read assembled each field one bit at a time. No field exceeds
16 bits, so three bytes cover any field at any bit offset: shift the
window down and mask. Bytes past the end read as zero and are masked
away; the length check is unchanged.

Before → after (decodePayload, min of alternating rounds):
  v10  bun 0.85 µs → 0.50 µs   node 1.1 µs → 0.7 µs
  v18  bun 3.2 µs → 1.9 µs     node 7.3 µs → 3.5 µs
  v40  bun 10.2 µs → 6.5 µs    node 20.3 µs → 9.5 µs
The products table is also packed four bytes to an Int32 (coefficient j
in byte j & 3 of word j >> 2), so each data byte shifts the remainder
down one byte across at most eight words and XORs one packed row,
instead of up to thirty byte operations. The byte table stays for the
decoder's remainder check; the table-less fallback had no callers.

Before → after (leave-one-out, min of alternating rounds, raw output):
  node  v3 5.9 µs → 5.3 µs, v8 16.9 → 15.7, v18 50.8 → 45.0
  bun   v3 3.7 µs → 3.5 µs, v8 11.3 → 10.8, v18 31.9 → 29.6
Use the encoder's packed products table and keep the LFSR register in an
Int32Array, so each block byte shifts and folds the register one word at
a time instead of one byte. Coefficient j lives in byte j & 3 of word
j >> 2; the top word's spare bytes shift in zeros and fold zero products.

Before → after (decodeGrid, clean symbol, bun):
  v10  8.6 µs → 8.0 µs
  v18  30.5 µs → 27.5 µs
  v40  86.2 µs → 63.8 µs
The register holds x^words * C(x) mod g(x), which agrees with the block
at every generator root up to the factor alpha^(i * words), so a damaged
block's syndromes come from its `words` register coefficients instead of
a Horner pass over the whole block (28 bytes instead of ~148 for a
version 40 block). Verified against the direct syndromes for every
parity length on random blocks.

Before → after (decodeGrid with 6/12/30/80 flipped modules, bun):
  15.3 / 43.2 / 64.5 / 187 µs → 9.5 / 28.2 / 41.3 / 117 µs
  node: 17.5 / 57.3 / 84.0 / 246 µs → 12.1 / 37.7 / 49.7 / 141 µs
Most of an SVG encode was spent formatting numbers and concatenating a
fresh command string per dark module. The two common relative moves
(same row, next row) with the h-1 return are kept in a table keyed by
(dy, dx) for the current output width, filled on first use, so each dark
module costs one lookup and one concatenation. Absolute moves and the
x < 10 H<x> form keep the inline path; bits are read from the packed row
directly. Output is byte-identical.

Before → after (leave-one-out, min of alternating rounds, svg output):
  node  v1 10.2 µs → 6.3 µs, v3 20.7 → 10.7, v8 59.6 → 27.8, v18 188 → 77
  bun   v1 4.4 µs → 3.6 µs, v3 8.0 → 5.9, v8 24.3 → 17.1, v18 76.6 → 71.5
The half-block renderer appended one glyph per cell. Cell codes are
packed two bits each and a 256-entry table of glyph quads is appended
once per four cells; the two module rows of a text line are hoisted and
read from the packed words. Output is byte-identical.

Before → after (leave-one-out, min of alternating rounds, ascii output):
  node  v1 3.5 µs → 2.9 µs, v3 6.8 → 5.4, v8 18.4 → 15.6, v18 55.7 → 44.7
  bun   v1 2.4 µs → 2.3 µs, v3 4.1 → 3.9, v8 12.3 → 12.1, v18 34.7 → 33.7
Pixel rows are block-copied back to back into the unused tail of the
output buffer, and each 126-byte chunk is then moved forward behind its
two-byte header with copyWithin. Every chunk lands at or before its
source, so the moves never clobber pixels still to be copied, and the
per-pixel byte loop disappears. Output is byte-identical.

Before → after (leave-one-out, min of alternating rounds, gif output):
  node  v1 3.9 µs → 3.4 µs, v3 6.7 → 6.1, v8 19.3 → 16.8, v18 54.2 → 45.9
  bun   v1 2.3 µs → 2.3 µs, v3 3.8 → 3.7, v8 11.4 → 10.9, v18 31.5 → 28.6
The alphabet check scanned a 45-character string per input character.
Test ALNUM_VAL against the mode's alphabet size instead; the error
message is built only on failure from the code point at the offending
index, which is the same character the string iterator yielded (63 error
cases verified identical).

Before → after (leave-one-out, min of alternating rounds, raw output):
  node  v1 3.2 µs → 3.1 µs, v8 18.6 → 17.1, v18 54.7 → 49.6
  bun   v1 2.1 µs → 2.0 µs, v8 12.6 → 10.8, v18 36.2 → 29.6
Border rows are stored without map or bit lookups, and module rows read
the column map once per cell with the row's word base hoisted, instead
of a per-cell helper that re-tested both coordinates. The boolean[][]
allocation itself is the floor: filled, sliced and push-built rows all
measured slower than plain indexed stores.

Before → after (leave-one-out, min of alternating rounds, raw output):
  node  v1 3.4 µs → 3.1 µs, v3 6.2 → 5.7, v8 18.1 → 17.1, v18 52.8 → 49.9
  bun   v3 3.9 µs → 3.6 µs, v8 11.7 → 11.4, v18 32.3 → 31.7
The zigzag fills a two-module column, so consecutive placement positions
usually share a word with the second bit one below the first. A per-pair
table (word << 6 | shift << 1 | 1) lets one OR place a 2-bit value; a
pair that straddles a word or a function pattern falls back to the
single-bit positions.

Before → after (steps 1-6 vs this, node, raw output):
  v1 2.80 µs → 2.75 µs, v8 15.7 → 15.2, v18 44.8 → 44.5
  random alphanumeric payloads: 2.6-3.2% faster
The reflective sweep still visits every typed-array field, but layer
zero's luma is the scanner's own arena and every layer's lumaWords is a
view over its luma, so those aliases were zero-filled two and three
times over. Skip them by identity; nothing else changes.

Before → after (bun, min of alternating rounds):
  clean(), 1080p scanner  105 µs → 51 µs
  raster v1 (116 px) decode  −7%
The smoother is clamped two blocks inside each edge, so per block row the
five column sums are built once and rolled one column at a time: five
loads per block instead of twenty-five. Integer sums in a different order
give the same threshold bit for bit, and the index arithmetic is
unchanged, so the tiny-grid behaviour stays as it was.

Before → after (bun, bitmap stage, min of alternating rounds):
  raster v1 layer 0   −18%
  1080p layers 0..3   −5 to −14%
Three words carry four RGB pixels; copyTriples mirrors copyWords with the
same alignment and stride gate and a byte tail, so a tight RGB frame no
longer goes through the per-channel byte loop. Verified mismatch-free on
two million random pixels.

Before → after (bun, min of alternating rounds):
  1080p RGB conversion  1.43 ms → 0.81 ms
  1080p RGB decode      −8 to −10%
patterns and inverted were sized to one record per 7x7 cell of the
maximum frame (1.6 MB and 0.4 MB for a 1080p one-shot scanner),
allocated up front and then touched again by clean(). Start at 64
records and double inside find(), the only writer, up to the same
one-per-cell ceiling of the staged frame, with the same "finder storage
exhausted" error beyond it. The batch test's capacity expectation
follows the new initial size.

Before → after (bun, min of alternating rounds):
  constructor, 1080p  21.5 µs → 16.4 µs
  clean(), 1080p      79 µs → 51 µs
  1080p decode        −4 to −6%, 720p −4%
The module grid, function map, codeword and payload arenas were always
allocated for Version 40 (two 31 KB maps, two 3.7 KB byte arrays and a
3,707-slot view list per scanner), which dominated a small one-shot
decode. They start empty and reserve() grows them where the symbol size
is committed, the one point every consumer is downstream of; the payload
bytes follow the first byte segment's symbol and the view cache resets
when they regrow. A reusable scanner pays each growth once.

Before → after (bun, min of alternating rounds):
  raster v1, 58 px    18.1 µs → 14.3 µs
  raster v1, 116 px   33.7 µs → 29.8 µs
  raster v8           −5%, raster v18 −4%
A camera frame is searched coarse-to-fine, and a symbol big enough to
scan is normally found on the half-resolution layer, but a miss still
pays for binarizing and scanning every native pixel. `nativeLimit` skips
the finder search on the native layer when its shorter side exceeds the
limit; modules are still sampled from native luma, and a frame too small
to have a half layer is always searched. QRCanvas forwards the option and
`nativeEvery` lets every n-th frame search native regardless, so a small
symbol on a large frame is still found within a few frames. The default
(Infinity) leaves every result unchanged.

2592x2160 frame, default → nativeLimit: 1080 (bun, M-series):
  blank miss   8.1 ms → 4.4 ms
  noise miss  27.4 ms → 9.2 ms
  symbol hit   3.4 ms → 3.4 ms
@paulmillr

Copy link
Copy Markdown
Owner

Crazy good stuff. Thank you.

@shreeve

shreeve commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

I love you man.

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.

2 participants