Provide Runtime Services to Systems

Introduction

Runtime Services are what capable of making Systems impure: they provide a mechanism for Systems to cause side effects and communicate with other parts of the game engine.

Take some examples: A clock service provides a timer that counts the elapsed time since the previous frame. A graphics service provides an interface for compiling and submitting rendering commands. An optional statistics service provides counters for the System to record what it’s doing in debug mode.

Let’s review the basic structure of a System with an example of a physics system.

struct System_physics
{
    using WriteAccess = ComponentFilter<ComponentPhysics, ComponentPosition>;

    Vector2f global_gravity { 0.f, -9.8f };

    template <typename RuntimeServices, typename EntityDatabaseAccess>
    void update(RuntimeServices &&rt, EntityDatabaseAccess &&db)
    {
        const auto dt = static_cast<float>(
            USAGI_SERVICE(rt, Service_master_clock).elapsed()
        );

        std::for_each(std::execution::par, db.begin(), db.end(), [&](auto &&p) {
            for(auto &&e : db.page_view(p, WriteAccess()))
            {
                auto &c_physics = USAGI_COMPONENT(e, ComponentPhysics);
                auto &c_pos = USAGI_COMPONENT(e, ComponentPosition);
                c_pos.position += c_physics.velocity * dt;
                c_physics.velocity +=
                    (c_physics.acceleration + global_gravity) * dt;
            }
        });
      
        USAGI_OPT_SERVICE(rt, Service_flag_need_rerender, flag, {
            flag = true;
        });
    }
};

The update() function of the System is a template. This means that it does not have to know about what exactly type is passed in as the Runtime, so the client of the System can compose whatever Services together without changing the System code. However, it also means that the IDE doesn’t know how to provide code completion for the System. To work around this problem, the macro USAGI_SERVICE performs a static_cast on the reference to the Runtime to cast it to the desired service type.

#define USAGI_SERVICE(rt, svc) \
    static_cast<svc&>(rt).get_service()

Basic Ideas

The Runtime is basically a struct that inherits from every Service Implementation that the Game Script wants to provide to the System. Each Service Implementation optionally implements a Service Interface which defines a ServiceType and provides a pure virtual function get_service() which returns a reference to the service variable. If it doesn’t inherit such an interface, it must provide the type and the function on its own but doesn’t have to make the function virtual. The ServiceType in the Service Interface might be just some abstract type and leaving actual implementation to the Service Implementation, which can exploit covariant return type to return a reference with more precise typing.

Example

The following code demonstrates how Service Implementation provides a renderer implementation to the System. The objective is making the compiler realize that by invoking static_cast<ServiceBase&>(runtime).get_service().render() we eventually invoke RendererImpl::render() so it can perform devirtualization and inlining accordingly.

#include <cstdio>

struct RendererBase
{
    virtual ~RendererBase() = default;
  	// Side-effects to prevent being optimized out
    virtual void render() { putchar('1'); }
};

// Declared as final to enable devirtualization
struct RendererImpl final : RendererBase
{
    void render() override { putchar('2'); }
};

// Service Interface
struct ServiceBase
{
    // Let the System think that it's using the base type.
    using ServiceType = RendererBase;
    virtual ~ServiceBase() = default;
    // Service is returned by this function to avoid name collisions.
    virtual ServiceType & get_service() = 0;
};

// Service Implementation
// Not final because Runtime has to inherit from this
struct ServiceImpl : ServiceBase
{
    RendererImpl renderer;
    // Covariant return type. A competent compiler should
    // be able to know that calls to the returned object
    // can be devirtualized because it's a final class.
    RendererImpl & get_service() override { return renderer; }
};

struct System 
{
    template <typename Runtime>
    void update(Runtime &&rt)
    {
      	// System doesn't have to know the real type.
        auto &svc = static_cast<ServiceBase&>(rt).get_service();
        svc.render();

        auto &svc3 = static_cast<ServiceImpl&>(rt).get_service();
        svc3.render();
    }
};

int main()
{
    struct Runtime : ServiceImpl { } rt;
    System sys;
    sys.update(rt);
}

Explanation

The static_cast provides type info to the IDE without having to instantiate the function template. Thus we get code completion capacity. It also prevents name collisions of get_service() inherited from different classes.

The ideal output of this code would be that the compiler inferred that the System is essentially using a RendererImpl object and perform devirtualization accordingly. The reason is struct Runtime inherits from struct ServiceImpl, whose get_service() returns a reference to struct RendererImpl, which is a derived class of struct RenderBase. And ServiceBase::get_service() returns a reference to struct RenderBase. The compiler should know that Runtime is a derived class of ServiceImpl and thus also ServiceBase, castingRuntime & to ServiceBase & and calling get_service() eventually calls ServiceImpl::get_service() and returns RendererImpl &. Since RendererImpl is a final class, devirtualization can be performed. Even it is not final, because we know that RendererImpl comes from ServiceImpl as an object, it cannot be anything else.

