#32: POOL the OpenAL sources instead of creating and destroying one per sound
Every 4.11.674 player log is saturated with acquisition failures -- 3031, 4657, 5245, 6275, 6571 across five machines. In Lynx's largest session the first failure lands 9.3% in and they continue to 96.8%: once it starts it never recovers for the rest of the match. CAUSE: RequestAudioChannels called alGenSources() per sound event and ReleaseSourceSet called alDeleteSources() on release -- create and destroy per sound. OpenAL sources are a scarce driver resource (OpenAL Soft caps a context at 256) and a combat burst churned straight through the ceiling. The two counters looked contradictory and were the tell: ACQUIRE FAILED always printed live=256 while the 30s census printed live=6. Same global, sampled at different moments -- sources spike to the cap during a burst and drain back between them. Churn, not a steady leak. FIX: generate sources once, up to a cap, and recycle them through a free list. Release scrubs and parks instead of deleting. Steady-state play performs no AL allocation at all. The scrub is load-bearing, not hygiene: a recycled source carries whatever the previous owner set, and the engine sets AL_LOOPING per sound (L4AUDLVL.cpp:327). Hand a looping source to a one-shot and it plays forever -- which is the "sound stuck looping" family (#51, #5). Every reusable property is reset at the single point where a source changes owner. VERIFIED (scratchpad/night8/audiopool.sh + the two-node mp_burst.sh): solo, sustained fire : 0 failures, pooled 152, reuses 21998 two nodes, 4 min : 0 failures on both, pooled 148/149, reuses ~7000 each ⚠ HONEST LIMIT: the bench does NOT reproduce the field failure -- the PRE-fix binary also scores 0 on it (peak 49 live), because solo/2-node combat is not dense enough to reach 256. So this verifies the pool works and allocates nothing in steady state; it does NOT by itself prove the field failures are gone. The five field logs remain the "before". Peak demand is set by how many audio COMPONENTS are alive (each reserves a SourceSet of up to 25 voices and holds them), not by audible sounds: measured high-water 138 solo, 149 two-node. That scales with player count, so the cap is set near the driver ceiling (240) and the pool now logs its high-water mark once per 25-source band -- so the next playtest sizes this from field data instead of a guess. Growth also self-limits: if a driver offers fewer sources than the cap, alGenSources simply fails, growth stops, and the pool recycles what it has. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3a22c334ac
commit
ad9dfade88
+178
-34
@@ -1371,6 +1371,15 @@ Logical
|
||||
return resources_available;
|
||||
}
|
||||
|
||||
//
|
||||
// OpenAL source pool (gitea #32) -- defined below, used here.
|
||||
//
|
||||
extern Logical BTAudioPoolAcquire(ALuint *out);
|
||||
extern void BTAudioPoolRelease(ALuint src);
|
||||
extern int BTAudioPoolSize();
|
||||
extern int BTAudioPoolFree();
|
||||
extern long BTAudioPoolReuses();
|
||||
|
||||
//
|
||||
//#############################################################################
|
||||
// RequestAudioChannels
|
||||
@@ -1402,6 +1411,9 @@ Logical
|
||||
{
|
||||
s_censusAt = now_ms;
|
||||
DEBUG_STREAM << "[audio] source census: live=" << gBTAudioSourcesLive
|
||||
<< " pooled=" << BTAudioPoolSize()
|
||||
<< " free=" << BTAudioPoolFree()
|
||||
<< " reuses=" << BTAudioPoolReuses()
|
||||
<< " acquireFails=" << gBTAudioAcquireFails
|
||||
<< std::endl << std::flush;
|
||||
}
|
||||
@@ -1414,35 +1426,41 @@ Logical
|
||||
if (requested > AUDIO_SOURCESET_CAPACITY)
|
||||
requested = AUDIO_SOURCESET_CAPACITY;
|
||||
|
||||
bool failed = true;
|
||||
|
||||
alGetError();
|
||||
|
||||
//
|
||||
// SOURCE POOLING (gitea #32). This used to alGenSources() here and
|
||||
// alDeleteSources() in ReleaseSourceSet -- i.e. CREATE and DESTROY OpenAL
|
||||
// sources per sound event. Every 4.11.674 player log shows the result:
|
||||
// thousands of acquisition failures each (3031-6571 across five machines),
|
||||
// beginning ~10% into a match and never recovering, because a combat burst
|
||||
// churns straight through OpenAL's hard per-context source limit (256).
|
||||
// The 30s census reading `live=6` while the failure line read `live=256`
|
||||
// was the tell: the same counter, sampled between bursts -- churn, not a
|
||||
// steady leak.
|
||||
//
|
||||
// Sources are now generated ONCE and recycled through a free list, so
|
||||
// steady-state demand costs no allocation at all and the driver cap is
|
||||
// never approached.
|
||||
//
|
||||
for (int i = 0; i < requested; i++)
|
||||
{
|
||||
if (!alIsSource(source_request->sources[i]))
|
||||
if (alIsSource(source_request->sources[i]))
|
||||
continue; // slot already holds one
|
||||
ALuint src = 0;
|
||||
if (!BTAudioPoolAcquire(&src))
|
||||
{
|
||||
alGenSources(1, source_request->sources + i);
|
||||
if (alIsSource(source_request->sources[i]))
|
||||
++gBTAudioSourcesLive;
|
||||
++gBTAudioAcquireFails;
|
||||
DEBUG_STREAM << "[audio] ACQUIRE FAILED (requested=" << requested
|
||||
<< " live=" << gBTAudioSourcesLive
|
||||
<< " pooled=" << BTAudioPoolSize()
|
||||
<< " free=" << BTAudioPoolFree()
|
||||
<< " fails=" << gBTAudioAcquireFails
|
||||
<< ") -- the pool is exhausted; expect dropouts"
|
||||
<< std::endl << std::flush;
|
||||
return False;
|
||||
}
|
||||
}
|
||||
|
||||
ALenum error = alGetError();
|
||||
if (error == AL_NO_ERROR)
|
||||
{
|
||||
failed = false;
|
||||
}
|
||||
|
||||
if (failed)
|
||||
{
|
||||
++gBTAudioAcquireFails;
|
||||
DEBUG_STREAM << "[audio] ACQUIRE FAILED (requested=" << requested
|
||||
<< " live=" << gBTAudioSourcesLive
|
||||
<< " fails=" << gBTAudioAcquireFails
|
||||
<< ") -- the pool is exhausted; expect dropouts"
|
||||
<< std::endl << std::flush;
|
||||
return False;
|
||||
source_request->sources[i] = src;
|
||||
}
|
||||
|
||||
return True;
|
||||
@@ -1524,6 +1542,137 @@ Logical
|
||||
long gBTAudioSourcesLive = 0;
|
||||
long gBTAudioAcquireFails = 0;
|
||||
|
||||
//#############################################################################
|
||||
// OpenAL SOURCE POOL (gitea #32)
|
||||
//#############################################################################
|
||||
//
|
||||
// The renderer used to create an AL source per sound event and destroy it on
|
||||
// release. OpenAL sources are a scarce driver resource -- OpenAL Soft caps a
|
||||
// context at 256 -- and creating them is not cheap, so combat bursts ran the
|
||||
// context dry. Field evidence (4.11.674, five machines): 3031-6571 acquisition
|
||||
// failures per player, first failure ~10% into a match, still failing at 97%.
|
||||
//
|
||||
// Sources are now generated once, on demand, up to kAudioPoolCap and then
|
||||
// RECYCLED: release scrubs the source and returns it to the free list instead
|
||||
// of deleting it. Steady-state play performs no AL allocation at all.
|
||||
//
|
||||
// THE SCRUB IS LOAD-BEARING. A recycled source carries whatever the previous
|
||||
// owner set on it, and the engine sets AL_LOOPING per sound (L4AUDLVL.cpp:327).
|
||||
// Hand a looping source to a one-shot and it plays forever -- exactly the
|
||||
// "sound stuck looping" family (#51, #5). Every reusable property is reset
|
||||
// here, at the single point where sources change owner.
|
||||
//
|
||||
// Not locked: the audio renderer runs on the main thread (only the network RX
|
||||
// socket has its own). If that ever changes, this needs a mutex.
|
||||
//
|
||||
static const int kAudioPoolCap = 240; // just under OpenAL Soft's 256 default
|
||||
//
|
||||
// PEAK DEMAND is set by how many audio COMPONENTS are alive, not by how many
|
||||
// sounds are audible: each component reserves a SourceSet of up to
|
||||
// AUDIO_SOURCESET_CAPACITY (25) voices and holds them until it is released.
|
||||
// Measured high-water: 138 solo vs one enemy, 149 across two nodes. That grows
|
||||
// with player count, so the cap is deliberately near the driver ceiling and the
|
||||
// pool reports its high-water mark once, to size this from FIELD logs rather
|
||||
// than from a guess. Growth also stops on its own if a driver offers fewer
|
||||
// sources than the cap -- alGenSources failing simply ends growth and the pool
|
||||
// recycles what it has.
|
||||
|
||||
static ALuint gAudioPoolFree[kAudioPoolCap];
|
||||
static int gAudioPoolFreeCount = 0; // entries in gAudioPoolFree
|
||||
static int gAudioPoolTotal = 0; // sources ever generated (<= cap)
|
||||
static long gAudioPoolReuses = 0; // diagnostics
|
||||
|
||||
int BTAudioPoolSize() { return gAudioPoolTotal; }
|
||||
int BTAudioPoolFree() { return gAudioPoolFreeCount; }
|
||||
long BTAudioPoolReuses() { return gAudioPoolReuses; }
|
||||
|
||||
//
|
||||
// Reset a source to a known-neutral state so nothing carries across owners.
|
||||
//
|
||||
static void
|
||||
BTAudioScrubSource(ALuint src)
|
||||
{
|
||||
ALint state = AL_STOPPED;
|
||||
alGetSourcei(src, AL_SOURCE_STATE, &state);
|
||||
if (state == AL_PLAYING || state == AL_PAUSED)
|
||||
alSourceStop(src);
|
||||
alSourcei(src, AL_BUFFER, 0); // detach (no queued buffers here)
|
||||
alSourcei(src, AL_LOOPING, AL_FALSE); // <-- the stuck-loop guard
|
||||
alSourcef(src, AL_GAIN, 1.0f);
|
||||
alSourcef(src, AL_PITCH, 1.0f);
|
||||
alSourcei(src, AL_SOURCE_RELATIVE, AL_FALSE);
|
||||
alSource3f(src, AL_POSITION, 0.0f, 0.0f, 0.0f);
|
||||
alSource3f(src, AL_VELOCITY, 0.0f, 0.0f, 0.0f);
|
||||
alSourcef(src, AL_MIN_GAIN, 0.0f);
|
||||
alSourcef(src, AL_MAX_GAIN, 1.0f);
|
||||
alGetError(); // swallow any property complaint
|
||||
}
|
||||
|
||||
//
|
||||
// Hand out a source: recycle first, generate only when the pool has never been
|
||||
// that large. False = genuinely out (peak concurrent demand exceeded the cap).
|
||||
//
|
||||
Logical
|
||||
BTAudioPoolAcquire(ALuint *out)
|
||||
{
|
||||
Check_Pointer(out);
|
||||
while (gAudioPoolFreeCount > 0)
|
||||
{
|
||||
ALuint src = gAudioPoolFree[--gAudioPoolFreeCount];
|
||||
if (alIsSource(src)) // paranoia: a context reset invalidates names
|
||||
{
|
||||
++gAudioPoolReuses;
|
||||
*out = src;
|
||||
return True;
|
||||
}
|
||||
--gAudioPoolTotal; // stale name: forget it
|
||||
}
|
||||
|
||||
if (gAudioPoolTotal >= kAudioPoolCap)
|
||||
return False;
|
||||
|
||||
ALuint src = 0;
|
||||
alGetError();
|
||||
alGenSources(1, &src);
|
||||
if (alGetError() != AL_NO_ERROR || !alIsSource(src))
|
||||
return False; // driver said no before our cap
|
||||
|
||||
++gAudioPoolTotal;
|
||||
++gBTAudioSourcesLive;
|
||||
{
|
||||
// One line per new high-water band, so a field log shows how close a
|
||||
// real match gets to the ceiling without spamming.
|
||||
static int s_notified = 0;
|
||||
if (gAudioPoolTotal >= s_notified + 25)
|
||||
{
|
||||
s_notified = gAudioPoolTotal;
|
||||
DEBUG_STREAM << "[audio] source pool high-water: " << gAudioPoolTotal
|
||||
<< " of " << kAudioPoolCap << std::endl << std::flush;
|
||||
}
|
||||
}
|
||||
*out = src;
|
||||
return True;
|
||||
}
|
||||
|
||||
//
|
||||
// Take a source back. Scrubbed and parked, NOT deleted.
|
||||
//
|
||||
void
|
||||
BTAudioPoolRelease(ALuint src)
|
||||
{
|
||||
if (!alIsSource(src))
|
||||
return;
|
||||
BTAudioScrubSource(src);
|
||||
if (gAudioPoolFreeCount < kAudioPoolCap)
|
||||
{
|
||||
gAudioPoolFree[gAudioPoolFreeCount++] = src;
|
||||
return;
|
||||
}
|
||||
alDeleteSources(1, &src); // cannot happen (cap == array size)
|
||||
--gAudioPoolTotal;
|
||||
--gBTAudioSourcesLive;
|
||||
}
|
||||
|
||||
void L4AudioRenderer::ReleaseSourceSet(SourceSet &sourceSet)
|
||||
{
|
||||
// Audio-dropout fix: the old bulk alDeleteSources(count, sources) is
|
||||
@@ -1533,19 +1682,14 @@ void L4AudioRenderer::ReleaseSourceSet(SourceSet &sourceSet)
|
||||
// partial release leaked its real sources and the pool never recovered
|
||||
// ("audio cutting in and out toward the end of the match"). Delete each
|
||||
// valid source individually and park the slot at 0 (never a valid name).
|
||||
extern long gBTAudioSourcesLive;
|
||||
// SOURCE POOLING (#32): hand each source back to the free list instead of
|
||||
// destroying it. BTAudioPoolRelease stops it, detaches its buffer and
|
||||
// clears AL_LOOPING before parking it, so the next owner starts clean.
|
||||
for (int i = 0; i < sourceSet.count; i++)
|
||||
{
|
||||
if (alIsSource(sourceSet.sources[i]))
|
||||
{
|
||||
ALint state = AL_STOPPED;
|
||||
alGetSourcei(sourceSet.sources[i], AL_SOURCE_STATE, &state);
|
||||
if (state == AL_PLAYING)
|
||||
alSourceStop(sourceSet.sources[i]);
|
||||
alDeleteSources(1, sourceSet.sources + i);
|
||||
--gBTAudioSourcesLive;
|
||||
}
|
||||
sourceSet.sources[i] = 0;
|
||||
if (sourceSet.sources[i] != 0)
|
||||
BTAudioPoolRelease(sourceSet.sources[i]);
|
||||
sourceSet.sources[i] = 0; // 0 is never a valid AL name
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# #32 AUDIO SOURCE POOL verification.
|
||||
#
|
||||
# BEFORE: sources were alGenSources'd per sound event and alDeleteSources'd on
|
||||
# release. Every 4.11.674 player log shows thousands of ACQUIRE FAILED lines
|
||||
# (3031-6571 across five machines), first failure ~10% into the match, still
|
||||
# failing at 97% -- combat bursts churned through OpenAL's 256-source context
|
||||
# limit.
|
||||
#
|
||||
# AFTER: sources are generated once and recycled. This run reproduces the
|
||||
# failure mode -- sustained combat, all weapon groups firing, missiles, deaths
|
||||
# and explosions -- and reads the census.
|
||||
#
|
||||
# PASS:
|
||||
# * ACQUIRE FAILED lines == 0
|
||||
# * census `pooled` settles well under the 200 cap (steady-state demand)
|
||||
# * census `reuses` climbs into the thousands (recycling is doing the work)
|
||||
set -x
|
||||
. /c/git/bt411/scratchpad/night6/bench_common.sh
|
||||
cd /c/git/bt411/content || exit 1
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 2
|
||||
sed "s/^map=.*/map=grass/; s/^time=.*/time=day/" MP.EGG > AUD.EGG
|
||||
|
||||
LOG=audiopool_${1:-post}.log
|
||||
rm -f "$LOG"
|
||||
|
||||
BT_DMG_LOG=1 \
|
||||
BT_SPAWN_ENEMY=1 BT_GOTO=enemy BT_GOTO_STOP=2 \
|
||||
BT_AUTOFIRE=1 BT_AF_MISSILE=1 BT_AF_PERIOD=1 \
|
||||
bt_launch "$LOG" AUD.EGG 0x03
|
||||
|
||||
sleep 200
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 2
|
||||
|
||||
echo "=== ACQUIRE FAILED (want 0) ==="
|
||||
grep -c "ACQUIRE FAILED" "$LOG"
|
||||
echo "=== census progression ==="
|
||||
grep -oE "\[audio\] source census:.*" "$LOG" | head -3
|
||||
grep -oE "\[audio\] source census:.*" "$LOG" | tail -3
|
||||
Reference in New Issue
Block a user