Skip to content

Commit 065febf

Browse files
authored
Merge pull request #308 from winnerspiros/copilot/merge-ppy-prioritize-our-fork
perf: second-pass hot-path audit — MathF, DimmablePieces cache, Taiko loop
2 parents eeddea9 + aa829ed commit 065febf

23 files changed

Lines changed: 335 additions & 99 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2+
// See the LICENCE file in the repository root for full licence text.
3+
4+
using osu.Game.Database;
5+
6+
namespace osu.Android
7+
{
8+
/// <summary>
9+
/// Android-specific <see cref="BackgroundDataStoreProcessor"/> that extends the sleep
10+
/// interval during active gameplay from the default 30 s to 2 minutes.
11+
/// </summary>
12+
/// <remarks>
13+
/// On Android the high-performance session (<see cref="Performance.AndroidHighPerformanceSessionManager"/>)
14+
/// flips <c>GCSettings.LatencyMode</c> to <c>SustainedLowLatency</c> for the entire gameplay window,
15+
/// which suppresses Gen-2 (major) GC collections. The background processor's sleep loop also
16+
/// suspends during gameplay, but wakes every <see cref="BackgroundDataStoreProcessor.TimeToSleepDuringGameplay"/>
17+
/// ms to re-check the condition. Each wake-up incurs a managed thread resume + lock acquisition,
18+
/// generating a small burst of GC-visible allocations. At 30 s those spurious wakes happen ~10×
19+
/// per typical 5-minute play session; at 120 s they drop to ~2×, cutting the associated
20+
/// allocation pressure and the risk of a GC stall at the worst possible moment.
21+
/// </remarks>
22+
public partial class AndroidBackgroundDataStoreProcessor : BackgroundDataStoreProcessor
23+
{
24+
// 2-minute polling interval while gameplay is active (vs. the default 30 s).
25+
protected override int TimeToSleepDuringGameplay => 120_000;
26+
}
27+
}

