tl;dr: Opus encoding just got 20% faster on desktop Chromium by turning on AVX2 vectorization in libopus.
Vectorization means one instruction doing the same arithmetic on a whole vector of values at once instead of one at a time. Codecs spend their hot loops running identical math across long buffers of samples, exactly where vectorization (SIMD instruction sets like AVX2 on desktop or NEON on ARM) is highly effective.
In November 2018, the person wiring up Opus’s SSE4.1 kernels in Chromium left a note for the future:
if libopus ever adds AVX support, add an opus_avx block.
This was done in the BUILD.gn file which controls how libopus is built inside Chromium. In 2018 libopus had no AVX functions but it was well understood that when, not if, Opus adds AVX2 this is going to be a win!
Then libopus added said AVX2 support. Years later. The three AVX2 kernels rode into the Chromium source code with the 1.5.2 roll in April 2024 (that release even shipped a fix for an AVX2 alignment crash, so the code was not merely present but actively maintained in libopus), fully wired into Opus’s runtime CPU dispatch, sitting in the tree but not being built. The build still stopped at SSE4.1, behind a stale comment insisting libopus had no AVX functions.

The comment outlived its own premise. Addressing it led to a 23% reduction of Opus encode time on the desktop VoIP path. Now, how much 23% of the audio encode time matters is a different question but it is hard to argue that it is not a win.
Vectorizing codec hot paths is a no-brainer. The interesting questions were and are:
- how much faster did it get
- where does one need to optimize
- how do you measure the results
Those are the questions that historically took a week to answer. Minions to the rescue, Claude Opus 4.8 this time!
The change itself is the boring part
Here is the whole patch:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
--- a/third_party/opus/BUILD.gn +++ b/third_party/opus/BUILD.gn @@ -75,9 +75,12 @@ # Some systems may have SSE4.1+ support. "OPUS_X86_MAY_HAVE_SSE4_1", - # At present libopus has no AVX functions so no sources are add for this, - # if you see linker errors on AVX code the this flag is why. - "OPUS_X86_MAY_HAVE_AVX", + # Some systems may have AVX2 support. libopus provides AVX2 kernels + # (celt_pitch_xcorr, silk_NSQ_del_dec, silk_inner_product_FLP) that are + # selected at runtime via RTCD; the actual sources are built in the + # ":opus_avx2" source_set below. MAY_HAVE (not PRESUME) so non-AVX2 CPUs + # fall back to SSE4.1/C at runtime. + "OPUS_X86_MAY_HAVE_AVX2", ] } @@ -158,7 +161,32 @@ cflags = [ "-msse4.1" ] } } - # TODO(dalecurtis): If libopus ever adds AVX support, add an opus_avx block. + + source_set("opus_avx2") { + sources = [ + "src/celt/x86/pitch_avx.c", + "src/silk/float/x86/inner_product_FLP_avx2.c", + "src/silk/x86/NSQ_del_dec_avx2.c", + ] + + configs -= [ "//build/config/compiler:chromium_code" ] + configs += [ "//build/config/compiler:no_chromium_code" ] + configs += [ + ":opus_private_config", + ":opus_config", + ] + + if (!is_win || is_clang) { + # Matches upstream DEFAULT_X86_AVX2_CFLAGS (configure.ac). + cflags = [ + "-mavx", + "-mfma", + "-mavx2", + ] + } else { + cflags = [ "/arch:AVX2" ] + } + } } @@ -404,7 +432,10 @@ "src/silk/x86/main_sse.h", "src/silk/x86/x86_silk_map.c", ] - deps += [ ":opus_sse41" ] + deps += [ + ":opus_avx2", + ":opus_sse41", + ] } |
It does change the BUILD.gn file to
- replace the now no-op
OPUS_X86_MAY_HAVE_AVXwithOPUS_X86_MAY_HAVE_AVX2 - add an
opus_avx2source_set that compiles the three_avx2.ckernels with-mavx -mfma -mavx2 - pull it into the
opustarget’s deps
No edit to the upstream code, the kernels were already there. MAY_HAVE, not PRESUME, so runtime CPU detection stays in charge and older machines fall back to SSE4.1/C. Thirty-ish lines of build config, and one of them deletes a TODO. I did not spend my morning on those thirty lines, thankfully.
The part I used to not have a week for
I spent it on the part that comes after: proving the change is effective.
The existing benchmark in libWebRTC drives the real WebRtcOpus_Encode wrapper over the in-tree speech and music vectors, looped to 400 seconds per case. Pinned to one core, median of five, on my machine. The same BUILD.gn stash-toggled between runs so opus recompiles with and without AVX2 and everything else is bit-exact. That used to be the finicky, easy-to-botch part I would half-do. Doing it properly is now cheap, so it gets done properly. With fancy graphs instead of an Excel sheet even.
And then the actual analysis, which is where it gets fun (to watch).
| Case (complexity 9) | Baseline (SSE4.1) | AVX2 | Delta encode |
|---|---|---|---|
| mono speech, 64 kbps | 1409 ms | 1163 ms | -17.5% |
| mono speech, 32 kbps | 1733 ms | 1336 ms | -22.9% |
| stereo music, 64 kbps | 1156 ms | 1133 ms | -2.1% |

