Skip to content

Reduce the CPU usage of the Android encoder - #201

Merged
ruccho merged 3 commits into
mainfrom
feature/android-encoder-cpu
Oct 29, 2026
Merged

ruccho merged 3 commits into
mainfrom
feature/android-encoder-cpu

Conversation

@ruccho

@ruccho ruccho commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

On a Galaxy S22 with the default options (blit path, in-memory buffer, audio enabled), recording added about 307 ms/s of CPU time to the process. The threads of unienc accounted for about 210 ms/s of that. Profiling with simpleperf showed that the main costs were not the encoder itself:

  • Handing every dequeueOutputBuffer and dequeueInputBuffer to the blocking pool, which costs a task, a wake-up and a park per call.
  • Importing each hardware buffer of the ImageWriter into Vulkan, and destroying it, for every frame.
  • Looking up the method ID and parsing the signature for every JNI call.

Change

  • Long-running loops on the blocking pool: The output of each of the video and audio encoders is drained, and audio input is fed, by a loop that is handed to the blocking pool once rather than once per dequeue. Each loop waits in MediaCodec itself and exchanges data through a bounded channel. While recording, the loops occupy three threads of the pool, which main also kept busy with its dequeues, and the threads return to the pool when the encoders end. Encoded data is copied directly from the direct buffers of MediaCodec rather than through Java arrays.
  • Cache of imported hardware buffers: The Vulkan image, memory, view and framebuffer imported from each hardware buffer are cached and reused. The ImageWriter cycles through a fixed set of buffers. The cache holds a reference to each buffer, so a cached entry cannot be confused with another buffer at the same address.
  • Cached JNI IDs: The bindings generated by java_api! cache the class and the method and field IDs in a OnceLock and call them with the unchecked JNI functions.
  • When the output is dropped before the input, signaling the end of stream to the audio input gives up after about 2 seconds. Previously it could wait indefinitely in Drop.

The C ABI is unchanged. The prebuilt native binaries are not updated in this PR and are to be rebuilt with build-unienc.yml.

Testing

  • cargo ndk check -p unienc_android_mc passes without warnings. The 13 tests of java_api_macros pass.
  • scripts/android-device-test.sh --release passes on a Galaxy S22 (e2e and e2e-idr-interval).
  • The blit path is not covered by the harness. The CPU usage was measured with simpleperf on a Galaxy S22, using the same APK with only libunienc_c.so replaced, alternating two runs of each version:
CPU time added by recording (all threads) CPU time of the unienc threads
main +317 / +298 ms/s +208 / +210 ms/s
This PR, first revision with dedicated threads +262 / +247 ms/s +139 / +136 ms/s

The loops ran on dedicated threads in the first revision of this PR and were then moved to the blocking pool. The measurement was repeated with the three versions, alternating two runs of each. The CPU time of all threads varied from +192 to +317 ms/s between runs and is not compared here.

CPU time of the unienc threads
main +193 / +197 ms/s
First revision (dedicated threads) +124 / +121 ms/s
This PR (loops on the blocking pool) +124 / +119 ms/s

The process had the same number of threads with both revisions, one more than with main.

Per operation, the following costs were eliminated:

Operation Cost on main
Hardware buffer import, release and framebuffer creation about 40 ms/s
JNI ID lookups about 27 ms/s

These costs were reduced:

Operation Cost on main Cost in this PR
Scheduling of the executor about 84 ms/s about 64 ms/s

These per-operation values come from a callgraph profile, so their scale differs slightly from the table above. The remaining scheduling cost is mainly from the fence wait of the blit path and the video input, which still use the blocking pool.

… while recording

Measured on a Galaxy S22 with the default settings, the CPU time added by recording fell from about 307 ms/s to about 255 ms/s.

- The outputs of the video and audio encoders are drained on a dedicated thread each, and audio input is fed on a dedicated thread, instead of handing every dequeue to the blocking pool of the executor. Encoded data is copied directly from the direct buffers of MediaCodec rather than through Java arrays.
- The Vulkan image, memory, view and framebuffer imported from each hardware buffer of the ImageWriter are cached and reused, instead of being created and destroyed for every frame.
- The bindings generated by java_api! cache the class and the method and field IDs and call them without looking them up again.
Each loop is handed to the blocking pool once, so it keeps waiting in MediaCodec itself without a hand-off per dequeue, and its thread returns to the pool when the encoder ends.
@ruccho
ruccho marked this pull request as ready for review October 28, 2026 09:41
@ruccho
ruccho requested a review from hkmt-mmy October 28, 2026 09:41
@ruccho
ruccho merged commit d345dab into main Oct 29, 2026
13 of 14 checks passed
@ruccho
ruccho deleted the feature/android-encoder-cpu branch October 29, 2026 07:18
@ruccho ruccho mentioned this pull request Oct 29, 2026
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