Tag Archives: dlang

The Cost of Compile Time in D

When I was creating my presentation for dconf online 2022, I was looking at alternatives to building constraints. If you watched my talk, you can see the fruit of that experiment in my strawman library (which is very much a proof-of-concept, and not ready for real use).

But it got me thinking — how much more expensive are these strawman constraints than the current Phobos range constraints? But even before I went that far, I started looking at some of the phobos constraints, and realized even there, we can achieve some savings.

Consider the constraint for isInputRange:

enum bool isInputRange(R) =
    is(typeof(R.init) == R)
    && is(ReturnType!((R r) => r.empty) == bool)
    && (is(typeof((return ref R r) => r.front)) ||
        is(typeof(ref (return ref R r) => r.front)))
    && !is(ReturnType!((R r) => r.front) == void)
    && is(typeof((R r) => r.popFront));

Let’s focus on one aspect of this, the use of the ReturnType template. What does that do? Essentially, it takes the parameter (in this case a lambda function) and evaluates to the return type of the callable.

But…. we have that as part of the language, don’t we? Yeah, it’s called typeof. typeof gives you the “type of” an expression. And it’s a direct link into the compiler’s semantic analysis — no additional semantic computation is needed.

To see what we are comparing against, let’s take a look at the ReturnType template (and its dependencies):

template ReturnType(alias func)
if (isCallable!func)
{
    static if (is(FunctionTypeOf!func R == return))
        alias ReturnType = R;
    else
        static assert(0, "argument has no return type");
}

template FunctionTypeOf(alias func)
if (isCallable!func)
{
    static if ( (is(typeof(& func) Fsym : Fsym*) && is(Fsym == function)) || is(typeof(& func) Fsym == delegate))
    {
        alias FunctionTypeOf = Fsym; // HIT: (nested) function symbol
    }
    else static if (is(typeof(& func.opCall) Fobj == delegate) || is(typeof(& func.opCall!()) Fobj == delegate))
    {
        alias FunctionTypeOf = Fobj; // HIT: callable object
    }
    else static if (
            (is(typeof(& func.opCall) Ftyp : Ftyp*) && is(Ftyp == function)) ||
            (is(typeof(& func.opCall!()) Ftyp : Ftyp*) && is(Ftyp == function))
        )
    {
        alias FunctionTypeOf = Ftyp; // HIT: callable type
    }
    else static if (is(func T) || is(typeof(func) T))
    {
        static if (is(T == function))
            alias FunctionTypeOf = T;    // HIT: function
        else static if (is(T Fptr : Fptr*) && is(Fptr == function))
            alias FunctionTypeOf = Fptr; // HIT: function pointer
        else static if (is(T Fdlg == delegate))
            alias FunctionTypeOf = Fdlg; // HIT: delegate
        else
            static assert(0);
    }
    else
        static assert(0);
}

template isCallable(alias callable)
{
    // 20 lines of code
}

template isSomeFunction(alias T)
{
    // 15 lines of code
}

Whoa, that’s a lot of code to tell me what the type of something is! Why is it so complex? The reason is because in order to determine the return type of something, we have to use the typeof primitive, but this needs a valid expression. For a callable, that means we need a valid set of parameters. All of that needs to be introspected by the library, which is simply given a symbol and doesn’t know anything about that symbol without context.

However we have context! We know exactly how to call the lambda function we have constructed, with an R! Why do we need this complexity for something that should be a simple call? As most well-versed in writing generic library code know, this is not an easy thing to do (sometimes generic types can’t be easily constructed, or you might have issues with disabled copying, etc.). In addition, ReturnType is built to handle all sorts of callable things, not just lambda functions.

But isInputRange doesn’t actually need to construct, or even have a valid R for generating the expression, all it needs is an already existing R to call methods on it. We can do this using a reinterpret cast of null to an R* and now we have an “already made” R. Yes, this would crash if actually run, but we don’t ever need to run it, we just need to get its type! And so, here is an equivalent isInputRange template that does not use ReturnType:

enum isInputRange(R) =
    is(typeof(R.init) == R)
    && is(typeof(() { return (*cast(R*)null).empty; }()) == bool)
    && (is(typeof((return ref R r) => r.front)) ||
        is(typeof(ref (return ref R r) => r.front)))
    && !is(typeof(() { return (*cast(R*)null).front; }()) == void)
    && is(typeof((R r) => r.popFront));

