bevyengine / bevy

A refreshingly simple data-driven game engine built in Rust
https://bevyengine.org
Apache License 2.0
36.25k stars 3.57k forks source link

std::thread::sleep sleeps for too long in ScheduleRunnerPlugin #1501

Open engiwengi opened 3 years ago

engiwengi commented 3 years ago

Bevy version

master

Operating system & version

Windows 10 - this issue may be isolated to Windows?

What you did

Create and run an app with ScheduleRunnerPlugin and set the expected FPS to an arbitrary number.

fn main() {
    App::build()
        .insert_resource(ScheduleRunnerSettings::run_loop(Duration::from_secs_f64(
            1.0 / 20.0,
        )))
        .add_plugin(ScheduleRunnerPlugin::default())
        .add_plugin(DiagnosticsPlugin)
        .add_plugin(FrameTimeDiagnosticsPlugin)
        .add_plugin(LogDiagnosticsPlugin::default())
        .run();
}

What you expected to happen

FPS should be almost exactly 20 FPS

What actually happened

FPS hovers between 15 and 16 FPS

Additional information

I swapped only the std::thread::sleep(delay) with the spin sleep crate's spin_sleep::sleep(delay) and get almost exactly 20 FPS as expected.

inodentry commented 3 years ago

I remember Amethyst had an API for choosing the sleep behavior for frame limiting, precisely because of this issue: some operating systems can sleep your thread for longer than indicated, so you cannot reliably guarantee that you will wake up on time for the next frame.

You could select whether to use sleep, or to busy-loop and call yield instead (which was the default). Yielding lets the OS schedule other processes/threads that want to run, but keeps our thread ready for the OS to come back to it immediately. You can also do a hybrid approach: sleep for a shorter period (to save some power/cpu) and then yield in a loop to wait out the remaining time until the next frame.

I don't know if this is the best solution to the problem (it's ugly!), or what other/production games use, but this is what I remember from Amethyst.

Unfortunately I cannot link you to Amethyst docs to show their solution, because their docs and website are broken as of right now, so api docs are not available anywhere.

YohDeadfall commented 3 years ago

Usually this problem is fixed by an internal dispatcher provided by a framework and it should be there too (just started here, so has no idea on internals). The dispatcher works in a loop checking for messages from the operating system and running updates, but at the start of each cycle when system event handling is done it verifies user mode timers which are stored in a sorted list as tuples of a time to trigger and a handler. This method doesn't cause high CPU usage and provides more precise scheduling.

inodentry commented 3 years ago

Yes, using some sort of timer API to schedule interrupts at specific times definitely sounds like the way to go.

Amethyst's "solution" seems like a hack/workaround for using the wrong API (sleep for duration) in the first place.

W4RH4WK commented 3 years ago

Here's a link to a related discussion on Amethyst: https://github.com/amethyst/amethyst/issues/2083

64kramsystem commented 3 years ago

Hi there!

I'm working on the Amethyst issue on the same topic (see https://github.com/bevyengine/bevy/issues/1501#issuecomment-786257435). Although analysis is not yet 100% complete, there is the conclusion is that there is a minimum amount of strategies that provide a different balance of pro/cons (in short: CPU occupation <> precision).

I think this is an issue that a game engine should definitely address. Based on a brief check, Bevy currently uses a simple thread::sleep().

Is there any interest from the maintainers in addressing this problem? I can look into it. It needs to be thought though, how to expose this to the developer. For example, would an additional optional field in RunMode::Loop work? If so, which should be the default? Which should be the semantic? Should it describe the implementation (e.g. Sleep, Yield), or indicate where on the spectrum of precision it resides (Fast, Precise)?

64kramsystem commented 3 years ago

So, I've actually missed YohDeadfall's comment above when posting here (as a consequence, ignore the proposal to implement different strategy).

In the current state, Bevy's logic is (as far as I understand) a standard loop with sleep (RunLoop), which is not different from Amethyst.

The current issue could be mitigated with the canonical timeBeginPeriod. In the longer term, the described event scheduler is an ideal solutiom, but in the meanwhile, timeBeginPeriod should be a simple enough intermediate step.

64kramsystem commented 3 years ago

Usually this problem is fixed by an internal dispatcher provided by a framework and it should be there too (just started here, so has no idea on internals). The dispatcher works in a loop checking for messages from the operating system and running updates, but at the start of each cycle when system event handling is done it verifies user mode timers which are stored in a sorted list as tuples of a time to trigger and a handler. This method doesn't cause high CPU usage and provides more precise scheduling.

This is an interesting design. Can you provide some references to implementations (or even just theory)? I wonder what's the technical implementation, that is, how much it is an event listener, and how much a loop in a strict sense.