Skip to content

Commit c659c76

Browse files
committed
Parallelize only offline rendering, keep playback serial
1 parent 8ae0b39 commit c659c76

9 files changed

Lines changed: 109 additions & 97 deletions

File tree

CHANGELOG

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,14 @@ Bug fixes:
7070

7171
Other:
7272

73-
* Process audio serially on the audio thread by default
74-
- The parallel device worker pool added per-buffer synchronization and cache
75-
overhead that outweighed its benefit for realistic projects and could cause
76-
stutter under load; serial processing is smoother and uses less CPU
77-
- Opt into the pool with NOTEAHEAD_AUDIO_WORKERS ("auto" for a hardware-based
78-
count, or a specific number of worker threads)
79-
- Warn in the log when worker threads are requested but real-time scheduling
80-
is unavailable
73+
* Process real-time playback serially, parallelize only offline rendering
74+
- Splitting per-buffer device processing across threads added synchronization
75+
and cache overhead that caused stutter during playback, so playback now runs
76+
serially on the audio thread (smoother, lower CPU)
77+
- Offline rendering/export still fans out across worker threads (at normal
78+
priority) for faster exports without affecting playback or the UI
79+
- NOTEAHEAD_AUDIO_WORKERS tunes the render worker count ("auto", a number, or
80+
0 for single-threaded rendering)
8181

8282
* Double the maximum number of devices and effects per rack to 16
8383

README.md

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -321,23 +321,20 @@ Transposition (also available via right-clicking on the editor):
321321
##
322322
## Audio performance
323323

324-
The internal devices and effects are rendered on the audio thread. By default all device processing
325-
runs **serially** on that thread, which for realistic projects is both smoother and lighter on the
326-
CPU than splitting the work across threads.
327-
328-
An optional parallel worker pool is available for very large projects that a single core cannot keep
329-
up with. It is opt-in via the `NOTEAHEAD_AUDIO_WORKERS` environment variable:
330-
331-
$ NOTEAHEAD_AUDIO_WORKERS=auto ./noteahead # hardware-based number of worker threads
332-
$ NOTEAHEAD_AUDIO_WORKERS=4 ./noteahead # a specific number of worker threads
333-
$ NOTEAHEAD_AUDIO_WORKERS=0 ./noteahead # serial (the default)
334-
335-
**Note**: The worker threads need real-time scheduling to be useful. Without it (i.e. no `rtprio`
336-
limit for your user, usually granted by being in the `audio` group) they get preempted while the
337-
audio thread waits for them, which causes stutter under load — Noteahead warns about this in the
338-
log. Because of the added per-buffer synchronization and CPU-cache overhead, the pool can be slower
339-
and glitchier than serial processing even when real-time scheduling *is* available, so only enable
340-
it if you measure a real improvement.
324+
**Real-time playback** processes all internal devices and effects **serially on the audio thread**.
325+
For realistic projects this is both smoother and lighter on the CPU than splitting the per-buffer
326+
work across threads, which adds synchronization and CPU-cache overhead that can cause stutter.
327+
328+
**Offline rendering / export**, on the other hand, has no real-time deadline and is parallelized
329+
across worker threads for faster exports. The number of render worker threads can be tuned with the
330+
`NOTEAHEAD_AUDIO_WORKERS` environment variable:
331+
332+
$ NOTEAHEAD_AUDIO_WORKERS=auto ./noteahead # hardware-based number of render threads (the default)
333+
$ NOTEAHEAD_AUDIO_WORKERS=4 ./noteahead # a specific number of render threads
334+
$ NOTEAHEAD_AUDIO_WORKERS=0 ./noteahead # render single-threaded
335+
336+
These worker threads run at normal priority and only during export, so they do not affect playback
337+
smoothness or preempt the UI.
341338

342339
##
343340
## Real-world test cases

src/infra/audio/audio_engine.cpp

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,13 @@ void AudioEngine::process(AudioContext & context)
352352
}
353353
auto effects = m_sendEffectRack->effects();
354354
const size_t sendCount = effects.size();
355-
const size_t laneCount = m_workerPool->laneCount();
355+
356+
// Parallelize across worker threads only for offline rendering/export (exclusive mode). Real-time
357+
// playback runs serially on the audio thread: splitting the per-buffer work adds synchronization
358+
// and cross-core cache overhead that hurts real-time performance, whereas offline rendering has no
359+
// deadline and benefits from the extra throughput.
360+
const bool useWorkers = m_isExclusive.load();
361+
const size_t laneCount = useWorkers ? m_workerPool->laneCount() : 1;
356362

