Decoder 3-5x faster, encoder up to 3x, identical results - #39
Merged
Merged
Conversation
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
This was referenced Sep 18, 2026
Owner
|
Crazy good stuff. Thank you. |
Contributor
Author
|
I love you man. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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),
mainvs this branch, Node 26 (V8) andBun 1.4 (JSC),
npm run benchmark.Decode:
Against the field (
npm run benchmark:thirdparty, Node):BoofCV quality (
npm run benchmark:quality): 57.5% → 57.5%, every categoryidentical, 10 → 6 ms per image.
Encode (byte-identical output):
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:
TypedArray.setforplanar input,
(r + 2g + b) >> 2straight from RGBA words, three-byte RGBfrom word triples, 2x2 box filter in 16-bit lanes)
threshold smoother along each block row
>>> 0coercions anywhere)Decoder, finder:
resident across runs, walk vertical runs down the column without
bit()cross()as soon as a run cannot passratio(); test ratios oninteger bounds (proved equal on 206 million tuples)
Decoder, symbol:
packed column masks and whole-byte stores; read payload fields through
a three-byte window
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:
actually attempted; grow finder records on demand; wipe each luma arena
once in
clean()(the reflective sweep stays)nativeLimit(andnativeEveryinQRCanvas): skip thefull-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 >= 2beforenew VideoFrame(a stream startedbefore 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
mutedthrough the property for mobile autoplay. Checked in Chrome on alive camera: native VideoFrame path from a cold start, 4.7 ms per
1664x1248 frame end to end.
Where this came from
We ported
qr0.7.0 to Rip, a language weare 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
readyState: 2(matching the othermocks 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.