Skip to content

Server: per-frame deep copy of all client audio buffers on the realtime audio thread #3804

Description

@mcfnord

Summary

CServer::CreateLevelsForAllConChannels() takes its vecvecsData argument by value:

jamulus/src/server.h

Lines 243 to 246 in 6b7a0ad

bool CreateLevelsForAllConChannels ( const int iNumClients,
const CVector<int>& vecNumAudioChannels,
const CVector<CVector<int16_t>> vecvecsData,
CVector<uint16_t>& vecLevelsOut );

bool CreateLevelsForAllConChannels ( const int                       iNumClients,
                                     const CVector<int>&             vecNumAudioChannels,
                                     const CVector<CVector<int16_t>> vecvecsData,   // <-- by value
                                     CVector<uint16_t>&              vecLevelsOut );

It is called from CServer::OnTimer() — the per-frame realtime audio processing routine — on every frame that has connected clients:

const bool bSendChannelLevels = CreateLevelsForAllConChannels ( iNumClients, vecNumAudioChannels, vecvecsData, vecChannelLevels );

Since CVector<T> derives from std::vector<T>, the by-value parameter deep-copies the entire pre-allocated member vector, every audio frame. Note that vecvecsData is initialized in the CServer constructor to iMaxNumChannels (the -u setting) inner buffers of worst-case stereo size and is never shrunk — so the copy always covers the full configured channel limit, regardless of how many clients are actually connected. A server run with a high -u pays the full cost even when nearly empty.

This allocates in the realtime audio processing routine — which the server otherwise deliberately avoids (see the "no memory must be allocated" worst-case pre-allocation in the CServer constructor, directly above the vecvecsData.Init call).

Data flow and threading

(Context requested in the comments: where the source vector is populated, where the data is processed, and the subsequent flow.)

1. Where vecvecsData is populated — thread and trigger

vecvecsData is a pre-allocated CServer member. The constructor sizes it for the worst case (iMaxNumChannels inner buffers of 2 * DOUBLE_SYSTEM_FRAME_SIZE_SAMPLES samples each), under the explicit invariant that the realtime routine must not allocate:

jamulus/src/server.cpp

Lines 168 to 195 in 6b7a0ad

