diff --git a/Docs/LatentTickTimeBudget.md b/Docs/LatentTickTimeBudget.md new file mode 100644 index 0000000..24c6bb8 --- /dev/null +++ b/Docs/LatentTickTimeBudget.md @@ -0,0 +1,109 @@ +# Tick time budget + +`UE5Coro::Latent::FTickTimeBudget` provides a convenient way to limit processing +on the game thread to the specified amount of time, instead of having to +manually tune the number of items processed per tick[^timeslice]. + +[^timeslice]: This can be trivially implemented with + `if (i % LoopsPerTick == 0) co_await NextTick();` in a loop. + +This class is not considered a latent awaiter (it's not an undocumented type +returned from a function), but it may only be used on the game thread. + +Awaiting it will do nothing until the budget has been exhausted, after which, it +will behave like `UE5Coro::Latent::NextTick()` once, then return to doing +nothing for a while. + +The intended use of this type is to be created outside a loop, then repeatedly +awaited inside. +This pattern ensures that at least one iteration will run, no matter how long it +took. + +> [!TIP] +> Although this feature is available to both async and latent coroutines, it's +> optimized for use in latent coroutines. +> The additional async overhead is a fixed amount per tick, regardless of how +> many co_awaits fit into the tick. + +### static FTickTimeBudget Seconds(double SecondsPerTick) + +Returns an object that lets code through for the specified amount of seconds per +tick. + +### static FTickTimeBudget Milliseconds(double MillisecondsPerTick) + +Returns an object that lets code through for the specified amount of +milliseconds per tick. + +### static FTickTimeBudget Microseconds(double MicrosecondsPerTick) + +Returns an object that lets code through for the specified amount of +microseconds per tick. + +## Examples + +Processing a fixed number of items on a 1 ms budget: +```cpp +using namespace UE5Coro; +using namespace UE5Coro::Latent; + +TCoroutine<> ProcessItems(TArray Items, FForceLatentCoroutine = {}) +{ + auto Budget = FTickTimeBudget::Milliseconds(1); + for (auto& Item : Items) + { + ProcessItem(Item); + co_await Budget; + } +} +``` + +Multi-stage processing works just as well, increasing the granularity at which +work can be deferred to the next tick. +The coroutine will resume where it left off: +```cpp +using namespace UE5Coro; +using namespace UE5Coro::Latent; + +TCoroutine<> ProcessItems(TArray Items, FForceLatentCoroutine = {}) +{ + auto Budget = FTickTimeBudget::Milliseconds(1); + for (auto& Item : Items) + { + PreProcess(Item); + co_await Budget; + ProcessCore(Item); + co_await Budget; + PostProcess(Item); + co_await Budget; + } +} +``` + +Processing items sent to the game thread from other threads (such as actor +spawning instructions), allowing 2 ms per tick: +```cpp +using namespace UE5Coro; +using namespace UE5Coro::Latent; + +TCoroutine<> ProcessItems(TMpscQueue& Queue, FForceLatentCoroutine = {}) +{ + for (;;) + { + auto Budget = FTickTimeBudget::Milliseconds(2); + for (FExample Item; Queue.Dequeue(Item); co_await Budget) + ProcessItem(Item); + // If the queue is empty, delay to the next tick, otherwise the outer + // loop would lock up the game thread + co_await NextTick(); + } +} +``` + +Repeatedly calling a worker function for 0.5 ms (500 µs) per tick: +```cpp +using namespace UE5Coro::Latent; + +for (auto Budget = FFrameTimeBudget::Microseconds(500); !bDone; co_await Budget) + PerformOneStepOfWork(); +``` diff --git a/README.md b/README.md index b04ca35..94ab066 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,10 @@ and other handlers. benefits (and your FPS). * What about spreading a heavy computation across multiple ticks?
Add a `co_await NextTick();` inside a loop, and you're already done. + There's a time budget class that lets you specify the desired processing time + directly, and let the coroutine dynamically schedule itself. +* Speaking of dynamic scheduling, throttling can be as simple as this:
+ `co_await Ticks(bCloseToCamera ? 1 : 2);` * Why time slice on the game thread when you have multiple CPU cores eager to work?
Add `co_await MoveToTask();` to your function, and everything after that line @@ -127,6 +131,7 @@ coroutines. * [Asset loading](Docs/LatentLoad.md) (async loading soft pointers, bundles...) * [Async collision queries](Docs/LatentCollision.md) (line traces, overlap checks...) * [Latent chain](Docs/LatentChain.md) (universal latent action wrapper) + * [Tick time budget](Docs/LatentTickTimeBudget.md) (run for x ms per frame) * [Latent callbacks](Docs/LatentCallback.md) (interaction with the latent action manager) diff --git a/Source/UE5Coro/Private/TickTimeBudget.cpp b/Source/UE5Coro/Private/TickTimeBudget.cpp new file mode 100644 index 0000000..37564b4 --- /dev/null +++ b/Source/UE5Coro/Private/TickTimeBudget.cpp @@ -0,0 +1,96 @@ +// Copyright © Laura Andelare +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted (subject to the limitations in the disclaimer +// below) provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY +// THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +// CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT +// NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +// PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +// EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; +// OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +// OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +// ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +#include "UE5Coro/TickTimeBudget.h" + +using namespace UE5Coro::Latent; + +namespace +{ +bool WaitForNextFrame(void* State, bool) +{ + // Return false on the suspending frame itself + return GFrameCounter > reinterpret_cast(State); +} +} + +FTickTimeBudget::FTickTimeBudget(double SecondsPerTick) + : FLatentAwaiter(nullptr, &WaitForNextFrame) +{ + // Check undefined conversion behavior before it occurs + checkf(SecondsPerTick / FPlatformTime::GetSecondsPerCycle() < + std::numeric_limits::max(), + TEXT("On this platform, the largest supported time budget is %f ms"), + FPlatformTime::GetSecondsPerCycle() * + std::numeric_limits::max() * 1000.0); + // This division is not ideal, but Unreal doesn't report cycles/sec. + // With double/double, there should be enough precision left over though. + CyclesPerTick = static_cast(SecondsPerTick / + FPlatformTime::GetSecondsPerCycle()); + Start = FPlatformTime::Cycles(); // Start the clock immediately +} + +FTickTimeBudget FTickTimeBudget::Seconds(double SecondsPerTick) +{ + return FTickTimeBudget(SecondsPerTick); +} + +FTickTimeBudget FTickTimeBudget::Milliseconds(double MillisecondsPerTick) +{ + return FTickTimeBudget(MillisecondsPerTick / 1'000.0); +} + +FTickTimeBudget FTickTimeBudget::Microseconds(double MicrosecondsPerTick) +{ + return FTickTimeBudget(MicrosecondsPerTick / 1'000'000.0); +} + +bool FTickTimeBudget::await_ready() +{ + if (int Elapsed = FPlatformTime::Cycles() - Start; // integer wraparound here + Elapsed < CyclesPerTick) [[likely]] + return true; + else + { + State = reinterpret_cast(GFrameCounter); // Resume on next tick + return false; + } +} + +void FTickTimeBudget::await_resume() +{ + // Reset the clock if and only if the coroutine was actually suspended + if (GFrameCounter == reinterpret_cast(State) + 1) [[unlikely]] + { + State = nullptr; + Start = FPlatformTime::Cycles(); + } +} diff --git a/Source/UE5Coro/Public/UE5Coro.h b/Source/UE5Coro/Public/UE5Coro.h index 2a29194..d0e6da9 100644 --- a/Source/UE5Coro/Public/UE5Coro.h +++ b/Source/UE5Coro/Public/UE5Coro.h @@ -49,6 +49,7 @@ #include "UE5Coro/LatentTimeline.h" #include "UE5Coro/Private.h" #include "UE5Coro/TaskAwaiter.h" +#include "UE5Coro/TickTimeBudget.h" #include "UE5Coro/Threading.h" #include "UE5Coro/UnrealTypes.h" diff --git a/Source/UE5Coro/Public/UE5Coro/TickTimeBudget.h b/Source/UE5Coro/Public/UE5Coro/TickTimeBudget.h new file mode 100644 index 0000000..4dd5fbf --- /dev/null +++ b/Source/UE5Coro/Public/UE5Coro/TickTimeBudget.h @@ -0,0 +1,62 @@ +// Copyright © Laura Andelare +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted (subject to the limitations in the disclaimer +// below) provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY +// THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +// CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT +// NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +// PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +// EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; +// OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +// OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +// ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +#pragma once + +#include "CoreMinimal.h" +#include "UE5Coro/Definition.h" +#include "UE5Coro/LatentAwaiter.h" + +namespace UE5Coro::Latent +{ +/** This class keeps track of the time elapsed during a tick, and co_awaiting it + * will delay the coroutine's execution to the next tick if the budget has been + * exhausted, otherwise it will keep running. + * Make sure to keep this outside the loop that uses it! */ +class [[nodiscard]] UE5CORO_API FTickTimeBudget : Private::FLatentAwaiter +{ + explicit FTickTimeBudget(double); + // These fields will be object sliced for await_suspend + int CyclesPerTick; + int Start; + +public: + static FTickTimeBudget Seconds(double SecondsPerTick); + static FTickTimeBudget Milliseconds(double MillisecondsPerTick); + static FTickTimeBudget Microseconds(double MicrosecondsPerTick); + UE_NONCOPYABLE(FTickTimeBudget); + + bool await_ready(); + void await_suspend(auto Handle) { FLatentAwaiter::await_suspend(Handle); } + void await_resume(); +}; +static_assert(!TLatentAwaiter); +} diff --git a/Source/UE5CoroTests/Private/TickTimeBudgetTest.cpp b/Source/UE5CoroTests/Private/TickTimeBudgetTest.cpp new file mode 100644 index 0000000..404d307 --- /dev/null +++ b/Source/UE5CoroTests/Private/TickTimeBudgetTest.cpp @@ -0,0 +1,114 @@ +// Copyright © Laura Andelare +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted (subject to the limitations in the disclaimer +// below) provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY +// THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +// CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT +// NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +// PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +// EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; +// OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +// OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +// ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +#include "TestWorld.h" +#include "Misc/AutomationTest.h" +#include "UE5Coro.h" + +using namespace UE5Coro; +using namespace UE5Coro::Latent; +using namespace UE5Coro::Private::Test; + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTickTimeBudgetAsyncTest, + "UE5Coro.Latent.TickTimeBudget.Async", + EAutomationTestFlags::ApplicationContextMask | + EAutomationTestFlags::HighPriority | + EAutomationTestFlags::ProductFilter) + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FTickTimeBudgetLatentTest, + "UE5Coro.Latent.TickTimeBudget.Latent", + EAutomationTestFlags::ApplicationContextMask | + EAutomationTestFlags::HighPriority | + EAutomationTestFlags::ProductFilter) + +namespace +{ +template +void DoTest(FAutomationTestBase& Test) +{ + FTestWorld World; + + // Since these test cases involve real time, they're not deterministic :( + { + int State = 0; + World.Run(CORO + { + auto Budget = FTickTimeBudget::Milliseconds(1); + FPlatformProcess::Sleep(0.002); + co_await Budget; + State = 1; + }); + World.EndTick(); + Test.TestEqual("State remained", State, 0); + World.Tick(); + Test.TestEqual("State changed", State, 1); + } + + { + int State = 0; + TSet Observed; + constexpr int Count = 20; + World.Run(CORO + { + auto Budget = FTickTimeBudget::Milliseconds(100); + for (; State < Count; ++State) + { + // Hopefully the platform can handle a sleep of roughly 20 ms... + FPlatformProcess::Sleep(20 / 1000.0f); + co_await Budget; + } + }); + World.EndTick(); + for (int i = 0; i < Count; ++i) + { + World.Tick(); + Observed.Add(State); + } + Test.TestFalse("No immediate suspension 0", Observed.Contains(0)); + Test.TestFalse("No immediate suspension 1", Observed.Contains(1)); + // This was observed to pass with ±3, take one away for safety + Test.TestTrue("Execution was between the two extremes", + Observed.Num() > 2 && Observed.Num() <= Count - 2); + } +} +} + +bool FTickTimeBudgetAsyncTest::RunTest(const FString& Parameters) +{ + DoTest<>(*this); + return true; +} + +bool FTickTimeBudgetLatentTest::RunTest(const FString& Parameters) +{ + DoTest(*this); + return true; +}