Have Your Efficiency, and Flexibility Too

Metaprogramming Techniques For No-Compromise Code

by Nick Sabalausky

Full source code for this article is available on GitHub, or can be downloaded here.

View this article on a Single Page or Multiple Pages

Table of Contents:

  1. Have Your Efficiency, and Flexibility Too
  2. First Attempt: Send Efficiency and Flexibility to Dr. Oop's Couples Therapy
  3. Respecting the Classics: Old-School Handcrafting
  4. Success at Dr. Metaprogramming's Clinic
  5. It Walks Like a Duck and Quacks Like a Duck...Kill It!
  6. Metaprogramming Plus: The Flexibility Enhancements
  7. The Last Remaining Elephant In The Room: Runtime Conversion
  8. Curtain Call

The Last Remaining Elephant In The Room: Runtime Conversion

Don't worry, I'm not really going to introduce an elephant into the story...

We've already seen various ways of configuring a compile-time option at runtime. But that was limited to creating a Gizmo. Once created, a Gizmo was stuck with those settings. So what if the number of ports and spinnablity of an existing Gizmo needs to change? If only a few Gizmos need to be able to change, we can just make those few out of the DynamicGizmo from earlier with only trivial modifications. But suppose all the Gizmos need to be changeable: Now how do we change the settings of an existing Gizmo without giving up the metaprogramming benefits?

In our original example, it would have been trivial to modify the Gizmo code so you could change a Gizmo's number of ports and spinnablity after it was created. They were already runtime values. But our metaprogramming examples have taken advantage of the fact that such values were fixed when a Gizmo is created. In fact, that's the fundamental key of the metaprogramming versions: "Turn your runtime options into compile-time options."

If one of these settings needs to change frequently during a program's run, then naturally it must be a runtime option. Turning it into a compile-time value won't improve its efficiency. That's because the "runtime to compile-time" trick works by taking advantage of the fact that certain values don't really need to change. So unfortunately, if a setting needs to change frequently, it must remain a runtime value. The only way to optimize it out is to eliminate the feature entirely.

However, if the settings only need to change occasionally, then we're still in business.

"What? Have you completely lost it? Configuring a compile-time option at runtime was crazy enough. And now you intend to change a compile-time option at runtime?! They're immutable!"

It not so strange, really. In fact, functional programming experts probably already know where I'm going with this...

Listen carefully: We converted the runtime values to compile-time ones by encoding them as part of the type, right? So to change the compile-time value, all we have to do is convert the variable to a different type. Just like converting an integer to a string. Easy. Except this is even easier because the types we're converting are fundamentally very similar.

Naturally, this does take extra processing. Likely much more than just simply changing an ordinary runtime variable. That's why I made a big deal about how frequently you need the value to change. If it changes a lot, you'll just slow things down from all the converting. But as long as it doesn't change enough to use up the efficiency savings from metaprogramming, you'll still end up ahead.

I'll demonstrate this by shuffling around a few Gizmos on every iteration. You'll notice that I'm always keeping the same number of each type of Gizmo, but that's not a real limitation: That's only so we can still compare benchmarks. It would be trivial to actually change a Gizmo without keeping everything balanced. All you'd have to do is remove the old Gizmo from the array for the old type, and append the newly converted one to the array for the new type. Of course, if you were to do this, you'd probably want to use a linked list or a stack instead of an array, since arrays have poor performance with such a usage pattern.

For simplicity, I won't use any of the "Flexibility Enhancements" covered in the previous section. But the techniques can certainly still be combined.

I've highlighted the changes from the original metaprogramming version, ex4_metaprogramming.d:

From ex7_meta_runtimeConversion.d:

