barcodes: 29 performance units from the qr review round, results identical - #327
Merged
Merged
Conversation
…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.
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.
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.ripandpdf417.ripfor 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):rip test/compare.ripnow 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 infindRows.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;barShiftcorrelating four products a step;edgeRunswithout 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.ripper-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,sampleLineover 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),
|0accumulation inbarShift(2-3x slower),parts.push/joinfor SVG (+35%), Float64 gradient buffers and a shift-freesharpRunscompare (noise).