osu.Android/Native/oboe_bridge.cpp

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,28 @@ bool OboeBridge::open(int32_t sampleRate) {
9797
// MMAP provides direct access to audio hardware buffers, shaving ~1-2ms off latency.
9898
oboe::OboeExtensions::setMMapEnabled(true);
9999

100-
// Initialise StabilizedCallback to even out callback execution time.
101-
// shared_ptr is used to satisfy the non-deprecated setDataCallback overload.
102-
stabilizedCallback_ = std::make_shared<oboe::StabilizedCallback>(this);
100+
// Non-owning shared_ptr for error callback — OboeBridge outlives the stream.
101+
auto errorCb = std::shared_ptr<oboe::AudioStreamErrorCallback>(
102+
std::shared_ptr<void>(), static_cast<oboe::AudioStreamErrorCallback*>(this));
103+
104+
// -----------------------------------------------------------------------
105+
// Pass 1: open with a raw 'this' callback (no StabilizedCallback).
106+
//
107+
// On AAudio MMAP paths (Pixel 3+, Snapdragon 8 Gen 1+, most modern
108+
// Android), the kernel delivers audio callbacks with near-perfect timing
109+
// via the hardware FIFO interrupt. StabilizedCallback works by sleeping
110+
// on the callback thread to normalise jitter — on MMAP that sleep is pure
111+
// overhead: it delays the write to the hardware ring buffer, adding latency
112+
// without removing any real jitter.
113+
//
114+
// We probe by opening with the raw callback first. If MMAP is confirmed
115+
// we keep this stream. If not, we close it and reopen with
116+
// StabilizedCallback (see Pass 2 below) to cover the non-MMAP / OpenSL ES
117+
// fallback path where OS scheduler jitter is real.
118+
// -----------------------------------------------------------------------
119+
stabilizedCallback_.reset();
120+
auto rawCb = std::shared_ptr<oboe::AudioStreamDataCallback>(
121+
std::shared_ptr<void>(), static_cast<oboe::AudioStreamDataCallback*>(this));
103122

104123
oboe::AudioStreamBuilder builder;
105124
builder.setDirection(oboe::Direction::Output)
@@ -120,18 +139,50 @@ bool OboeBridge::open(int32_t sampleRate) {
120139
->setIsContentSpatialized(true)
121140
// Prevent other apps from capturing our audio stream (competitive integrity).
122141
->setAllowedCapturePolicy(oboe::AllowedCapturePolicy::None)
123-
// Use shared_ptr overload (non-deprecated) for data callback.
124-
->setDataCallback(stabilizedCallback_)
125-
// Non-owning shared_ptr for error callback — OboeBridge outlives the stream.
126-
->setErrorCallback(std::shared_ptr<oboe::AudioStreamErrorCallback>(
127-
std::shared_ptr<void>(), static_cast<oboe::AudioStreamErrorCallback*>(this)));
142+
->setDataCallback(rawCb)
143+
->setErrorCallback(errorCb);
128144

129145
oboe::Result result = builder.openStream(stream_);
130146

131-
if (result != oboe::Result::OK) {
147+
if (result == oboe::Result::OK) {
148+
bool mmapActive = oboe::OboeExtensions::isMMapUsed(stream_.get());
149+
LOGI("Oboe pass-1 open: MMAP=%s", mmapActive ? "yes" : "no");
150+
151+
if (!mmapActive) {
152+
// ---------------------------------------------------------------
153+
// Pass 2: MMAP unavailable — close and reopen with StabilizedCallback.
154+
// StabilizedCallback adds a compensating sleep to normalise the
155+
// variable latency introduced by the OS scheduler on non-MMAP paths,
156+
// reducing buffer underruns on devices that rely on AAudio binder IPC
157+
// or the OpenSL ES compatibility layer.
158+
// ---------------------------------------------------------------
159+
stream_->close();
160+
stream_.reset();
161+
162+
stabilizedCallback_ = std::make_shared<oboe::StabilizedCallback>(this);
163+
164+
builder.setDataCallback(stabilizedCallback_);
165+
result = builder.openStream(stream_);
166+
167+
if (result != oboe::Result::OK) {
168+
LOGE("AAudio + StabilizedCallback open failed (%s), falling back to unspecified API",
169+
oboe::convertToText(result));
170+
{ std::lock_guard<std::mutex> eLock(errorLock_); lastError_ = std::string("AAudio: ") + oboe::convertToText(result); }
171+
builder.setAudioApi(oboe::AudioApi::Unspecified);
172+
builder.setSharingMode(oboe::SharingMode::Shared);
173+
result = builder.openStream(stream_);
174+
}
175+
}
176+
} else {
177+
// AAudio exclusive failed outright — try unspecified API + shared mode.
178+
// Always wrap with StabilizedCallback on this fallback path since we
179+
// almost certainly won't have MMAP on an OpenSL ES device.
132180
LOGE("AAudio open failed (%s), falling back to unspecified API",
133181
oboe::convertToText(result));
134182
{ std::lock_guard<std::mutex> eLock(errorLock_); lastError_ = std::string("AAudio: ") + oboe::convertToText(result); }
183+
184+
stabilizedCallback_ = std::make_shared<oboe::StabilizedCallback>(this);
185+
builder.setDataCallback(stabilizedCallback_);
135186
builder.setAudioApi(oboe::AudioApi::Unspecified);
136187
builder.setSharingMode(oboe::SharingMode::Shared);
137188
result = builder.openStream(stream_);
@@ -168,14 +219,15 @@ bool OboeBridge::open(int32_t sampleRate) {
168219
tuner_ = std::make_unique<oboe::LatencyTuner>(*stream_);
169220

170221
LOGI("Oboe stream opened: api=%s, sampleRate=%d, framesPerBurst=%d, "
171-
"bufferSize=%d, bufferCapacity=%d, sharingMode=%s, mmap=%s",
222+
"bufferSize=%d, bufferCapacity=%d, sharingMode=%s, mmap=%s, stabilized=%s",
172223
stream_->getAudioApi() == oboe::AudioApi::AAudio ? "AAudio" : "OpenSLES",
173224
stream_->getSampleRate(),
174225
stream_->getFramesPerBurst(),
175226
stream_->getBufferSizeInFrames(),
176227
stream_->getBufferCapacityInFrames(),
177228
stream_->getSharingMode() == oboe::SharingMode::Exclusive ? "Exclusive" : "Shared",
178-
oboe::OboeExtensions::isMMapUsed(stream_.get()) ? "yes" : "no");
229+
oboe::OboeExtensions::isMMapUsed(stream_.get()) ? "yes" : "no",
230+
stabilizedCallback_ ? "yes" : "no");
179231

180232
return true;
181233
}
@@ -302,8 +354,10 @@ oboe::DataCallbackResult OboeBridge::onAudioReady(
302354
framesRead = std::clamp(framesRead, 0, numFrames);
303355

304356
if (framesRead < numFrames) {
305-
size_t bytesDone = static_cast<size_t>(framesRead) * stream->getChannelCount() * sizeof(float);
306-
size_t totalBytes = static_cast<size_t>(numFrames) * stream->getChannelCount() * sizeof(float);
357+
// Cache channel count in a local to avoid two virtual dispatches.
358+
int32_t ch = stream->getChannelCount();
359+
size_t bytesDone = static_cast<size_t>(framesRead) * ch * sizeof(float);
360+
size_t totalBytes = static_cast<size_t>(numFrames) * ch * sizeof(float);
307361
memset(static_cast<char*>(audioData) + bytesDone, 0, totalBytes - bytesDone);
308362
}
309363
} else {

osu.Android/OsuGameAndroid.cs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
using osu.Framework.Platform;
2828
using osu.Game;
2929
using osu.Game.Configuration;
30+
using osu.Game.Database;
3031
using osu.Game.Overlays;
3132
using osu.Game.Overlays.Settings;
3233
using osu.Game.Screens;
@@ -186,6 +187,9 @@ public partial class OsuGameAndroid : OsuGame
186187
private IntPtr adpfInputSession;
187188
private double inputAdpfAccumulatedMs;
188189
private long inputAdpfLastReportMs; // Environment.TickCount64 ms timestamp of last ADPF report
190+
// Cached display-frame period in ms. Recomputed in applyDisplayMode so the hot
191+
// input callback (which can fire at ~100 kHz) reads a plain field, not Math.Round.
192+
private long adpfInputIntervalMs = 8L;
189193

190194
// One-shot System.Threading.Timer that runs a burst of background-thread taming passes
191195
// when the user transitions into active gameplay. Cancelled and replaced on each new
@@ -1488,6 +1492,8 @@ private void applyDisplayMode(global::Android.Views.Display display, global::And
14881492
try
14891493
{
14901494
currentRefreshRate = (int)mode.RefreshRate;
1495+
// Cache the interval so the input hot-path avoids Math.Round on every poll.
1496+
adpfInputIntervalMs = currentRefreshRate > 0 ? (long)Math.Round(1000.0 / currentRefreshRate) : 8L;
14911497

14921498
// Request the refresh rate via Surface.setFrameRate() ONLY.
14931499
//
@@ -1610,13 +1616,11 @@ private void onInputFrameCompleted()
16101616

16111617
inputAdpfAccumulatedMs += elapsedMs;
16121618

1613-
// Report once per display frame period. `currentRefreshRate` is an int written
1614-
// from the UI thread — a torn or stale read is harmless (worst-case we use a
1615-
// slightly wrong interval for one report cycle).
1616-
long intervalMs = currentRefreshRate > 0 ? (long)Math.Round(1000.0 / currentRefreshRate) : 8L;
1619+
// Report once per display frame period. `adpfInputIntervalMs` is pre-computed
1620+
// in applyDisplayMode() so this callback reads a plain field instead of calling Math.Round.
16171621
long nowMs = System.Environment.TickCount64;
16181622

1619-
if (nowMs - inputAdpfLastReportMs >= intervalMs)
1623+
if (nowMs - inputAdpfLastReportMs >= adpfInputIntervalMs)
16201624
{
16211625
OboeAudioBridge.nADPFReportActualDuration(adpfInputSession, (long)(inputAdpfAccumulatedMs * 1_000_000.0));
16221626
inputAdpfAccumulatedMs = 0;
@@ -2725,6 +2729,8 @@ private static bool isFrameworkDuplicateOfAndroidHandler(osu.Framework.Input.Han
27252729

27262730
protected override UpdateManager CreateUpdateManager() => new MobileUpdateNotifier();
27272731

2732+
protected override BackgroundDataStoreProcessor CreateBackgroundDataStoreProcessor() => new AndroidBackgroundDataStoreProcessor();
2733+
27282734
protected override BatteryInfo CreateBatteryInfo() => new AndroidBatteryInfo();
27292735

27302736
protected override void Dispose(bool isDisposing)

osu.Game.Rulesets.Catch/UI/Catcher.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
using System;
55
using System.Buffers;
66
using System.Diagnostics;
7-
using System.Linq;
87
using osu.Framework.Allocation;
98
using osu.Framework.Bindables;
109
using osu.Framework.Graphics;
@@ -390,7 +389,7 @@ private Vector2 computePositionInStack(Vector2 position, float displayRadius)
390389
float adjustedRadius = displayRadius * lenience_adjust;
391390
float checkDistance = MathF.Pow(adjustedRadius, 2);
392391

393-
while (caughtObjectContainer.Any(f => Vector2Extensions.DistanceSquared(f.Position, position) < checkDistance))
392+
while (tooCloseToExistingObject(position, checkDistance))
394393
{
395394
position.X += RNG.NextSingle(-adjustedRadius, adjustedRadius);
396395
position.Y -= RNG.NextSingle(0, 5);
@@ -399,6 +398,17 @@ private Vector2 computePositionInStack(Vector2 position, float displayRadius)
399398
return position;
400399
}
401400

401+
private bool tooCloseToExistingObject(Vector2 position, float checkDistance)
402+
{
403+
foreach (var f in caughtObjectContainer)
404+
{
405+
if (Vector2Extensions.DistanceSquared(f.Position, position) < checkDistance)
406+
return true;
407+
}
408+
409+
return false;
410+
}
411+
402412
private void addLighting(JudgementResult judgementResult, Color4 colour, float x) =>
403413
hitExplosionContainer.Add(new HitExplosionEntry(Time.Current, judgementResult, colour, x));
404414

osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,17 @@ public void ApplyToDrawableHitObject(DrawableHitObject drawableObject)
111111
{
112112
if (drawable.HitObject is SpinnerTick or Slider) return;
113113

114-
BubbleDrawable? lastBubble = bubbleContainer.OfType<BubbleDrawable>().LastOrDefault();
114+
// Reverse linear scan avoids the OfType<BubbleDrawable>().LastOrDefault() LINQ allocations.
115+
BubbleDrawable? lastBubble = null;
116+
117+
for (int i = bubbleContainer.Children.Count - 1; i >= 0; i--)
118+
{
119+
if (bubbleContainer.Children[i] is BubbleDrawable b)
120+
{
121+
lastBubble = b;
122+
break;
123+
}
124+
}
115125

116126
lastBubble?.ClearTransforms();
117127
lastBubble?.Expire(true);

osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.Linq;
88
using osu.Framework.Localisation;
99
using osu.Game.Rulesets.Mods;
10+
using osu.Game.Rulesets.Objects.Drawables;
1011
using osu.Game.Rulesets.Objects.Types;
1112
using osu.Game.Rulesets.Osu.Objects;
1213
using osu.Game.Rulesets.Osu.Objects.Drawables;
@@ -80,8 +81,12 @@ public void Update(Playfield playfield)
8081

8182
double time = playfield.Clock.CurrentTime;
8283

83-
foreach (var h in playfield.HitObjectContainer.AliveObjects.OfType<DrawableOsuHitObject>())
84+
foreach (DrawableHitObject dho in playfield.HitObjectContainer.AliveObjects)
8485
{
86+
// All alive objects in an osu! playfield are DrawableOsuHitObjects.
87+
// Casting inline avoids the OfType<> LINQ state-machine allocation per frame.
88+
if (dho is not DrawableOsuHitObject h)
89+
continue;
8590
// we are not yet close enough to the object.
8691
if (time < h.HitObject.StartTime - RELAX_LENIENCY)
8792
break;

osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,19 @@ public void ApplyToDrawableHitObject(DrawableHitObject drawable)
4646
if (slider.Clock is IGameplayClock { IsRewinding: true })
4747
return;
4848

49-
var tail = slider.NestedHitObjects.OfType<StrictTrackingDrawableSliderTail>().First();
49+
// Manual scan avoids allocating a LINQ state-machine on every tracking-change event.
50+
StrictTrackingDrawableSliderTail? tail = null;
5051

51-
if (!tail.Judged)
52+
foreach (var nested in slider.NestedHitObjects)
53+
{
54+
if (nested is StrictTrackingDrawableSliderTail t)
55+
{
56+
tail = t;
57+
break;
58+
}
59+
}
60+
61+
if (tail != null && !tail.Judged)
5262
tail.MissForcefully();
5363
};
5464
}

osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ public partial class DrawableHitCircle : DrawableOsuHitObject, IHasApproachCircl
3333
public HitReceptor HitArea { get; private set; } = null!;
3434
public SkinnableDrawable CirclePiece { get; private set; } = null!;
3535

36-
protected override IEnumerable<Drawable> DimmablePieces => new[] { CirclePiece };
36+
protected override IEnumerable<Drawable> DimmablePieces => dimmablePieces;
37+
private Drawable[] dimmablePieces = null!;
3738

3839
Drawable IHasApproachCircle.ApproachCircle => ApproachCircle;
3940

@@ -96,6 +97,8 @@ private void load()
9697

9798
Size = HitArea.DrawSize;
9899

100+
dimmablePieces = new Drawable[] { CirclePiece };
101+
99102
PositionBindable.BindValueChanged(_ => UpdatePosition());
100103
StackHeightBindable.BindValueChanged(_ => UpdatePosition());
101104
ScaleBindable.BindValueChanged(scale => scaleContainer.Scale = new Vector2(scale.NewValue));

osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
using System;
77
using System.Collections.Generic;
88
using System.Diagnostics;
9-
using System.Linq;
109
using osu.Framework.Allocation;
1110
using osu.Framework.Bindables;
1211
using osu.Framework.Graphics;
@@ -108,8 +107,11 @@ protected override void ClearNestedHitObjects()
108107
// and because of separate pooling of parent and child objects, there is no guarantee that the pieces will be associated with `this` again on re-use.
109108
// therefore, clean up the subscription here to avoid crosstalk.
110109
// not doing so can result in the callback attempting to read things from `this` when it is in a completely bogus state (not in use or similar).
111-
foreach (var piece in DimmablePieces.OfType<DrawableHitObject>())
112-
piece.ApplyCustomUpdateState -= applyDimToDrawableHitObject;
110+
foreach (var piece in DimmablePieces)
111+
{
112+
if (piece is DrawableHitObject dho)
113+
dho.ApplyCustomUpdateState -= applyDimToDrawableHitObject;
114+
}
113115
}
114116

115117
private void applyDim(Drawable piece)

osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,6 @@ protected override void OnFree()
163163
protected override void LoadSamples()
164164
{
165165
// Note: base.LoadSamples() isn't called since the slider plays the tail's hitsounds for the time being.
166-
167166
Samples.Samples = HitObject.TailSamples.Cast<ISampleInfo>().ToArray();
168167
slidingSample.Samples = HitObject.CreateSlidingSamples().Cast<ISampleInfo>().ToArray();
169168
}
@@ -301,7 +300,13 @@ protected override void CheckForResult(bool userTriggered, double timeOffset)
301300
ApplyResult(static (r, hitObject) =>
302301
{
303302
int totalTicks = hitObject.NestedHitObjects.Count;
304-
int hitTicks = hitObject.NestedHitObjects.Count(h => h.IsHit);
303+
int hitTicks = 0;
304+
305+
foreach (var h in hitObject.NestedHitObjects)
306+
{
307+
if (h.IsHit)
308+
hitTicks++;
309+
}
305310

306311
if (hitTicks == totalTicks)
307312
r.Type = HitResult.Great;
@@ -320,7 +325,18 @@ protected override void CheckForResult(bool userTriggered, double timeOffset)
320325
// But the slider needs to still be judged with a reasonable hit/miss result for visual purposes (hit/miss transforms, etc).
321326
ApplyResult(static (r, hitObject) =>
322327
{
323-
r.Type = hitObject.NestedHitObjects.Any(h => h.Result.IsHit) ? r.Judgement.MaxResult : r.Judgement.MinResult;
328+
bool anyHit = false;
329+
330+
foreach (var h in hitObject.NestedHitObjects)
331+
{
332+
if (h.Result.IsHit)
333+
{
334+
anyHit = true;
335+
break;
336+
}
337+
}
338+
339+
r.Type = anyHit ? r.Judgement.MaxResult : r.Judgement.MinResult;
324340
});
325341
}
326342
}

0 commit comments

Comments
 (0)