The difference here is we have a no-argument lambda, and so we don’t have to rely on library tricks or introspection to know how to call it (and as you can see, we call it with no parameters as expected).

Measuring the results

Given an isInputRange template that is completely independent of std.traits, what is the result? How much does it save?

To test this, I wrote a program generator that created 10000 identical but independently named input ranges, that are tested like this:

struct S0 { int front; void popFront() {}; bool empty = false; }
static assert(isInputRange!S0);
struct S1 { int front; void popFront() {}; bool empty = false; }
static assert(isInputRange!S1);
...
struct S9999 { int front; void popFront() {}; bool empty = false; }
static assert(isInputRange!S9999);

Running on my Linux system, using DMD 2.101.2, I get the following results:

COMMANDTIMEMEMORY USAGE
dmd -version=usePhobos2.75s1.755G
dmd -version=useTypeof1.47s621M

Looking at the savings, it’s quite significant — almost 50% time savings, and over 65% memory savings. Note that each call to ReturnType is unique, and so it will execute its own semantic analysis. Using the compiler’s -vtemplates switch, we can see that using the current Phobos adds quite a few dependent templates. For each usage of isInputRange, we see:

  • 2 distinct instantiations of ReturnType
  • 4 instantiations of isCallable (2 distinct)
  • 2 distinct instantiations of FunctionTypeOf
  • 2 distinct instantiations of isSomeFunction

All that adds up to an additional 8 distinct template instantiations, and 10 total instantiations. A distinct template instantiation will run semantic analysis, but a non-distinct one will just find the existing template in the symbol table and return it.

Using the measurement numbers we can somewhat extrapolate that each ReturnType instantiation adds 64 microseconds, and consumes 56.7K of RAM. The RAM consumption comes from storing the additional template instantiation symbols in the symbol table.

Conclusion

Such small savings, why is it important? It’s important because this is a perfect example of “death by 1000 paper cuts”. Each little template instantiation gives us a bit of convenience, but adds a tiny cost. These costs can add up significantly, and produce an overall compiler experience that is frustratingly slow, or worse, runs out of memory (yes, I have had this happen)! For something such as isInputRange, which almost nobody ever looks at or needs to, the cost is not well spent — especially considering how short and readable the alternative is!

When you reach for something in std.traits, consider what the compile-time cost might be, and don’t always assume that a small call will be efficient. Are you writing something people have to understand easily? If not, make the messy details as complex as needed to avoid such costs. If you can write the same thing using builtins, it will run faster, and it might even work better. I like to prefer compiler builtins such as typeof, is expressions and __traits to std.traits whenever possible, as long as the cognitive load of the resulting code isn’t too great (and yes, it can be).

I do plan to submit a PR to streamline everything I can about the range traits, maybe we can all pitch in and see where some of this interdependent fat can be trimmed all throughout Phobos!

How to Keep Using D1 Operator Overloads

D1 style operator overloads have been deprecated in D2 since version 2.088, released in 2019. Version 2.100, released last month, saw those operator overloads removed completely from the language. However, using D’s fabulous metaprogramming capability, it is possible to write a mixin template shim that will allow your D1 style operator overloads to keep working.

For sure, the best path forward is to switch to the new style of operator overloads. But there can be good reasons to keep using the old ones. Maybe you really love the simplicity of them. Maybe you use them already for virtual functions in classes, and don’t want to change. Maybe you just don’t want to do much code editing to an old project.

Whatever the reason, this post will show you how to do it easily and succinctly!

D1 Operator Overloads vs. D2 Operator Overloads

An operator overload is a way for a custom type to handle operators (e.g. + and -). In D1 these were handled using plain named functions, such as opAdd for addition or opMul for multiplication. For an example to work with, here is a struct type that uses an integer to represent its internal state:

struct S {
   int x;
   S opAdd(S other) {
      return S(x + other.x);
   }
   S opSub(S other) {
      return S(x - other.x);
   }
   S opMul(S other) {
      return S(x * other.x);
   }
   S opDiv(S other) {
      assert(other.x != 0, "divide by zero!");
      return S(x / other.x);
   }
}

void main() {
   S s1 = S(6);
   S s2 = S(3);
   assert(s1 + s2 == S( 9));
   assert(s1 - s2 == S( 3));
   assert(s1 * s2 == S(18));
   assert(s1 / s2 == S( 2));
}