// To avoid audio clitches, in the entire realtime timer audio processing
// routine including the ProcessData no memory must be allocated. Since we
// do not know the required sizes for the vectors, we allocate memory for
// the worst case here:
// allocate worst case memory for the temporary vectors
vecChanIDsCurConChan.Init ( iMaxNumChannels );
vecvecfGains.Init ( iMaxNumChannels );
vecvecfPannings.Init ( iMaxNumChannels );
vecvecsData.Init ( iMaxNumChannels );
vecvecsData2.Init ( iMaxNumChannels );
vecvecsSendData.Init ( iMaxNumChannels );
vecvecfIntermediateProcBuf.Init ( iMaxNumChannels );
vecvecbyCodedData.Init ( iMaxNumChannels );
vecNumAudioChannels.Init ( iMaxNumChannels );
vecNumFrameSizeConvBlocks.Init ( iMaxNumChannels );
vecUseDoubleSysFraSizeConvBuf.Init ( iMaxNumChannels );
vecAudioComprType.Init ( iMaxNumChannels );
for ( i = 0; i < iMaxNumChannels; i++ )
{
// init vectors storing information of all channels
vecvecfGains[i].Init ( iMaxNumChannels );
vecvecfPannings[i].Init ( iMaxNumChannels );
// we always use stereo audio buffers (which is the worst case)
vecvecsData[i].Init ( 2 /* stereo */ * DOUBLE_SYSTEM_FRAME_SIZE_SAMPLES /* worst case buffer size */ );
vecvecsData2[i].Init ( 2 /* stereo */ * DOUBLE_SYSTEM_FRAME_SIZE_SAMPLES /* worst case buffer size */ );

Incoming network audio does not touch it directly: packets arrive on the socket thread and are placed into each channel's jitter buffer (CChannel). The socket thread never writes vecvecsData.

The trigger that populates it is the periodic frame tick. CHighPrecisionTimer (non-Windows: a dedicated QThread started at TimeCriticalPriority, clock_nanosleep absolute-deadline loop) emits timeout once per server frame — 64 or 128 samples at 48 kHz, i.e. every ~1.33 ms / ~2.67 ms:

jamulus/src/util.cpp

Lines 345 to 377 in 6b7a0ad

void CHighPrecisionTimer::run()
{
// loop until the thread shall be terminated
while ( bRun )
{
// call processing routine by fireing signal
//### TODO: BEGIN ###//
// by emit a signal we leave the high priority thread -> maybe use some
// other connection type to have something like a true callback, e.g.
// "Qt::DirectConnection" -> Can this work?
emit timeout();
//### TODO: END ###//
// now wait until the next buffer shall be processed (we
// use the "increment method" to make sure we do not introduce
// a timing drift)
# if defined( __APPLE__ ) || defined( __MACOSX )
mach_wait_until ( NextEnd );
NextEnd += Delay;
# else
clock_nanosleep ( CLOCK_MONOTONIC, TIMER_ABSTIME, &NextEnd, NULL );
NextEnd.tv_nsec += Delay;
if ( NextEnd.tv_nsec >= 1000000000L )
{
NextEnd.tv_sec++;
NextEnd.tv_nsec -= 1000000000L;
}
# endif
}
}

Because the signal crosses threads, the default (queued) connection at server.cpp:268 delivers CServer::OnTimer() on the thread CServer lives in (the Qt main/event thread of the server process). OnTimer() is the entire per-frame pipeline and must complete within one frame period.

Inside OnTimer(), under QMutexLocker ( &Mutex ), DecodeReceiveData() pulls the coded frame for each connected channel from its jitter buffer (vecChannels[id].GetData(...)) and writes decoded PCM into vecvecsData[iChanCnt]opus_custom_decode for OPUS, memcpy for raw audio, memset silence for lost packets:

jamulus/src/server.cpp

Lines 981 to 1002 in 6b7a0ad

if ( !bIsRawAudio )
{
// OPUS decode received data stream
if ( CurOpusDecoder != nullptr )
{
iUnused = opus_custom_decode ( CurOpusDecoder,
pCurCodedData,
iCeltNumCodedBytes,
&vecvecsData[iChanCnt][iOffset],
iClientFrameSizeSamples );
}
}
else if ( pCurCodedData != nullptr )
{
// copy received raw data stream
memcpy ( &vecvecsData[iChanCnt][iOffset], pCurCodedData, iCeltNumCodedBytes );
}
else
{
// lost packet - fill with silence
memset ( &vecvecsData[iChanCnt][iOffset], 0, iCeltNumCodedBytes );
}

With multithreading enabled the decode fans out over the thread pool (DecodeReceiveDataBlocks), but OnTimer() waits on all futures before leaving that block (server.cpp:713-718). So by the time execution reaches the call in question, population is complete and no other thread is writing to vecvecsData.

2. Where the data is processed — thread

The call at issue happens in the same OnTimer() invocation, on the same thread, immediately after the decode phase:

jamulus/src/server.cpp

Lines 732 to 735 in 6b7a0ad

if ( iNumClients > 0 )
{
// calculate levels for all connected clients
const bool bSendChannelLevels = CreateLevelsForAllConChannels ( iNumClients, vecNumAudioChannels, vecvecsData, vecChannelLevels );

CreateLevelsForAllConChannels() runs inline (never on the pool) and only reads the data: once every CHANNEL_LEVEL_UPDATE_INTERVAL (200) frames it passes vecvecsData[j] — as const CVector<short>& — to CChannel::UpdateAndGetLevelForMeterdB() to compute each client's meter level; on the other 199 frames the body does nothing but iFrameCount++:

jamulus/src/server.cpp

Lines 1676 to 1711 in 6b7a0ad

bool CServer::CreateLevelsForAllConChannels ( const int iNumClients,
const CVector<int>& vecNumAudioChannels,
const CVector<CVector<int16_t>> vecvecsData,
CVector<uint16_t>& vecLevelsOut )
{
bool bLevelsWereUpdated = false;
// low frequency updates
if ( iFrameCount > CHANNEL_LEVEL_UPDATE_INTERVAL )
{
iFrameCount = 0;
bLevelsWereUpdated = true;
for ( int j = 0; j < iNumClients; j++ )
{
// update and get signal level for meter in dB for each channel
const double dCurSigLevelForMeterdB = vecChannels[vecChanIDsCurConChan[j]].UpdateAndGetLevelForMeterdB ( vecvecsData[j],
iServerFrameSizeSamples,
vecNumAudioChannels[j] > 1 );
// map value to integer for transmission via the protocol (4 bit available)
vecLevelsOut[j] = static_cast<uint16_t> ( std::ceil ( dCurSigLevelForMeterdB ) );
}
}
// increment the frame counter needed for low frequency update trigger
iFrameCount++;
if ( bUseDoubleSystemFrameSize )
{
// additional increment needed for double frame size to get to the same time interval
iFrameCount++;
}
return bLevelsWereUpdated;
}

The by-value parameter, however, deep-copies all iMaxNumChannels inner buffers on every call — including the 199-in-200 frames where the copy is never even read.

Since the decode futures have already been joined and the socket thread only writes to the jitter buffers, there is no concurrent writer at the call site — the copy cannot be serving as a thread-safety snapshot. The existing implementation gains nothing from it.

3. Subsequent flow

  • Levels out: when the interval fires, vecLevelsOut (= vecChannelLevels) is sent to every connected client via ConnLessProtocol.CreateCLChannelLevelListMes() (server.cpp:746-749) — the channel-level list protocol message that drives the client-side input meters.
  • vecvecsData afterwards, same tick: the very same member vector is then consumed by the rest of the frame pipeline:
    • recording, if enabled: emit AudioFrame ( ..., vecvecsData[iChanCnt] ) (server.cpp:754-758) — here the queued signal makes its own copy, which is exactly the one place a cross-thread copy is genuinely needed;
    • mixing/encode/transmit: MixEncodeTransmitData() reads it via const CVector<int16_t>& vecsData = vecvecsData[j]; (server.cpp:1037, 1099-1100) — i.e. the mixer accesses the identical data in the identical tick by const reference, which is the established access pattern for this vector;
    • with --delaypan, the frame is copied element-wise into vecvecsData2 for the next frame (server.cpp:793-802).
  • Next tick overwrites the buffers in place; nothing outside OnTimer()'s frame pipeline ever reads vecvecsData.

Impact / measurement

A standalone allocation-counting reproduction of the exact copy (150 channels, i.e. -u 150 = MAX_NUM_CHANNELS; 2×128 int16_t per buffer; ~375 frames/s at 128-sample double frame size) measured:

  • ~56,600 heap allocations per second
  • ~30 MB per second copied

purely from the by-value parameter. The copy is not elided at -O2. At the default -u 10 the same mechanism still costs ~4,100 allocations/s and ~2 MB/s. On any server with at least one client connected this is continuous, avoidable allocation and memory-bandwidth pressure on the most timing-critical code path in the server.

Fix

Pass vecvecsData by const reference (the function only reads it), matching the adjacent vecNumAudioChannels parameter and the const-reference access the mixer already uses on the same data in the same frame. Proposed in #3803.

The neighbouring vecNumAudioChannels is already const&, so this is just an oversight on the one parameter.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    Done

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions