Untitled :: Boost.OpenMethod

Open-methods can be as fast as ordinary virtual member functions when compiled with optimization.

First, let’s examine the code generated by clang for an ordinary virtual function call:

    void call_virtual_function(const Node& node, std::ostream& os) {
        node.postfix(os);
    }

Clang compiles this function to the following assembly on the x64 architecture:

    mov	rax, qword ptr [rdi]
    mov	rax, qword ptr [rax + 24]
    jmp	rax                             # TAILCALL

llvm-mca estimates this code has a throughput of 1 cycle per dispatch.

Let’s look at a method call now:

    void call_via_ref(const Node& node, std::ostream& os) {
        postfix(node, os);
    }

This compiles to (variable names are shortened for readability):

    	mov		rax, rdi
    	mov		rcx, qword ptr [rdi]
    	mov		rdi, qword ptr [rip + mult]
    	imul 	rdi, qword ptr [rcx - 8]
    	movzx	ecx, byte ptr [rip + shift]
    	shr		rdi, cl
    	mov		rdx, rsi
    	mov		rcx, qword ptr [rip + vptr_vector_vptrs]
    	mov		rdi, qword ptr [rcx + 8*rdi]
    	mov		rcx, qword ptr [rip + postfix::fn+88]
    	mov		rcx, qword ptr [rdi + 8*rcx]
    	mov		rsi, rax
    	jmp		rcx                             # TAILCALL

This is quite a few instructions more. Upon closer examination, we see that many are memory reads, independent of one another; they can thus be executed in parallel. For example, the first three instructions can execute simultaneously.

llvm-mca estimates a throughput of 4 cycles per dispatch. However, the difference is amortized by the time spent passing the arguments and returning from the function; plus, of course, executing the body of the function.

Micro- and RDTSC-based benchmarks suggest that dispatching an open-methods with a single virtual argument via a reference is between 30% and 50% slower than calling the equivalent virtual function, with an empty body and no other arguments. In most real programs, the overhead would be unnoticeable.

However, `call_via_ref` does two things: it constructs a `virtual_ptr<Node>` from a `const Node&`, then it calls the method.

The construction of the `virtual_ptr` is the costly part. It performs a lookup in a perfect hash table, indexed by pointers to `std::type_info`, to find the correct vtable. Then it stores a pointer to it in the `virtual_ptr` object, along with a pointer to the object.[1]

If we already have a `virtual_ptr`:

    void call_via_virtual_ptr(virtual_ptr<const Node> node, std::ostream& os) {
        postfix(node, os);
    }

A method call compiles to:

    mov	rax, qword ptr [rip + postfix::fn+88]
    mov	rax, qword ptr [rdi + 8*rax]
    jmp	rax                             # TAILCALL

`virtual_ptr` arguments are passed through the method call, to the overrider, which can use them to make further method calls.

Code that incorporates open-methods in its design should use `virtual_ptr`s in place of plain pointers or references, as much as possible. Here is the Node example, rewritten to use `virtual_ptr`s thoughout:

    #include <boost/openmethod.hpp>
    using boost::openmethod::virtual_ptr;

    struct Node {
        virtual ~Node() {}
        virtual int value() const = 0;
    };

    struct Variable : Node {
        Variable(int value) : v(value) {}
        int value() const override { return v; }
        int v;
    };

    struct Plus : Node {
        Plus(virtual_ptr<const Node> left, virtual_ptr<const Node> right)
            : left(left), right(right) {}
        int value() const override { return left->value() + right->value(); }
        virtual_ptr<const Node> left, right;
    };

    struct Times : Node {
        Times(virtual_ptr<const Node> left, virtual_ptr<const Node> right)
            : left(left), right(right) {}
        int value() const override { return left->value() * right->value(); }
        virtual_ptr<const Node> left, right;
    };

    #include <iostream>

    using boost::openmethod::virtual_ptr;

    BOOST_OPENMETHOD(postfix, (virtual_ptr<const Node> node, std::ostream& os), void);

    BOOST_OPENMETHOD_OVERRIDE(
        postfix, (virtual_ptr<const Variable> var, std::ostream& os), void) {
        os << var->v;
    }

    BOOST_OPENMETHOD_OVERRIDE(
        postfix, (virtual_ptr<const Plus> plus, std::ostream& os), void) {
        postfix(plus->left, os);
        os << ' ';
        postfix(plus->right, os);
        os << " +";
    }

    BOOST_OPENMETHOD_OVERRIDE(
        postfix, (virtual_ptr<const Times> times, std::ostream& os), void) {
        postfix(times->left, os);
        os << ' ';
        postfix(times->right, os);
        os << " *";
    }

    BOOST_OPENMETHOD_CLASSES(Node, Variable, Plus, Times);

    #include <boost/openmethod/initialize.hpp>

    int main() {
        boost::openmethod::initialize();
        Variable a{2}, b{3}, c{4};
        Plus d{a, b}; Times e{d, c};
        postfix(e, std::cout);
        std::cout << " = " << e.value() << "\n"; // 2 3 + 4 * = 20
    }

1. This is how Go and Rust implement dynamic dispatch.
Edit this Page