struct Gizmo(int _numPorts, bool _isSpinnable) { // So other generic code can determine the // number of ports and spinnability: static immutable numPorts = _numPorts; static immutable isSpinnable = _isSpinnable; static if(numPorts < 1) static assert(false, "A portless Gizmo is useless!"); private OutputPort[numPorts] ports; void doStuff() { static if(numPorts == 1) ports[0].zap(); else static if(numPorts == 2) { ports[0].zap(); ports[1].zap(); } else { foreach(port; ports) port.zap(); } } static if(isSpinnable) int spinCount; void spin() { static if(isSpinnable) spinCount++; // Spinning! Wheeee! }
TOut convertTo(TOut)()
{
TOut newGizmo;
static if(isSpinnable && TOut.isSpinnable)
newGizmo.spinCount = this.spinCount;
int portsToCopy = min(newGizmo.numPorts, this.numPorts);
newGizmo.ports[0..portsToCopy] = this.ports[0..portsToCopy];
// If there were any other data, we'd copy it to the newGizmo here
return newGizmo;
}
} struct OutputPort { int numZaps; void zap() { numZaps++; } } struct UltraGiz { template gizmos(int numPorts, bool isSpinnable) { Gizmo!(numPorts, isSpinnable)[] gizmos; } int numTimesUsedSpinny; int numTimesUsedTwoPort; void useGizmo(T)(ref T gizmo) { gizmo.doStuff(); gizmo.spin(); if(gizmo.isSpinnable) numTimesUsedSpinny++; if(gizmo.numPorts == 2) numTimesUsedTwoPort++; } void run() { StopWatch stopWatch; stopWatch.start(); // Create gizmos gizmos!(1, false).length = 10_000; gizmos!(1, true ).length = 10_000; gizmos!(2, false).length = 10_000; gizmos!(2, true ).length = 10_000; gizmos!(5, false).length = 5_000; gizmos!(5, true ).length = 5_000; // Use gizmos foreach(i; 0..10_000) {
// Modify some gimos:
// Save a 1-port non-spinny
auto old1PortNoSpin = gizmos!(1, false)[i];
// Convert a 2-port spinny to 1-port non-spinny
gizmos!(1, false)[i] =
gizmos!(2, true)[i].convertTo!( Gizmo!(1, false) )();
// Convert a 5-port spinny to 2-port spinny
gizmos!(2, true)[i] =
gizmos!(5, true)[i%2].convertTo!( Gizmo!(2, true) )();
// Convert the old 1-port non-spinny to 5-port spinny
gizmos!(5, true)[i%2] =
old1PortNoSpin.convertTo!( Gizmo!(5, true) )();
// Use gizmos as usual
foreach(ref gizmo; gizmos!(1, false)) useGizmo(gizmo); foreach(ref gizmo; gizmos!(1, true )) useGizmo(gizmo); foreach(ref gizmo; gizmos!(2, false)) useGizmo(gizmo); foreach(ref gizmo; gizmos!(2, true )) useGizmo(gizmo); foreach(ref gizmo; gizmos!(5, false)) useGizmo(gizmo); foreach(ref gizmo; gizmos!(5, true )) useGizmo(gizmo); } writeln(stopWatch.peek.msecs, "ms"); assert(numTimesUsedSpinny == 25_000 * 10_000); assert(numTimesUsedTwoPort == 20_000 * 10_000); } }

Conversion Downsides And Possible Fixes

There are a couple downsides to the runtime conversions above. But, they can be dealt with.

Hold It, Gizmo! Quit Squirming Around!

One issue with converting a Gizmo is that doing so puts it in a different memory location. This means that if anything was keeping a reference to the Gizmo, after conversion the reference is no longer pointing to the newly converted Gizmo. It's still referring to the old location.

This may not always be an issue. But if it does matter, you can solve it by using a tagged union. Something like this:

From example_anyGizmo.d:

import std.stdio; struct Gizmo(int _numPorts, bool _isSpinnable) { // So other generic code can determine the // number of ports and spinnability: static immutable numPorts = _numPorts; static immutable isSpinnable = _isSpinnable; // The rest here... } struct AnyGizmo { TypeInfo currentType; GizmoUnion gizmoUnion; union GizmoUnion { Gizmo!(1, false) gizmo1NS; Gizmo!(1, true ) gizmo1S; Gizmo!(2, false) gizmo2NS; Gizmo!(2, true ) gizmo2S; Gizmo!(5, false) gizmo5NS; Gizmo!(5, true ) gizmo5S; } void set(T)(T value) { static if(is(T==Gizmo!(1, false))) gizmoUnion.gizmo1NS = value; else static if(is(T==Gizmo!(1, true))) gizmoUnion.gizmo1S = value; else static if(is(T==Gizmo!(2, false))) gizmoUnion.gizmo2NS = value; else static if(is(T==Gizmo!(2, true))) gizmoUnion.gizmo2S = value; else static if(is(T==Gizmo!(5, false))) gizmoUnion.gizmo5NS = value; else static if(is(T==Gizmo!(5, true))) gizmoUnion.gizmo5S = value; currentType = typeid(T); } } void useGizmo(T)(T gizmo) { writeln("Using a gizmo:"); writeln(" ports=", gizmo.numPorts); writeln(" isSpinnable=", gizmo.isSpinnable); } void main() { AnyGizmo anyGizmo; anyGizmo.set( Gizmo!(2, true)() ); if( anyGizmo.currentType == typeid(Gizmo!(1, false)) ) useGizmo( anyGizmo.gizmoUnion.gizmo1NS ); else if( anyGizmo.currentType == typeid(Gizmo!(1, true)) ) useGizmo( anyGizmo.gizmoUnion.gizmo1S ); else if( anyGizmo.currentType == typeid(Gizmo!(2, false)) ) useGizmo( anyGizmo.gizmoUnion.gizmo2NS ); else if( anyGizmo.currentType == typeid(Gizmo!(2, true)) ) useGizmo( anyGizmo.gizmoUnion.gizmo2S ); else if( anyGizmo.currentType == typeid(Gizmo!(5, false)) ) useGizmo( anyGizmo.gizmoUnion.gizmo5NS ); else if( anyGizmo.currentType == typeid(Gizmo!(5, true)) ) useGizmo( anyGizmo.gizmoUnion.gizmo5S ); else throw new Exception("Unexpected type"); }

As you see in main() above, before you use an AnyGizmo, you first check the type via currentType and then use the appropriate member of gizmoUnion. This does have a few gotchas, though:

First, you have the runtime cost of checking the type before using the Gizmo. But this may be minimal, since you won't usually need to check the type on every single member access, only when it might have changed.

Second, since a union must be the size of its largest member, you won't get as much space savings when using an AnyGizmo. But, if the mere existence of one of your optional features requires extra time-consuming processing, you can at least save time because any Gizmos that forgo that feature won't incur the extra cost. Plus, this issue will only affect an AnyGizmo, not any other Gizmos you may have in use.

The final gotcha is that this makes converting a Gizmo more costly since the newly converted Gizmo needs to be copied back to the memory location of the original Gizmo. But if the Gizmo only needs to be converted occasionally, this shouldn't be a problem. If it is a problem, though, it may be possible to do the conversion in-place in memory.

Hey Data, Get Back Here!

The other main issue with converting Gizmos is that some conversions can loose data. Suppose we convert a spinny Gizmo to a non-spinny and back again. The non-spinny Gizmos don't have a spinCount, so our newly re-spinified Gizmo has lost its original spinCount data. It's back at zero again. Same thing with the ports: Convert a 5-port to a 2-port and back again and you've lost the numZaps from the last three ports.

Sometimes that might matter, sometimes it might not. Or it might matter for only some pieces of data. In any case, there's an easy fix: just make sure every Gizmo type has the data you don't want to loose. Even if a certain type of Gizmo doesn't actually use that data, let the Gizmo hold on to that data anyway.

If you need to hold on to all the data, then as with the AnyGizmo above, each Gizmo type will take up as much space as the largest Gizmo. In fact, in such a case, using the AnyGizmo above may be a good idea. Except this time, the performance cost of doing conversions can be almost completely eliminated.

How? Suppose every type of Gizmo does need to hold all the possible Gizmo data, and you use AnyGizmo. In that case, if you arrange all the data members of all the Gizmo types so everything is always laid out in memory the same way, then to convert the AnyGizmo from one type to another, all you need to do is change the currentType member. That's it. Everything else will already be in the right place.

At this point, it might sound like we've merely re-invented the DynamicGizmo. While it is similar, there are two important differences:

First, this method allows us to have completely different functions for each Gizmo type. Each type can have a different set of functions, and each function can be specially-tailored to the features that Gizmo is supposed to support. So the Gizmos don't have to spend any extra processing time dealing with being flexible (for instance, by checking their own isSpinnable variables to see whether or not to actually spin). Essentially, we have a form of polymorphism. In fact, if a DynamicGizmo isn't included in the AnyGizmo, then there can still be a space savings over DynamicGizmo because variables like isSpinnable still won't need to exist at runtime at all.

Second, if we want to change multiple settings on a Gizmo, we only need to actually modify one value: the value of currentType.

Next: Curtain Call

Table of Contents:

  1. Have Your Efficiency, and Flexibility Too
  2. First Attempt: Send Efficiency and Flexibility to Dr. Oop's Couples Therapy
  3. Respecting the Classics: Old-School Handcrafting
  4. Success at Dr. Metaprogramming's Clinic
  5. It Walks Like a Duck and Quacks Like a Duck...Kill It!
  6. Metaprogramming Plus: The Flexibility Enhancements
  7. The Last Remaining Elephant In The Room: Runtime Conversion
  8. Curtain Call