Are The Compilers Good Enough?

The question is is there any compiler smart enough to do this job? To verify, I compared the code output from three major compilers on godbolt.org. The results are summarised below:

  • MSVC
    • With static_cast<ServiceBase&>, get_service() is called by looking up the vtable and render(), too.
    • With static_cast<ServiceImpl&>, get_service() is still a virtual call. But render() is properly devirtualized and inlined.
    • System::update() is inlined into the main function.
    • MSVC didn’t realize that ServiceImpl::get_service() isn’t overridden in Runtime and perform devirtualization of this function accordingly, even if Runtime is declared as final.
  • GCC
    • RendererImpl::render() is devirtualized in both cases. However, it’s only inlined with static_cast<ServiceImpl&>.
    • System::update() is inlined also into the main function.
  • Clang
    • What are you doing with so much code? It’s just two getchar()!
The disassembly output on Compiler Explorer: https://godbolt.org/z/f79w3s. It’s such a priceless tool.

If the final keyword is removed from RendererImpl, MSVC forgets about devirtualization or whatever; GCC still devirtualizes the the calls to RendererImpl::render() but won’t inline them. And Clang: Wut? You changed the code? I didn’t know that.

So, yes. At least we have one winner who is capable of the job, which is good news.

Force the Compilers to Cooperate

The point of USAGI_SERVICE macro is to provide static typing information of the requested Service. Therefore, it cannot rely on the type of Runtime to get the actual type of Service Implementation. Because the dependent types are exactly what we are trying to get rid of.

There are actually two steps involved in the optimization process. The compiler first has to realize that calling static_cast<ServiceBase&>(runtime).get_service() resolves to ServiceImpl::get_service() and performs devirtualization accordingly. This can be assisted by declaring ServiceImpl::get_service() as final to enlighten even the dumbest compiler we’ve encountered. Then the compiler has to realize the return type of ServiceImpl::get_service() is eligible for devirtualization, too. Still, we can declare RendererImpl as final to assist this step, but if the compiler fails the first step, it won’t get to this step anyway.

There are several paths to resolving the actual Service Implementation type from the Service Interface type, each with major problems, however.

Relying on the Compilers

This is unreliable and we’ve seen it. The standard doesn’t force the compilers to perform devirtualization.

Using Dirty Macros

This is by far the most viable compromise I can think of: It allows each Runtime to inform the Systems about the type of Service Implementation without modifying the code of System. It works by making some little changes to the USAGI_SERVICE macro so the service type is actually substituted by another macro:

#define USAGI_SERVICE(rt, svc) \
static_cast<USAGI_SVC_##svc&>(rt).get_service()

The svc parameter is still the type of Service Interface. But it is actually concatenated with a prefix and will be substituted in the next parsing pass to the type of Service Implementation, defined in the body file using the System before including the header of the System:

#define USAGI_SVC_ServiceBase ServiceImpl

#include "SystemRenderer.hpp"

struct Runtime
  	: ServiceImpl
{
};

// ...

This macro is only visible to the current body file. If the same System is used elsewhere with another Runtime, another macro definition can be made so that the System compiles with another Service Implementation type. Because they are actually two types of Runtime and the macro definition merely reflects the actual Service Implementation used in the Runtime, no linking problem will be caused.

The single most important problem of this approach is that it looks ugly. Beyond that, it works just well. Because the static analysis of the System is done by compiling the body file #including it, the analyzer sees the definition of Services and provides auto-completion info accordingly. However, it should be noted that it actually provides such info for RendererImpl instead of RendererBase.

Auto-completion info provided by Resharper based on the macro definition

How about Templates??

Sadly using templates to resolve to the type of Service Implementation from Service Interface will inevitably produce types dependent on Runtime or cause template definition conflicts.

The first case is obvious. For the latter case, because we want a type independent of the type of Runtime, we have to make a template and provide specialization for each of the Service Interface to define the type of the Service Implementation. Unfortunately, because the template would be global, the ability to use different Service Implementation for different Runtimes is lost since template specializations cannot be redefined.

Optional Services

Sometimes we also want some optional components which are only enabled in some case such as debugging. The Game Script decides whether to enable these Services by opting between incorporating the corresponding Service Implementation into its inheritance graph (hopefully a tree) or not. This follows one gold C++ design principle: You don’t pay for what you don’t use.

This is not hard to implement. Just check whether the Runtime inherits from the Service Interface. If it does, the code within the if constexpr block will be executed. Otherwise, this is a no-op.

#include <type_traits>

#define USAGI_OPT_SERVICE(rt, svc_type, svc_var, task) \
    do { \
        if constexpr(std::is_base_of_v< \
            svc_type, std::remove_cvref_t<decltype(rt)>>) \
        { \
            auto &svc_var = USAGI_SERVICE(rt, svc_type); \
            task \
        } \
    } while(false) \
/**/

Yukino

Leave a Reply

Your email address will not be published. Required fields are marked *