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

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

lc3_get_symbol() compares ac->low against range * symbols[s].low at every probe of its binary search. Each probe's s depends on the previous compare, so that is up to five dependent multiplies on the critical path of every decoded symbol.

For unsigned values with range >= 1:

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

(let q = low / range, so range*q <= low < range*(q+1); if q < L then low < range*(q+1) <= range*L, and if low < range*L then range*q <= low < range*L, so q < L.)

So one division before the search turns each probe into a plain table compare. ac->range is initialized to 0xffffff and renormalization keeps it >= 0x10000, so range is in [0x40, 0x3fff] and the division is always defined.

Where it is enabled: only on cores with a fast hardware divider, via LC3_FAST_UDIV in common.h (1 on x86-64 / AArch64, 0 otherwise, overridable with -DLC3_FAST_UDIV=0/1). MCUs, 32-bit ARM and audio DSPs compile the original multiply-based search unchanged.

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

Performance: decoder wall time 3–5% faster on Apple M2 Pro (Apple clang 15, release flags), interleaved A/B, median of 6–10 rounds. Encoder and lc3_put_symbol untouched.

@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 5 times, most recently from 303c2f6 to c979716 Compare September 6, 2026 05:00
@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?

@parthvelobyte

parthvelobyte commented Sep 20, 2026 •

Copy link
Copy Markdown
Author

Agreed, thanks.

  • The division is now behind LC3_FAST_UDIV (common.h): 1 on __x86_64__ / __aarch64__, 0 everywhere else, -DLC3_FAST_UDIV=0/1 to override. The #else path is the original multiply-based search, untouched.
  • Macro preprocesses to 0 for armv6m, armv7em, riscv32, armhf, i686 and to 1 for aarch64, x86_64.
  • Encoded and decoded outputs byte-identical to upstream at 32/96 kbps, 7.5/10 ms, with the flag on and off.

The binary search in lc3_get_symbol() chains up to five dependent
multiplies, range * symbols[s].low, on the critical path of every
decoded symbol. For unsigned values with range >= 1,

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

so one division before the search turns each probe into a plain
table compare.

Enabled only on cores with a fast hardware divider (x86-64, AArch64)
via LC3_FAST_UDIV in common.h; MCUs and audio DSPs keep the
multiply-based search unchanged. Override with -DLC3_FAST_UDIV=0/1.

Encoded and decoded outputs are byte-identical to the unpatched
build under both settings. Decoder 3-5% faster on Apple M2 Pro.

This branch has not been deployed

No deployments
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