Speech encode drops 17-23%, stable across complexity 8, 9, and 10. Music drops about 2%. Decode does not move at all (~1.5%, noise), which confirms the measurement: the kernels are encoder-side, so no change is expected.
Why the split? All three kernels live in the encoder, and two of the three live in SILK, which is what speech runs:
silk_inner_product_FLPwas plain scalar C, no SSE variant ever existed, so it makes a scalar to AVX2 jump in one step.silk_NSQ_del_dec, the hottest SILK function at the complexity WebRTC uses by default, goes SSE4.1 to AVX2.celt_pitch_xcorris the lone CELT kernel that improves, so music barely budges.
The WebRTC desktop default is complexity 9 and VoIP is speech-heavy, so the common case gets the big win.
To satisfy my curiosity I also wondered what the hypothetical win over the scalar version is and the relative improvement of AVX2. Quite significant as shown below:

And you can push the question one level deeper, because pushing it one level deeper is now free in terms of my time. “Can you split up the improvements and quantify them?” used to buy you an afternoon of profiler wrangling.
The answer: about 96% of the SILK win comes from silk_NSQ_del_dec alone. That is surprising, because silk_inner_product_FLP has the biggest gain on paper. It is nearly irrelevant to the total, because it is a small slice of encode time. NSQ_del_dec is the hot one, so vectorizing it is where the milliseconds actually live. Guessing from the relative gains, I would have pointed at the wrong kernel. I did not have to guess.
The bar moved… again
In the last post, the bar for making a change dropped. This time, the bar for proving and explaining a change dropped. This particular change did not wait two years because it was hard, it was thirty lines the whole time, and the vectorization kernels had been one roll away since 2024.
While in reality this was probably a simple oversight, for the sake of having a story to tell, let’s pretend the change waited because the week after the change was the expensive part: benchmarking, attribution and a sales-y slide deck. The person who knew it was worth doing did not have the week. That week got cheap(er), so it is not a week anymore and I can fit it in a smaller slot. What is left is the same scarce thing it always was: knowing the change is worth making (as demonstrated by Dale Curtis’s 2018 comment), and being able to tell a real win from a flattering graph.
The trailer of the commit message is quite interesting too:
BUG=none TEST=unittests all pass and bots are happy |
This is a relatively low bar but quite common: nobody has time to do it “properly”, but it needs to be done.
The patch, the plan, the reproduction steps and the full results are on the bug. The change landed in Chromium 152. Thank you Dale for writing such a great TODO in 2018, Jakob for doing the heavy lifting on the Opus 1.5 update, and Felicia for the review of the AVX2 change!
One thing to note: if you encode Opus on a server, the relevant vector kernels are SSE and AVX2 on x86, NEON on ARM. If you run a genai voice stack that synthesizes and encodes audio for a bunch of concurrent sessions, the 20% improvement multiplies across every encode and comes straight off your compute bill – even though it is going to be less than 20%. The vectorization kernels are sitting in libopus already. Check if your build has them enabled! The Chromium ones did not, for at least two years.
p.s.: yes MoQ fanboys, this improves WebCodecs too! You are welcome.
{“author”: “Philipp Hancke“, “copyminion”: “Claude”}





























