Skip to content

bits: replace per-probe multiplies with one division in lc3_get_symbol - #89

Open
parthvelobyte wants to merge 1 commit into
google:mainfrom
parthvelobyte:get-symbol-udiv
Open

bits: replace per-probe multiplies with one division in lc3_get_symbol#89
parthvelobyte wants to merge 1 commit into
google:mainfrom
parthvelobyte:get-symbol-udiv

Conversation

@parthvelobyte

@parthvelobyte parthvelobyte commented Sep 5, 2026

Copy link
Copy Markdown

While profiling the float decoder on Apple M2 Pro (release flags, -O3 -ffast-math -flto), nearly all decode time attributes to lc3_spec_decode, and within it the arithmetic-decoder symbol reads. Looking at lc3_get_symbol(): the binary search compares ac->low against range * symbols[s].low at every probe, and since each probe's s depends on the previous compare, that is up to five dependent multiplies on the critical path of every symbol.

The multiplies can be replaced by one division computed before the search:

low < range * L   <=>   low / range < L        (unsigned, range >= 1)

Proof: let q = low / range, so range*q <= low < range*(q+1). If q < L then low < range*(q+1) <= range*L. Conversely if low < range*L then range*q <= low < range*L, so q < L. ∎

After the udiv, every probe is a plain table compare with no chained arithmetic, and the four compares are independent of each other except through s. The final ac->low -= range * symbols[s].low / ac->range update is unchanged.

Why the division is always defined and nothing wraps: ac->range is initialized to 0xffffff and every renormalization restores ac->range >= 0x10000, so range = (ac->range >> 10) & 0xffff is in [0x40, 0x3fff]; ac->low is masked to 24 bits everywhere; symbols[s].low < 2^16. All products stay below 2^30. The identity is also machine-checked with z3 over exactly this domain — script below, proves in 0.1 s.

Correctness: encoded and decoded outputs are byte-identical to the unpatched build (SHA-256 on every artifact) across a deterministic 180 s 48 kHz corpus at 32 and 96 kbps, 7.5 ms and 10 ms frames.

Performance: decoder wall time 3–5% faster on Apple M2 Pro (Apple clang 15, release flags), measured with the two libraries strictly alternating in the same process environment, median of 6–10 rounds. lc3_put_symbol and the encoder are untouched.

z3 proof script
# pip install z3-solver
# Characterizes q = low/range by the division axioms rather than UDiv,
# so the check is instant. All C expressions stay below 2^30 on this
# domain, so 32-bit unsigned ops coincide with integer arithmetic.
import z3

low, rng, L, q, rem = z3.Ints("low rng L q rem")

pre = z3.And(
    low >= 0, low < 2**24,
    rng >= 0x40, rng <= 0x3FFF,
    L >= 0, L < 2**16,
    low == q * rng + rem,      # division axioms: q, rem are THE
    rem >= 0, rem < rng,       # quotient and remainder of low / rng
    q >= 0,
)

s = z3.Solver()
s.add(pre, (low < rng * L) != (q < L))
assert s.check() == z3.unsat   # identity holds on the whole domain
reproduce the benchmark

Any fixed 48 kHz mono WAV works as a corpus (I used 180 s of deterministic
seeded noise + tones). With base and patch builds of bin/dlc3 and an
encoded c.lc3:

./elc3_base -b 96000 corpus.wav base.lc3 && ./elc3_patch -b 96000 corpus.wav patch.lc3
cmp base.lc3 patch.lc3                       # encoder: byte-identical
./dlc3_base base.lc3 a.wav && ./dlc3_patch base.lc3 b.wav
cmp a.wav b.wav                              # decoder: byte-identical
# then time dlc3_base / dlc3_patch strictly alternating, median of >=6 rounds

🤖 Generated with Claude Code

@google-cla

google-cla Bot commented Sep 5, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@parthvelobyte

Copy link
Copy Markdown
Author

@googlebot I signed it!

@parthvelobyte
parthvelobyte force-pushed the get-symbol-udiv branch 4 times, most recently from 7bd946f to 303c2f6 Compare September 6, 2026 01:28
The binary search over the ac model compares ac->low against
range * symbols[s].low at every probe, which puts a data-dependent
multiply on the decoder's critical path for every symbol read.

For unsigned integers with range >= 1,

    low < range * L   <=>   low / range < L

(range*q <= low < range*(q+1), so low < range*L iff q < L), so the
quotient computed once turns every probe into a plain table compare.
The final low/range update is unchanged.

Encoded and decoded outputs are byte-identical before and after the
change across a 180 s corpus at 32/96 kbps, 7.5 and 10 ms frames.
Decoder wall time measures 3-5% faster on Apple M2 Pro (clang 15,
-O3 -ffast-math, interleaved A/B, median of 8 rounds).
@zxzxwu

zxzxwu commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Thanks for the analysis and PR!

While replacing the 4 dependent multiplications with 1 runtime integer division (ac->low / range) reduces the critical path latency on out-of-order application processors like Apple M2, I am concerned this will cause a significant performance regression on embedded Bluetooth audio targets where liblc3 is heavily deployed:

  1. MCUs without a hardware divider (e.g., ARM Cortex-M0/M0+, RISC-V with Zmmul only): Since range is a runtime variable, the compiler cannot optimize ac->low / range into a multiplication-by-reciprocal and must emit a software division routine (e.g., __aeabi_uidiv). This takes 20 to 100+ cycles per call inside a LC3_HOT function, compared to just 4 cycles for 4 single-cycle multiplies.
  2. Audio DSPs (e.g., Xtensa HiFi / CEVA): Most audio DSPs are heavily optimized for single-cycle MAC/multiply operations (range * symbols[s].low takes 1 cycle) but lack single-instruction integer dividers, requiring multi-cycle iterative loops for division.
  3. MCUs with hardware division (e.g., ARM Cortex-M4 / Cortex-M33): Hardware UDIV takes 2–12 cycles (typically ~6–10 cycles for 18-bit quotients), which largely offsets or exceeds the cost of 4 single-cycle MUL instructions.

Could we guard this optimization behind an architecture/compiler macro check (e.g., only enabling it on __x86_64__ or __aarch64__ / targets with fast out-of-order hardware division), keeping the multiply-based binary search as the default for embedded/MCU/DSP builds?

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