357363
ensureWorkBuffers(laneCount, sendCount, bufferSize);
358364
ensureEffectWetBuffers(sendCount, bufferSize);
@@ -418,7 +424,13 @@ void AudioEngine::process(AudioContext & context)
418424
bufferSize,
419425
context.bpm
420426
};
421-
m_workerPool->run(layer.size(), &deviceContext, processDeviceTask);
427+
if (useWorkers) {
428+
m_workerPool->run(layer.size(), &deviceContext, processDeviceTask);
429+
} else {
430+
for (size_t taskIndex = 0; taskIndex < layer.size(); taskIndex++) {
431+
processDeviceTask(&deviceContext, taskIndex, 0);
432+
}
433+
}
422434
}
423435

424436
// Sum parallel results into the main output and send buses
@@ -446,7 +458,13 @@ void AudioEngine::process(AudioContext & context)
446458
context.sampleRate,
447459
context.bpm
448460
};
449-
m_workerPool->run(sendCount, &effectContext, processEffectTask);
461+
if (useWorkers) {
462+
m_workerPool->run(sendCount, &effectContext, processEffectTask);
463+
} else {
464+
for (size_t taskIndex = 0; taskIndex < sendCount; taskIndex++) {
465+
processEffectTask(&effectContext, taskIndex, 0);
466+
}
467+
}
450468

