Skip to content

barcodes: 29 performance units from the qr review round, results identical - #327

Merged
shreeve merged 32 commits into
mainfrom
barcodes-perf2
Sep 18, 2026
Merged

shreeve merged 32 commits into
mainfrom
barcodes-perf2

Conversation

@shreeve

@shreeve shreeve commented Sep 18, 2026 •

Copy link
Copy Markdown
Owner

The review round on the TypeScript fork (paulmillr/qr #39) found wins this package did not have yet, and four review agents then went through qr.rip, image.rip, gif.rip, code128.rip and pdf417.rip for more. This PR brings all of it back: 29 units, one commit each, every commit message carrying its own before and after numbers and the proof it read for. Every decode result, every encoded byte and every corpus count is unchanged.

Numbers

rip test/bench.rip, idle machine, min of three alternating rounds, main vs this branch (Apple M5, Bun 1.4.0):

Encode main branch
QR raw v10 15 µs 13 µs −13%
QR raw v22 47 µs 41 µs −13%
QR svg v10 32 µs 20 µs −38%
QR gif v10 15 µs 13 µs −13%
PDF417 12 µs 11 µs −8%
Read a clean raster main branch
QR v1 132x132 37 µs 26 µs −30%
QR 720p 646 µs 591 µs −9%
QR v10 1080p 1.53 ms 1.41 ms −8%
Code 128 1080p 170 µs 107 µs −37%
PDF417 1080p 148 µs 114 µs −23%
Miss at 1080p (I420) main branch
QR desk 5.05 ms 4.64 ms −8%
QR text 2.82 ms 2.55 ms −10%
QR noise 10.78 ms 8.95 ms −17%
QR weave 3.57 ms 3.13 ms −12%
Code 128 desk 1.02 ms 764 µs −25%
PDF417 desk 285 µs 223 µs −22%
Miss at the phone arena 2160x2592 main branch
QR desk 13.59 ms 12.55 ms −8%
QR text 8.05 ms 7.12 ms −12%
QR noise 27.66 ms 21.94 ms −21%
QR weave 10.25 ms 8.73 ms −15%
QR desk, nativeLimit 1500 4.61 ms 4.05 ms −12%
Code 128 desk 1.39 ms 1.16 ms −17%
Code 128 text 925 µs 742 µs −20%
Code 128 noise 2.05 ms 1.73 ms −16%
PDF417 desk 465 µs 401 µs −14%
PDF417 noise 1.28 ms 785 µs −39%

rip test/compare.rip now races six columns in one process: ZXing as @zxing/library and zxing-wasm, paulmillr/qr 0.7.0 and qr with #39 applied, and this package before and after this branch, each cell with how many times faster the branch is. On a 1080p frame with one QR: ZXing 3.3x, zxing-wasm 3.5x, qr 0.7.0 1.7x, qr + #39 0.9x, prior rip 1.1x. Code 128 at 1080p: ZXing 18.8x, zxing-wasm 15.3x; PDF417: 39.4x and 37.9x. The README carries the full tables.

Units

Decoder, finder: vertical runs down the column without bit(); cross() leaves as soon as a run cannot pass the ratio (loose bound 4·r2/3); the current word stays resident across runs in findRows.

Decoder, symbol: the module grid sampled without per-module calls (bilinear read inlined, 2.4x on the grid walk); codewords walked with packed column masks and whole-byte stores; payload fields read through a three-byte window; Reed-Solomon remainder folded four coefficients a word from a packed table shared with the encoder; a damaged block's syndromes derived from the remainder register (correction −49 to −56%).

Decoder, pixel pipeline and per-call cost: the 5x5 threshold smoother slid along each block row; each luma arena wiped once in clean(); finder records grown on demand; version scratch sized to the symbol actually attempted (Kanji/Hanzi chunk boundary kept exactly); three-byte RGB folded a word at a time (1080p RGB addImage −44%).

Encoder (byte-identical on 12,040 probe outputs and a 4,551-case oracle): SVG path commands cached per width; runs of adjacent dark modules appended as one string; ASCII glyphs four cells at a time; GIF rows copied whole then spread into LZW chunks (Code 128 GIF at scale 3 −74%, since its rows repeat); text validated against the value table; raw rows from the packed words; data bits placed two at a time; numeric groups straight from the code points.

Code 128 and PDF417 readers: line buffers kept across calls; axis scan lines through whole-span copies; PDF417 axis rays through sampleLine; barShift correlating four products a step; edgeRuns without a closure per line; the widest pair of runs tested before a PDF417 anchor match; one anchor sweep per runs array instead of eight.

Probes: three scripts under test/ (probe-stages, probe-pixels, probe-encode) print one hash line per case so two builds can be shown identical beyond the corpus counts; the first commit adds them and every later commit was checked against their main baselines.

Proof

Per commit: 46 tests, test/corpus.rip per-set counts identical (924 of ZXing's 845, 0 false), the three probes identical to main (6,495 + 924 + 12,040 lines). On the final head, each agent's own oracle against main: decoder stage hash over 7,247 symbols (grid, codewords, block bytes, result), finder-stage records over synthetic scenes, decoded text and geometry for 1,076 corpus image × rotation pairs, luma over 336 format × layout cases, 23.8 million random RGB pixels, Kanji/Hanzi payloads (320), reader results over 2,204 photographs and synthetic frames, sampleLine over 876 cases, encoder oracle 4,551. All identical.

Tested and dropped, with numbers in the reports: integer ratio bounds (float wins on JSC, 3.8 vs 6.1 ns), |0 accumulation in barShift (2-3x slower), parts.push/join for SVG (+35%), Float64 gradient buffers and a shift-free sharpRuns compare (noise).

…encoder output

Three scripts under test/ print one hash line per case so two builds can be
shown identical beyond the corpus counts: probe-stages hashes the decoder's
grid, codewords and result for every version, ecc level, mask, text kind,
scale and damage level (6495 symbols); probe-pixels hashes every pyramid
layer's luma, cuts, blocks, packed bitmap and finder count for sizes from
8x8 to 1922x1082 across every input format, padding, offset and the in-place
arena path (924 inputs); probe-encode hashes QR, Code 128 and PDF417 output
in every format, option and alphabet (12040 encodes). Each header carries
its diff recipe.
A vertical run keeps its column's word offset and bit mask and steps the
word position by the row stride, so each row costs one load and one mask
instead of a bit() call that recomputes both. bit() stays for its two
remaining callers.

Alternating A/B, min of 3, bun: 1080p QR noise miss 10.81 → 10.35 ms,
phone noise miss 28.27 → 26.17 ms. Results identical: 46 tests, corpus
per-set counts, finder-stage probe (pattern records, polarity, loose
counts, retry sets over synthetic scenes, rasters and rotations), stage
probes.
The center run is measured first; a center under two bits, or any side run
that reaches the center, fails before the remaining runs are walked. The
loose fit admits sides up to 4/3 of the center (sides under 1.6 modules,
center over 1.2), so the loose bound is 4·r2/3 and the strict one r2.

Alternating A/B, min of 3, bun: 1080p QR noise miss 10.36 → 9.42 ms,
phone noise miss 25.94 → 23.45 ms; other rows within noise. Results
identical: tests, corpus, finder-stage probe, stage probes.
The packed row's word and the shift inside it are carried from one run to
the next instead of being reloaded per run, so a row walk touches each word
once.

Alternating A/B, min of 5, bun: 1080p QR noise miss 9.85 → 9.44 ms, weave
3.68 → 3.47 ms; phone weave 10.35 → 9.89 ms. Results identical: tests,
corpus, finder-stage probe, stage probes.
projectQuad inlines mapPoint and read(): the plane fields and the nine
homography entries are hoisted, and the per-row products are summed in
read()'s order so the bilinear luma and threshold samples land on the same
doubles. mapPoint allocated a point per module and read() was a call per
sample, which is where the grid walk spent its time.

Alternating A/B, min of 5, bun: projectQuad v10 36.1 → 16.9 µs, v18
97.9 → 41.0 µs, v40 418.5 → 174.6 µs; bench QR v1 132x132 38 → 32 µs.
Results identical: tests, corpus, stage probe over 7247 symbols (grid,
codewords, block bytes, result at every version, ecc, mask, text and
damage level), stage probes.
One mask period per column is packed into a word, y mod 12 is tracked
incrementally and each codeword byte is stored whole once its eight bits
are in, so the clearing fill of the codeword arena goes.

Alternating A/B, min of 5, bun: decodeGrid v10 9.1 → 8.5 µs, v18
21.6 → 19.9 µs, v40 95.6 → 80.5 µs. Results identical: tests, corpus,
stage probe over 7247 symbols, stage probes.
The widest field any segment reads is 16 bits (every read call site and the
ECI lengths are 8 or 16), so a three-byte window over the data bytes serves
every read without the per-bit loop.

Alternating A/B, min of 5, bun: decodePayload v10 0.4 → 0.2 µs, v18
1.0 → 0.5 µs, v40 4.9 → 2.4 µs. Results identical: tests, corpus, stage
probe over 7247 symbols, stage probes.
The products table packs four coefficients to an Int32 (coefficient j in
byte j & 3 of word j >> 2) and is shared by the encoder's rsEcc and the
decoder's clean-block check: each data byte shifts the LFSR register down
one byte across the words and XORs in the packed products row of the
feedback byte. The register lives in a module Int32Array for the encoder
and a scanner-owned one for the decoder. The byte products table had no
remaining consumer and is gone.

Alternating A/B, min of 5, bun: decodeGrid clean v10 8.0 → 6.5 µs, v18
18.7 → 14.9 µs, v40 75.5 → 57.0 µs; encodeQR raw v18 36.8 → 34.2 µs;
bench QR raw v10 15 → 14 µs, v22 47 → 44 µs. Results identical: tests,
corpus, stage probe over 7247 symbols, encoder hashes identical on raw,
ascii, term, svg and gif for 40 versions × 4 ecc × 2 masks × 3 texts, stage
probes (12040 encodes).
The remainder register holds x^eccWords · C(x) mod g(x), which agrees with
the block at every generator root up to the factor alpha^(i·eccWords), so a
damaged block's syndromes come from its eccWords register coefficients
instead of a Horner pass over the whole block.

Alternating A/B, min of 5, bun: decodeGrid damaged v10 with 6 flips
16.3 → 9.8 µs, v18/12 39.8 → 24.1 µs, v40/30 147.6 → 88.7 µs, v40/80
234.8 → 120.6 µs; clean blocks unchanged. Results identical: tests, corpus,
stage probe over 7247 symbols (damage levels 1..400 flips), stage probes.
Five column sums per block row are kept and rolled one column at a time
for the interior blocks, so each cut costs two column updates instead of a
25-cell sum. The small-grid branch, which folds the window onto edge
blocks, is kept as it is. The sums are integers, so every cut is
bit-identical.

Paired in-process A/B, best of 3, bun: 1080p QR text miss 3.24 → 2.84 ms,
weave −3%, phone desk and weave −6%, QR v1 132x132 −5%; other rows within
noise. Results identical: tests, corpus, pixel probe (924 inputs: every
layer's luma, cuts, blocks, bitmap and finder count), stage probes.
clean() sweeps every typed-array field, and layer zero's luma is the
scanner's own arena while every layer's lumaWords is a view over the same
bytes, so those were zero-filled two and three times. Views and aliases of
an arena already wiped are skipped by identity; the reflective sweep stays.

Paired in-process A/B, best of 3, bun: clean() at 1080p 93.8 → 46.9 µs.
Results identical: tests (scanner reuse), corpus, stage probes.
patterns and inverted start at 64 records and double inside recordFinder,
the only writer, up to the same one-per-7x7-cell ceiling with the same
"finder storage exhausted" error. No caller holds a pattern alias across a
findRows pass, so a grown array is never read through a stale reference.

Paired in-process A/B, best of 3, bun: constructor at 1080p 88 → 66 µs,
clean() at 1080p 45 → 23 µs; decode rows within noise. Results identical:
tests, corpus, pixel probe (86 frames hashing patterns, inverted and the
count per layer), stage probes.
grid, fun, codewords and blockBytes start empty and reserve(size) grows
them where decodedSize is committed in projectTriple, the one point every
consumer (projectQuad, confirm, decodeFormat, markDecoded) is downstream
of. Payload starts with an empty byte arena and reserves per segment: byte
segments reserve dataLen, which bounds any segment; Kanji and Hanzi reserve
min(2·length, KANJI_CHUNK) where KANJI_CHUNK = MAX_CODEWORDS & ~1 keeps the
chunk boundary exactly where it is, so decoded text is unchanged for any
input. The length guard against the view count goes: length ≤ dataLen ≤
2956 already.

Paired in-process A/B, best of 3, bun: decodeQR v1 58px 22.8 → 18.0 µs,
v1 116px 36.9 → 33.0 µs, QR v1 132x132 −9.5%, constructor at 1080p −8%.
Results identical: tests, corpus, stage probe (7254 lines hashing the grid
at every decodeFormat entry and the corrected data at every decodePayload
entry, up to 24 attempts per case, plus a reused scanner across versions
1, 40, 7, 20, 1, 33, 2), payload probe (320 byte, Kanji, Hanzi and mixed
segment cases, 27 longer than one chunk), stage probes.
Three words carry four RGB pixels, so a tight little-endian RGB frame
whose rows are 4-byte aligned is converted from word triples instead of
the per-channel byte loop; copyTriples sits beside copyWords with the same
gate and a byte-wise tail.

Paired in-process A/B, best of 3, bun: addImage 1080p RGB 1.22 ms →
678 µs, decodeQR 1080p RGB 1.72 → 1.19 ms; everything else within noise.
Results identical: luma probe on 336 cases (8 sizes × 10 formats × 5
layouts, aligned and unaligned views), 23.8 million random RGB pixels at
five alignments and padded strides with 0 mismatches, tests, corpus, stage
probes.
Finished path commands for 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 a dark module costs one
lookup and one concatenation; absolute moves and the H<x> form keep the
inline path, and bits are read from the packed row directly.

Alternating processes, min of 5, bun: svg v8 22.17 → 21.51 µs, v18
70.84 → 65.31 µs; v1 and v3 within noise. Output byte-identical: encoder
oracle (4551 hashes over every version, ecc, mask, output, border and scale,
widths interleaved so the caches refill), encode probe (12040), tests,
corpus.
From x = 10 on, a dark cell immediately after another always wins the
one-step relative move (m1 0h1v1h-1Z is two characters, no longer than the
digits of x and y), so a run of k such cells is appended as one cached
string repeated k times; scale multiplies run lengths. The guard is exact,
so the tie rule never changes a byte.

Alternating processes, min of 5, bun: svg v1 4.53 → 4.18 µs, v3
7.30 → 6.70 µs, v8 21.18 → 17.93 µs, v18 64.32 → 54.82 µs. Output
byte-identical: encoder oracle, encode probe, tests, corpus.
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.

Alternating processes, min of 5, bun: ascii v1 2.73 → 2.56 µs, v3 −6%, v8
12.83 → 12.05 µs, v18 35.1 → 33.8 µs. Output byte-identical: encoder
oracle, encode probe, tests, corpus.
Pixel rows are copied back to back into the unused tail of the output
buffer, then spread forward chunk by chunk behind their two-byte headers
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 goes.
The QR row builder reads bits from the packed words with the row's word
base hoisted. The 1-D encoders return the same row for every y, so the byte
loop was their whole cost.

Alternating processes, min of 5, bun: QR gif v3 4.06 → 3.84 µs, v8
11.71 → 10.87 µs, v18 31.41 → 28.49 µs, data-url v1..v18 −8 to −9%;
Code 128 gif scale 1 6.14 → 3.11 µs, scale 3 105.7 → 27.7 µs, data-url
scale 3 113.6 → 39.0 µs; PDF417 gif scale 1 7.37 → 6.18 µs, scale 3
67.8 → 34.3 µs. Output byte-identical for all three encoders: encoder
oracle, encode probe, tests, corpus.
The alphabet check scanned a 45-character string per input character; the
value table is tested against the mode's alphabet size instead, and the
error message is built only on failure from the code point at the offending
index, the same character the string iterator yielded.

Alternating processes, min of 5, bun: numeric v8 16.91 → 15.26 µs, v18
54.5 → 45.9 µs; alphanumeric v8 −12%, v18 −16%. Output byte-identical,
including all 105 error cases (lone and paired surrogates, astral code
points, chars ≥ 128): encoder oracle, encode probe, tests, corpus.
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.

Alternating processes, min of 5, bun: raw v1 −4%, v3 3.85 → 3.65 µs, v8
−2%, v18 29.74 → 28.27 µs. Output byte-identical: encoder oracle, encode
probe, tests, corpus.
The zigzag fills a two-module column, so consecutive placement positions
mostly sit side by side in one word; a per-pair table (word index, shift
and a pair flag) lets one OR place a 2-bit value, falling back to the
single-bit positions where a pair straddles a word or a function pattern.

Alternating processes, min of 5, bun: raw v1 2.43 → 2.29 µs, v3 −5%, v8
10.70 → 9.96 µs, v18 28.53 → 26.96 µs; alphanumeric v8/v18 −6/−7%. Output
byte-identical: encoder oracle, encode probe, tests, corpus.
Three digits a group from their code points, the tail group carrying one or
two; validation already guarantees digits, so the slice-and-Number per
group goes.

Alternating processes, min of 5, bun: numeric v8 15.01 → 12.99 µs, v18
43.4 → 40.2 µs; alphanumeric untouched. Output byte-identical: encoder
oracle, encode probe, tests, corpus.
luma, runs and rev (three diagonal-length arrays, about 37 KB at 1080p)
live in a module record sized for the longest diagonal seen, as PDF417's
state already does; every consumer takes explicit run counts, so stale
content is never read.

Paired in-process A/B, best of 3, bun: Code 128 1080p read −1.8%, within
noise; a camera loop feeding frames of one size allocates once. Results
identical: reader probe (876 lines: sampleLine over 6 formats × 4 widths ×
4 alignments × 6 start and advance combinations, full readCode128 and
readPDF417 results on 3 texts × scales × 3 formats × 4 rotations × 2
polarities, 6 miss frames), tests, corpus, stage probes.
sampleLine with consecutive pixels copies a planar row with set and folds
word-aligned RGBA and RGB rows with copyWords and copyTriples. sampleAlong
routes the two exact axis directions through sampleLine, which also gives
vertical lines a plain strided loop; the samples are exactly those of one
scan line, since (x + k·sx) >> 16 equals (x >> 16) + k·(sx >> 16) for
whole-pixel steps.

Paired in-process A/B, best of 3, bun: Code 128 1080p read 176 → 141 µs,
PDF417 desk 1080p miss 475 → 353 µs, Code 128 text phone miss −9%. Results
identical: reader probe (876 lines), tests, corpus, stage probes.
sampleRay for the four exact axis directions computes the first inner
sample's index and a fixed advance and hands the luma span to sampleLine,
so horizontal rays take the whole-span copy.

Profile at 1080p, 2000 decodes: sampleRay self time 158 → 11 ms. Paired
in-process A/B, best of 3, bun: PDF417 1080p read 150 → 134 µs; misses
unchanged. Results identical: reader probe (876 lines), tests, corpus,
stage probes.
barShift is 37% of a Code 128 miss (16 calls × 35 shifts × 1024 Int16
products); the inner sum is unrolled four wide, which is exact because the
products are integers.

Paired in-process A/B, best of 3, bun: Code 128 1080p read 135.5 →
114.7 µs, desk 1080p miss 1.00 ms → 844 µs, phone desk/text/noise misses
−11/−17/−6%. Results identical: reader probe (876 lines), tests, corpus,
stage probes.
edgeRuns built an edge closure over eight locals on every scan line and
called it per edge; in noise nearly every sample is an edge. The rising and
falling branches are merged with the direction as a conditional (the
comparisons are unchanged, so every crossing is bit-identical) and the edge
body is written in place, once in the loop and once for the trailing edge.

Alternating runs, min of 5, bun: PDF417 desk miss 0.81x, noise 0.85x, read
0.84x; Code 128 read 0.94x, noise 0.89x. Results identical: reader oracle
(2204 reads: ZXing code128, pdf417 and falsepositives sets at 4 rotations,
synthetic rasters at scales, rotations, inversions, skews, blur and noise in
RGBA and I420, symbol-free frames, hashing corners, angle, codewords and
flags), tests, corpus, stage probes.
Every position that passed the sliding width gate went to matchRuns, which
re-sums the window and only then rejects on its first pair; on textured
frames that is most positions. A pattern's widest pair of runs must span at
least (p_j + p_{j+1} − 0.8) / modules of the window when the pattern
matches, which is the per-pair tolerance matchRuns applies, so checking
that pair against the sliding total with a further 0.01 margin skips only
windows the match would refuse. widest() derives the pair and the fraction
from the pattern, so findRuns stays generic.

Alternating runs, min of 5, bun: PDF417 noise miss 0.66x, desk 0.87x, desk
1080p 0.86x, read 0.98x. Results identical: reader oracle (2204 reads),
tests, corpus, stage probes.
scanLine ran eight findRuns sweeps over each line (two modes × forward and
reversed × two parities). sweep walks each of the two arrays once, sliding
the four windows (start pattern and mirrored stop, both parities) together,
and collects the matching positions per mode and parity; decodes are then
tried in the original order. Each window is summed as its own sweep would
sum it (fresh at its parity's start and after every match, the same
association for the slide), so the width gate reads the same totals and
the candidate sequence is identical.

Alternating runs, min of 5, bun: PDF417 noise miss 0.91x on top of the
widest-pair gate (811 → 736 µs), other rows within ±2%. Results identical:
reader oracle (2204 reads), tests, corpus, stage probes.
Every bench row and both race tables on an idle machine, min of three
alternating rounds against the previous figures; the Test section names
the three stage probes.
…ckage before its review round

test/compare.rip runs six columns in one process: @zxing/library and
zxing-wasm (dev dependencies), paulmillr/qr 0.7.0 as released and with pull
request 39 applied (misc/qr, misc/qr-perf), and this package as it stands
on main (misc/rip-prior) and as the working tree; every cell shows its
slowdown against the working tree, and every reader is checked against the
expected text before it is timed. Code 128 and PDF417 join the race on the
bench's 1080p hit frames. The README carries the tables and the recipe.
@shreeve
shreeve merged commit b0128a3 into main Sep 18, 2026
2 checks passed
@shreeve
shreeve deleted the barcodes-perf2 branch September 18, 2026 08:30
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.

1 participant