Skip to content

Commit

Permalink
Add Latent::FTickTimeBudget, a dynamic time slice helper
Browse files Browse the repository at this point in the history
  • Loading branch information
landelare committed Jul 4, 2024
1 parent d975340 commit 96db8d3
Show file tree
Hide file tree
Showing 6 changed files with 387 additions and 0 deletions.
109 changes: 109 additions & 0 deletions Docs/LatentTickTimeBudget.md
Original file line number Diff line number Diff line change
@@ -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<FExampleItem> 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<FExampleItem> 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<FExample>& 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();
```
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ and other handlers.
benefits (and your FPS).
* What about spreading a heavy computation across multiple ticks?<br>
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:<br>
`co_await Ticks(bCloseToCamera ? 1 : 2);`
* Why time slice on the game thread when you have multiple CPU cores eager to
work?<br>
Add `co_await MoveToTask();` to your function, and everything after that line
Expand Down Expand Up @@ -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)
Expand Down
96 changes: 96 additions & 0 deletions Source/UE5Coro/Private/TickTimeBudget.cpp
Original file line number Diff line number Diff line change
@@ -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<uint64>(State);
}
}

FTickTimeBudget::FTickTimeBudget(double SecondsPerTick)
: FLatentAwaiter(nullptr, &WaitForNextFrame)
{
// Check undefined conversion behavior before it occurs
checkf(SecondsPerTick / FPlatformTime::GetSecondsPerCycle() <
std::numeric_limits<int>::max(),
TEXT("On this platform, the largest supported time budget is %f ms"),
FPlatformTime::GetSecondsPerCycle() *
std::numeric_limits<int>::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<int>(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<void*>(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<uint64>(State) + 1) [[unlikely]]
{
State = nullptr;
Start = FPlatformTime::Cycles();
}
}
1 change: 1 addition & 0 deletions Source/UE5Coro/Public/UE5Coro.h
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
62 changes: 62 additions & 0 deletions Source/UE5Coro/Public/UE5Coro/TickTimeBudget.h
Original file line number Diff line number Diff line change
@@ -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<FTickTimeBudget>);
}
Loading

0 comments on commit 96db8d3

Please sign in to comment.