451469
for (const auto & wetBuffer : m_effectWetBuffers) {
452470
for (uint32_t i = 0; i < bufferSize; i++) {

src/infra/audio/real_time_worker_pool.cpp

Lines changed: 19 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -21,36 +21,13 @@
2121
#include <cstdlib>
2222
#include <pthread.h>
2323
#include <string_view>
24-
#include <sys/resource.h>
2524

2625
namespace noteahead {
2726

28-
namespace {
29-
30-
// Real-time priority the worker threads request. Kept in one place so the availability probe and
31-
// the per-thread setup stay in sync.
32-
constexpr int workerRealtimePriority = 80;
33-
34-
// Whether this process can actually give its worker threads real-time (SCHED_FIFO) priority. Without
35-
// it, non-real-time workers get preempted while the real-time audio thread blocks waiting for them,
36-
// which causes stutter under load — so the pool is worse than serial processing. RLIMIT_RTPRIO is
37-
// the binding constraint for a self-set priority (RTKit, used by e.g. PulseAudio for its own thread,
38-
// is not involved here), so it predicts whether pthread_setschedparam will succeed.
39-
bool canObtainRealtimePriority()
40-
{
41-
struct rlimit rl;
42-
if (getrlimit(RLIMIT_RTPRIO, &rl) != 0) {
43-
return false;
44-
}
45-
return rl.rlim_cur == RLIM_INFINITY || rl.rlim_cur >= static_cast<rlim_t>(workerRealtimePriority);
46-
}
47-
48-
} // namespace
49-
5027
RealTimeWorkerPool::RealTimeWorkerPool(size_t workerCount)
5128
{
52-
juzzlin::L("RealTimeWorkerPool").info() << "Starting with " << workerCount << " worker thread(s)"
53-
<< (workerCount == 0 ? " (serial processing on the audio thread)" : "");
29+
juzzlin::L("RealTimeWorkerPool").info() << "Render worker pool: " << workerCount << " worker thread(s)"
30+
<< (workerCount == 0 ? " (single-threaded rendering)" : "");
5431

5532
m_startSemaphores.reserve(workerCount);
5633
m_workers.reserve(workerCount);
@@ -64,17 +41,12 @@ RealTimeWorkerPool::RealTimeWorkerPool(size_t workerCount)
6441
enableHardwareDenormalProtection();
6542

6643
// Set thread name for easier debugging
67-
const std::string threadName = "AudioWorker-" + std::to_string(i);
44+
const std::string threadName = "RenderWorker-" + std::to_string(i);
6845
pthread_setname_np(pthread_self(), threadName.c_str());
6946

70-
// Set real-time priority
71-
struct sched_param param;
72-
param.sched_priority = workerRealtimePriority; // High priority for audio
73-
if (pthread_setschedparam(pthread_self(), SCHED_FIFO, &param) != 0) {
74-
juzzlin::L("RealTimeWorkerPool").warning() << "Failed to set RT priority for " << threadName
75-
<< " (needs rtprio limits, e.g. the 'audio' group); "
76-
<< "audio may stutter under load. Set NOTEAHEAD_AUDIO_WORKERS=0 to disable the pool.";
77-
}
47+
// These workers only run during offline rendering/export, which has no real-time deadline,
48+
// so they run at normal priority. Elevating them would risk preempting the UI thread while
49+
// an export is in progress.
7850

7951
workerLoop(i);
8052
});
@@ -159,39 +131,28 @@ size_t RealTimeWorkerPool::hardwareBasedWorkerCount()
159131

160132
size_t RealTimeWorkerPool::defaultWorkerCount()
161133
{
162-
// Serial processing on the audio thread is the default. Splitting the per-buffer device
163-
// processing across worker threads adds fixed synchronization cost and bounces device/effect
164-
// state between CPU caches every callback; for realistic projects (which fit comfortably in one
165-
// core) that overhead outweighs any benefit and can cause stutter, even when the workers do get
166-
// real-time priority. Opt in via NOTEAHEAD_AUDIO_WORKERS: "auto" for a hardware-based count, or a
167-
// specific number of worker threads.
134+
// The pool parallelizes offline rendering/export only (real-time playback runs serially on the
135+
// audio thread), so a hardware-based worker count is a sensible default. Override with
136+
// NOTEAHEAD_AUDIO_WORKERS: "auto" for the hardware-based count, a specific number of threads, or 0
137+
// to render single-threaded.
168138
const char * env = std::getenv("NOTEAHEAD_AUDIO_WORKERS");
169139
if (!env || !*env) {
170-
return 0;
140+
return hardwareBasedWorkerCount();
171141
}
172142

173-
size_t workers = 0;
174143
if (std::string_view { env } == "auto") {
175-
workers = hardwareBasedWorkerCount();
176-
} else {
177-
char * end = nullptr;
178-
const long requested = std::strtol(env, &end, 10);
179-
if (end == env || requested < 0) {
180-
juzzlin::L("RealTimeWorkerPool").warning() << "Ignoring invalid NOTEAHEAD_AUDIO_WORKERS value: " << env;
181-
return 0;
182-
}
183-
const auto hardwareThreads = std::thread::hardware_concurrency();
184-
workers = static_cast<size_t>(std::min<long>(requested, hardwareThreads > 0 ? hardwareThreads : requested));
144+
return hardwareBasedWorkerCount();
185145
}
186146

187-
if (workers > 0 && !canObtainRealtimePriority()) {
188-
juzzlin::L("RealTimeWorkerPool").warning()
189-
<< "NOTEAHEAD_AUDIO_WORKERS requested worker threads but real-time scheduling is unavailable "
190-
<< "(RLIMIT_RTPRIO too low); expect stutter under load. Grant rtprio, e.g. join the 'audio' group.";
147+
char * end = nullptr;
148+
const long requested = std::strtol(env, &end, 10);
149+
if (end == env || requested < 0) {
150+
juzzlin::L("RealTimeWorkerPool").warning() << "Ignoring invalid NOTEAHEAD_AUDIO_WORKERS value: " << env;
151+
return hardwareBasedWorkerCount();
191152
}
192153

193-
juzzlin::L("RealTimeWorkerPool").info() << "Audio worker threads requested via NOTEAHEAD_AUDIO_WORKERS: " << workers;
194-
return workers;
154+
const auto hardwareThreads = std::thread::hardware_concurrency();
155+
return static_cast<size_t>(std::min<long>(requested, hardwareThreads > 0 ? hardwareThreads : requested));
195156
}
196157

197158
void RealTimeWorkerPool::workerLoop(size_t workerIndex)

src/infra/audio/real_time_worker_pool.hpp

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626

2727
namespace noteahead {
2828

29+
//! Fan-out/fan-in worker pool used to parallelize offline rendering/export (see AudioEngine::process).
30+
//! Real-time playback runs serially on the audio thread and does not use this pool, so the workers run
31+
//! at normal priority. The name is historical.
2932
class RealTimeWorkerPool
3033
{
3134
public:
@@ -42,7 +45,7 @@ class RealTimeWorkerPool
4245

4346
void run(size_t taskCount, void * context, TaskCallback callback);
4447

45-
//! Worker count used by default. Serial (0) unless opted into via NOTEAHEAD_AUDIO_WORKERS.
48+
//! Worker count used by default for rendering. Hardware-based unless NOTEAHEAD_AUDIO_WORKERS overrides.
4649
static size_t defaultWorkerCount();
4750
//! Heuristic worker count based on the number of hardware threads (leaving headroom for UI).
4851
static size_t hardwareBasedWorkerCount();

src/unit_tests/real_time_worker_pool_test/real_time_worker_pool_test.cpp

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,11 @@ void RealTimeWorkerPoolTest::test_singleTaskUsesCallerThread_shouldExecuteOnCurr
8181

8282
void RealTimeWorkerPoolTest::test_defaultWorkerCount_envOverride_shouldBeRespected()
8383
{
84-
// Default is serial (no worker threads) unless explicitly opted in.
84+
// Unset uses the hardware-based render worker count.
8585
qunsetenv("NOTEAHEAD_AUDIO_WORKERS");
86-
QCOMPARE(RealTimeWorkerPool::defaultWorkerCount(), static_cast<size_t>(0));
86+
QCOMPARE(RealTimeWorkerPool::defaultWorkerCount(), RealTimeWorkerPool::hardwareBasedWorkerCount());
8787

88+
// 0 forces single-threaded rendering.
8889
qputenv("NOTEAHEAD_AUDIO_WORKERS", "0");
8990
QCOMPARE(RealTimeWorkerPool::defaultWorkerCount(), static_cast<size_t>(0));
9091

@@ -95,9 +96,9 @@ void RealTimeWorkerPoolTest::test_defaultWorkerCount_envOverride_shouldBeRespect
9596
qputenv("NOTEAHEAD_AUDIO_WORKERS", "auto");
9697
QCOMPARE(RealTimeWorkerPool::defaultWorkerCount(), RealTimeWorkerPool::hardwareBasedWorkerCount());
9798

98-
// Invalid values fall back to serial.
99+
// Invalid values fall back to the hardware-based default.
99100
qputenv("NOTEAHEAD_AUDIO_WORKERS", "not-a-number");
100-
QCOMPARE(RealTimeWorkerPool::defaultWorkerCount(), static_cast<size_t>(0));
101+
QCOMPARE(RealTimeWorkerPool::defaultWorkerCount(), RealTimeWorkerPool::hardwareBasedWorkerCount());
101102

102103
qunsetenv("NOTEAHEAD_AUDIO_WORKERS");
103104
}

src/unit_tests/side_chain_audio_test/side_chain_audio_test.cpp

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,37 @@ void SideChainAudioTest::test_audioEngine_process_runtimeSidechainChange_shouldR
190190
QCOMPARE(compressor->sidechainSourceDeviceIndex().value_or(999), 2u);
191191
}
192192

193+
void SideChainAudioTest::test_audioEngine_serialAndExclusive_shouldProduceIdenticalOutput()
194+
{
195+
// Real-time playback processes serially; offline render (exclusive mode) may fan out to worker
196+
// threads. The two paths must produce identical output. Several independent devices in one layer
197+
// exercise the parallel fan-out and the per-lane summing.
198+
const auto render = [](bool exclusive, std::vector<double> & out) {
199+
AudioEngine engine;
200+
engine.setIsExclusive(exclusive);
201+
for (int i = 0; i < 5; i++) {
202+
const auto device = std::make_shared<MockDevice>("Device " + std::to_string(i));
203+
device->setGenerateSignal(true);
204+
engine.setDevice(static_cast<size_t>(i), device);
205+
}
206+
out.assign(128, 0.0);
207+
AudioContext context { std::span(out.data(), 128), 64, 44100 };
208+
engine.process(context);
209+
};
210+
211+
std::vector<double> serialOut;
212+
std::vector<double> exclusiveOut;
213+
render(false, serialOut);
214+
render(true, exclusiveOut);
215+
216+
QCOMPARE(serialOut.size(), exclusiveOut.size());
217+
for (size_t i = 0; i < serialOut.size(); i++) {
218+
QVERIFY(std::abs(serialOut[i] - exclusiveOut[i]) < 1e-9);
219+
}
220+
// Five devices each emitting 1.0 sum to 5.0 per sample.
221+
QVERIFY(std::abs(serialOut[0] - 5.0) < 1e-9);
222+
}
223+
193224
void SideChainAudioTest::test_audioEngine_rebuildProcessingGraph_shouldHandleCircularDependencyGracefully()
194225
{
195226
AudioEngine engine;

src/unit_tests/side_chain_audio_test/side_chain_audio_test.hpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ private slots:
2828
void test_audioEngine_rebuildProcessingGraph_shouldCorrectlySortIndependentDevices();
2929
void test_audioEngine_rebuildProcessingGraph_shouldCorrectlySortDependentDevices();
3030
void test_audioEngine_process_runtimeSidechainChange_shouldRebuildGraph();
31+
void test_audioEngine_serialAndExclusive_shouldProduceIdenticalOutput();
3132
void test_audioEngine_rebuildProcessingGraph_shouldHandleCircularDependencyGracefully();
3233
void test_compressorEffect_process_shouldApplySidechainGainReduction();
3334
void test_compressorEffect_sideChainLpf_bypass_shouldPreserveGainReduction();

src/view/qml/Manual.html

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -388,13 +388,13 @@ <h2 style="color: orange; border-bottom: 1px solid #444; margin-top: 20px;">Tool
388388
</ul>
389389

390390
<h3 style="color: orange;">Audio Engine Performance</h3>
391-
<p>The internal devices and effects are rendered on the audio thread. By default all device processing runs <strong>serially</strong> on that thread, which for realistic projects is both smoother and lighter on the CPU than splitting the work across threads.</p>
392-
<p>An optional parallel worker pool is available for very large projects that a single core cannot keep up with. It is opt-in via the <code>NOTEAHEAD_AUDIO_WORKERS</code> environment variable, read at startup:</p>
391+
<p><strong>Real-time playback</strong> processes all internal devices and effects <strong>serially on the audio thread</strong>. For realistic projects this is both smoother and lighter on the CPU than splitting the per-buffer work across threads, which adds synchronization and CPU-cache overhead that can cause stutter.</p>
392+
<p><strong>Offline rendering / export</strong> has no real-time deadline and is parallelized across worker threads for faster exports. The number of render worker threads is read at startup from the <code>NOTEAHEAD_AUDIO_WORKERS</code> environment variable:</p>
393393
<ul>
394-
<li><code>NOTEAHEAD_AUDIO_WORKERS=auto</code>: use a worker count based on the number of hardware threads.</li>
395-
<li><code>NOTEAHEAD_AUDIO_WORKERS=4</code>: use a specific number of worker threads.</li>
396-
<li><code>NOTEAHEAD_AUDIO_WORKERS=0</code> (or unset): serial processing on the audio thread (the default).</li>
394+
<li><code>NOTEAHEAD_AUDIO_WORKERS=auto</code> (or unset): a render thread count based on the number of hardware threads (the default).</li>
395+
<li><code>NOTEAHEAD_AUDIO_WORKERS=4</code>: a specific number of render threads.</li>
396+
<li><code>NOTEAHEAD_AUDIO_WORKERS=0</code>: render single-threaded.</li>
397397
</ul>
398-
<p>The worker threads need real-time scheduling to be useful. Without it (no <code>rtprio</code> limit for your user, usually granted by membership in the <code>audio</code> group) they get preempted while the audio thread waits for them, which causes stutter under load &mdash; Noteahead warns about this in the log. Because of the added per-buffer synchronization and CPU-cache overhead, the pool can be slower and glitchier than serial processing even when real-time scheduling <em>is</em> available, so only enable it if you measure a real improvement.</p>
398+
<p>These worker threads run at normal priority and only during export, so they do not affect playback smoothness or preempt the UI.</p>
399399

400400
<p style="margin-top: 30px; font-size: 0.8em; color: #888;">Noteahead is licensed under the GNU GPLv3.</p>

0 commit comments

Comments
 (0)