Note how repetitive the operator code is! Plus, we only handled 4 operations. There are actually 11 math and bitwise binary (2-arg) operations that could be potentially overloaded for an integer. This doesn’t count unary operations (e.g. S s3 = -s1) or operations where S is on the right side of the op, with maybe an int on the left side (e.g. opAdd_r, opMul_r). If we needed to overload based on operand type, we could branch out into template functions, but that might not be that much less code.

D2 decided that a better way to handle bulk operations would be to use templates in order to handle operators. Instead of calling opAdd for + and opMul for *, it will call opBinary!"+" and opBinary!"*" respectively. This means we can handle all the operations in one function. To process them all, we can rewrite S like this:

struct S {
   int x;
   S opBinary(string op)(S other) {
      static if(op == "/" || op == "%")
         assert(other.x != 0, "divide by zero!");
      return mixin("S(x ", op, " other.x)");
   } 
}

void main() {
   S s1 = S(6);
   S s2 = S(3);
   assert( s1 + s2  == S( 9));
   assert( s1 - s2  == S( 3));
   assert( s1 * s2  == S(18));
   assert( s1 / s2  == S( 2));
   assert( s1 % s2  == S( 0));
   assert((s1 | s2) == S( 7));
   // and so on
}

Note how we not only have only one function (with a slight difference for the division operators), but we handle all math operations! The code is easier to write, less error prone, and less verbose.

Aliasing Operators

But what if you already have operators in D1 style, and you don’t want to change them, or merge them into one super-function?

D allows you to alias member functions to another symbol, and opBinary is no exception. Here is the original type, but with aliases for each of the operators:

struct S {
   int x;
   S opAdd(S other) {
      return S(x + other.x);
   }
   S opSub(S other) {
      return S(x - other.x);
   }
   S opMul(S other) {
      return S(x * other.x);
   }
   S opDiv(S other) {
      assert(other.x != 0, "divide by zero!");
      return S(x / other.x);
   }

   alias opBinary(op : "+") = opAdd;
   alias opBinary(op : "-") = opSub;
   alias opBinary(op : "*") = opMul;
   alias opBinary(op : "/") = opDiv;
}

Note that we are using a few cool features of D metaprogramming here. The aliases are eponymous templates which means I don’t have to write out the template long form, and we are using template parameter specialization to avoid having to use a single template and look for the covered operations inside the template, or having to use template constraints to filter out the operations we cover.

But we can do even better than this! Nobody wants to write this boilerplate code tailored to each type which may not all cover the same exact operators.

Mixin Templates

A mixin template is a template with a set of declarations in it. Wherever you mixin that template, it’s (almost) as if you typed all those declarations directly. Using the power of D’s compile-time introspection, it’s possible to handle every single possible operator overload that D1 could offer, by writing aliases to the D1 style operator overload, automatically.

In order to do this, we are going to have three rules. First is that we don’t care if the operators are properly written in D1 style. As long as the names match, we will forward to them. We also don’t need to worry about overloads based on the types or parameters accepted, as aliases are just name rewrites. Second, this mixin MUST be added at the end of the type, because otherwise, the entire type’s members may not have been analyzed by the compiler (this may change in a future version of D). Third, D does not allow overloads between the mixed-in functions and regular functions — the regular functions will take precedence. So you cannot define any D2 style operators of a specific name (e.g. opBinary). If you want D2 operators, convert the whole thing, don’t use some D1 and some D2.

Let’s write just the opAdd declaration in a mixin template, and see how it works.

mixin template D1Ops() {
   static if(__traits(hasMember, typeof(this), "opAdd"))
      alias opBinary(op : "+") = opAdd;
}

There’s a lot of meta code in here, I’ll explain it all.

The mixin template declaration is telling the compiler that this is a template specifically for mixins. Technically, you can use any template for mixins, but declaring it a mixin template requires that it’s only used in that way.

If you don’t know what static if is, I highly recommend reading a tutorial on D metaprogramming, as it’s essential for almost every metaprogramming task. Needless to say, the contained code is only included if the condition is true.

__traits(hasMember, T, "opAdd") is a specialized condition that is true only if the specified type T (in this case, the type of the struct the mixin is being added to) contains a member having the name opAdd.

