DEV Community

Yusuf İhsan Görgel
Yusuf İhsan Görgel

Posted on

The 24.8 milliseconds you pay before ffmpeg decodes a single sample

You have an .ogg file and you need the raw samples. In Dart there is no decoder in the standard library, so you do the normal thing: you shell out to ffmpeg, read its stdout, and get your PCM. It works, it is one line, and ffmpeg is better at codecs than anything you are going to write.

I wanted to know what that costs, so I measured it against decoding in the same process. The interesting number turned out not to be the one I went looking for.

Decoding the same Ogg file in process and by spawning ffmpeg, at four clip lengths. Every ffmpeg bar starts with the same 24.8 ms block of process startup, so at one second of audio almost none of the elapsed time is spent decoding

The measurement

Four clips of a 440 Hz sine, 44.1 kHz stereo, Vorbis in Ogg, at 1, 5, 15 and 30 seconds. Two ways to turn each one into interleaved 16-bit PCM:

  • In process. decodeOgg(bytes) from my audio_decode package, which compiles stb_vorbis from source through a Dart build hook and calls it over FFI.
  • Subprocess. ffmpeg -i clip.ogg -f s16le -acodec pcm_s16le -, run with Process.runSync, reading the PCM off stdout. ffmpeg 8.1.1.

Nine runs each, median reported, Apple M-series laptop. The bench checks that both paths produce the same amount of audio before it trusts either timing, which matters more than it sounds like and which I will come back to.

clip in process ffmpeg ratio
1 s 0.594 ms 25.221 ms 42x
5 s 2.108 ms 27.406 ms 13x
15 s 6.294 ms 32.746 ms 5x
30 s 12.923 ms 40.452 ms 3x

Run to run these move by a few percent, and more than that on the sub-millisecond cell: a second run, from a fresh clone of the repo, gave 0.622 ms and 24.727 ms in the first row. Nothing below depends on the third decimal.

A 42x ratio at one second is the sort of number that ends up in a README, and it would be a dishonest one. Look at what the ffmpeg column actually does: thirty times more audio, and the time goes up by 15 ms. The clip length is barely moving that number. Something else is most of it.

The floor

The way to find out is to give ffmpeg nothing to do. I encoded a 0.05-second clip, 4 KB, and decoded it the same way:

Decoding a 0.05 s clip through ffmpeg still takes 24.793 ms, so that
is roughly what the process itself costs, before any audio is decoded.
Enter fullscreen mode Exit fullscreen mode

That is the answer. About 24.8 ms of the 25.2 ms at one second is not decoding. It is fork, exec, the dynamic linker resolving ffmpeg's dependencies, ffmpeg parsing its arguments, probing the input format, setting up filters, and tearing all of that down again.

Subtract it and the two decoders look like what they are, which is two competent implementations of the same codec:

clip in process ffmpeg, decoding only
1 s 0.594 ms 0.428 ms
5 s 2.108 ms 2.613 ms
15 s 6.294 ms 7.953 ms
30 s 12.923 ms 15.659 ms

ffmpeg is faster than me at one second and I am faster at thirty. Nobody should switch decoders over that. The 24.8 ms is the whole story, and it has nothing to do with audio.

What it actually costs you

Because the floor is fixed per invocation, the arithmetic is per file, not per second of audio:

  • One 30-minute podcast: ffmpeg spends ~25 ms starting up and around a second decoding. The startup is 2-3% of the job. Shell out; it is fine.
  • A thousand short voice clips: 1000 × 24.8 ms is about 25 seconds, spent entirely on process startup, before counting a single sample. Batch them into one ffmpeg call and that goes away, but the moment the files arrive one at a time, or need different decode options, you are back to paying it per file. In process, it never exists.
  • A Flutter app decoding a clip in response to a tap: 25 ms is a frame and a half at 60 fps, on top of whatever the decode costs, and you cannot amortise it because there is one clip.

That last one is the case where it stops being an optimisation and becomes a design constraint. It is also the case where shelling out is often not available at all: on iOS you cannot spawn a process, so there the subprocess column is not a slower option, it is not an option.

The honest summary is narrow. If you decode occasionally, or in a batch job where a few seconds of overhead disappears, run ffmpeg and enjoy its enormous format coverage. If you decode many files, or decode on a latency budget, or ship somewhere without a shell, the per-file floor is the reason to pull the decoder in process.

The check that made the numbers mean anything

Before the bench times anything, it decodes the clip both ways and checks that each produced the same amount of audio. This felt like belt-and-braces when I wrote it. It was not.

A first, stricter version of the check compared the raw sample counts and threw:

sample count differs for 1s: 88200 vs 87944
Enter fullscreen mode Exit fullscreen mode

128 frames short, 256 samples across the two stereo channels, on the one-second clip only. Two decoders disagreeing about how much audio is in a file is exactly the kind of thing that quietly makes a benchmark meaningless, so I went and looked instead of loosening the check.

The container settles it. ffprobe reports duration_ts=44100 for that clip, which is the frame count the Ogg stream declares. My decoder returns 44100 frames. ffmpeg's raw PCM pipe delivers 43972, stopping 128 frames early, and only at the shortest clip:

1s:  declared 44100     audio_decode 44100     ffmpeg pipe 43972
5s:  declared 220500    audio_decode 220500    ffmpeg pipe 220500
15s: declared 661500    audio_decode 661500    ffmpeg pipe 661500
30s: declared 1323000   audio_decode 1323000   ffmpeg pipe 1323000
Enter fullscreen mode Exit fullscreen mode

A tail-trimming edge case at very short durations. 128 frames is 2.9 ms of audio, so for most uses it does not matter. But it does mean the check has to be about how much audio each side produced, counted in frames, not about the samples matching byte for byte. The samples do not match byte for byte, and that is the second thing worth knowing: stb_vorbis and ffmpeg's Vorbis decoder are two different decoders, so about half the samples differ by one least-significant bit. Both are correct; floating-point Vorbis synthesis simply does not have a single right answer to the last bit. A check that demanded identical samples would fail on every clip and tell you nothing.

So the check counts frames: it reports the 128-frame gap rather than hiding it, and fails only if the two decoders ever disagree by more than about a thousand frames, a codec block's worth, which would mean one of them is genuinely dropping audio. A benchmark that does not check that both sides did the same work is measuring something, and you do not know what.

Run it yourself

The timings in this post come from a script in the package, not from my notes:

git clone https://github.com/Yusufihsangorgel/audio_decode
cd audio_decode
dart run bench/vs_ffmpeg.dart
Enter fullscreen mode Exit fullscreen mode

It needs ffmpeg on your PATH and a C toolchain for the build hook, generates its own clips, and prints the table above plus the measured floor for your machine. If your floor is very different from 24.8 ms I would genuinely like to know: process startup varies a lot by OS and by how ffmpeg was built, and the whole argument in this post is downstream of that one number.

None of this is specific to audio. Shell out to a tool in a hot path and you pay for the process, not the tool, at the same price whether you asked it to do a little work or a lot.

Top comments (0)