And finally, the alias is as we wrote before.

Now, how would we use this inside our type?

struct S {
   int x;
   S opAdd(S other) {
      return S(x + other.x);
   }
   S opSub(S other) {
      return S(x - other.x);
   }
   S opMul(S other) {
      return S(x * other.x);
   }
   S opDiv(S other) {
      assert(other.x != 0, "divide by zero!");
      return S(x / other.x);
   }

   mixin D1Ops;
}

That’s it! Now opAdd is hooked via the aliased opBinary instead of via the D1 operator overload. Therefore, S + S will compile on 2.100 and later. However, the other operator overloads will not.

Why do it this way? As we will see, using the static if allows us to mixin the template regardless of whether opAdd is present or not. Using this feature, we can handle every possible situation with regards to existing operator overloads.

Using the Full Power of D

Adding each and every operator overload to the mixin is going to be very repetitive. But there is no need to do this, D is a superpower in metaprogramming! All we need to do is lay out the operation mappings, and we can use another specialized metaprogramming feature, static foreach, to avoid having to repeat the same boilerplate over and over.

With this, we can handle every binary operation that the struct might have written D1 style:

mixin template D1Ops() {
   static foreach(op, d1;
     ["+" : "opAdd", "-" : "opSub", "*" : "opMul", "/" : "opDiv",
      "%" : "opMod"]) {
      static if(__traits(hasMember, typeof(this), d1))
         alias opBinary(string s : op) = mixin(d1);
   }
}

Let’s look at the new things we have added to the mixin template. The first thing is an associative array of string to string, indicating which ops should map to which D1 function names. static foreach is a feature which will, at compile time, loop over all the elements in a thing that normally you would iterate at runtime (in this case, the associative array). It’s as if you wrote all those things out directly one at a time, with the symbols op and d1 mapped to the keys and values of the associative array containing the operation mappings.

See how our static if has changed a bit, instead of using a string literal, we use the d1 symbol, which in the first loop is "opAdd", in the second loop is "opSub" and so on.

In addition, there is one minor change in the alias. Because we must alias the opBinary call to a symbol, and not a string, we must fetch the symbol based on its string name. mixin(d1) does this. This is a relatively new feature, in older compilers we could still achieve this with a single mixin statement for the whole alias statement, but just calling mixin on d1 is a lot cleaner looking.

With that, our final code looks like this:

mixin template D1Ops() {
   static foreach(op, d1;
     ["+" : "opAdd", "-" : "opSub", "*" : "opMul", "/" : "opDiv",
      "%" : "opMod"]) {
      static if(__traits(hasMember, typeof(this), d1))
         alias opBinary(string s : op) = mixin(d1);
   }
}

struct S {
   int x;
   S opAdd(S other) {
      return S(x + other.x);
   }
   S opSub(S other) {
      return S(x - other.x);
   }
   S opMul(S other) {
      return S(x * other.x);
   }
   S opDiv(S other) {
      assert(other.x != 0, "divide by zero!");
      return S(x / other.x);
   }

   mixin D1Ops;
}

void main() {
   S s1 = S(6);
   S s2 = S(3);
   assert( s1 + s2  == S( 9));
   assert( s1 - s2  == S( 3));
   assert( s1 * s2  == S(18));
   assert( s1 / s2  == S( 2));
}

You’ll notice that I intentionally included opMod in the mixin, even though our type does not have it. This demonstrates the power of the static if to only provide aliases if the appropriate D1 operator overload exists.

Filling it out

All that is left for opBinary is to fill out the mappings to handle any possible existing D1 binary operations. As long as you have a D1-style operator, the mixin will generate an alias to cover it.

And finally, any other D1 style operations as listed in the changelog, such as opUnary or opBinaryRight can also be covered by adding another loop. You could even nest the mappings if you wanted to, or include the name of the template to alias as part of the mapping. Or you might notice that all the opBinaryRight operators are the same as the opBinary operators (except in), and just do both at the same time.

You also might not using static foreach for this, and actually write them all out by hand, simply because static foreach is slightly expensive, and so is constructing an associative array at compile-time. Remember, once this template is done, there will never need to be any updates to it. The advantage of using a loop is you have to write a lot less code, which makes it a lot less error prone.

And if you aren’t in the mood to do it yourself, here is a gist mapping the entire suite of D1 operator overloads.