Running D without its runtime
In this article, we'll disable the D programming language runtime. Expected audience: software developers interested in D. Reading time = 8 min.
Our products Panagement and Graillon now run with the D language runtime disabled. This post is both a post-mortem and tutorial on how to live without the D runtime.
1. What does the D language runtime do?
Upon entry into a normal D program, druntime
is initialized.
What does druntime
do?
- Running global constructors,
- Allocating space for thread-local variables (TLS),
- Enabling the Garbage Collector to function properly (GC).
A lot of the runtime machinery exists for the sole usage of the GC. druntime
maintains a list of registered threads, whose stack segments have to be scanned by the GC, in case they would hold pointers to managed memory blocks.
2. The D runtime is optional
As a system language, D is able to operate without its runtime. A program without druntime
will instead rely on the C runtime only. This doesn't come without effort.
Two different solutions here:
"Runtime-less": Not linking with the runtime. This has the benefit of turning every runtime use into a linking error. Some language features depend on data structures provided by the runtime source code. Hence the need to rewrite a minimal
druntime
. This is involved and more fit for writing an OS than for making consumer software."Runtime-free": Linking with the runtime, and then not enabling it.
Runtime.initialize()
is just not called. GC allocations, global constructors/destructors, and thread-local variables have to be avoided. And that's about it.
We went with "runtime-free" because it's easier.
3. Why did we disable druntime for audio plugins?
This was more of a logical next step than an absolute necessity.
Advantages:
Support unloading D shared libraries on macOS with existing D compilers. This was previously working with a hack, but that hack broke with macOS Sierra.
Avoids to register and unregister threads constantly. When called from an audio host, the only clean way is to register the incoming thread, and unregister it as it goes. "This causes some overhead", we thought.
Most of our code was already avoiding the GC and TLS,
We were expecting performance improvements.
Disadvantages:
Disabling the runtime includes (but is not limited to) disabling the GC,
Inability to use parts of the standard library,
Inability to use most of the library ecosystem.
So we set ourselves on a task to mainly remove all outstanding GC allocations.
Fortunately, D has an attribute called @nogc
.
4. Going fully @nogc
@nogc
ensures zero GC allocation in functions it annotates. @nogc
is essential to reach runtime freedom.
Step 1. Assuming @nogc
with casting
Ironically, one of the first thing we need is an escape-hatch from nothrow @nogc
(these attributes often travel together in practice, so let's group them).
/// Assumes a function to be nothrow and @nogc
auto assumeNothrowNoGC(T) (T t) if (isFunctionPointer!T || isDelegate!T)
{
enum attrs = functionAttributes!T
| FunctionAttribute.nogc
| FunctionAttribute.nothrow_;
return cast(SetFunctionAttributes!(T, functionLinkage!T, attrs)) t;
}
This breaks the type-system and allows to conveniently shoot oneself in the foot. So why do this?
The reason is: Object.~this()
, base destructor of every class object, is virtual and neither nothrow
nor @nogc
. Because of covariance, every class object's destructor is assumed to throw exceptions and use the GC by default.
So assumeNothrowNoGC!T
is an important building block to call carefully choosen class destructors in nothrow @nogc
contexts. Let's see how.
Step 2. An optimistic object.destroy()
-like
We can use our brand new foot-shooting device in the following way:
// For classes
void destroyNoGC(T)(T x) nothrow @nogc
if (is(T == class) || is(T == interface))
{
assumeNothrowNoGC(
(T x)
{
return destroy(x);
})(x);
}
// For structs
void destroyNoGC(T)(ref T obj) nothrow @nogc
if (is(T == struct))
{
assumeNothrowNoGC(
(ref T x)
{
return destroy(x);
})(obj);
}
For our purpose, object.destroy()
has the fatal flaw of not being nothrow @nogc
(because it may call Object.~this()
). assumeNothrowNoGC
makes object.destroy()
work for nothrow @nogc
code.
Step 3. Objects on the malloc
heap
Going forward with our trail of casts and unsafety, let's make a template function like C++'s new
:
/// Allocates and construct a class or struct object.
/// Returns: Newly allocated object.
auto mallocEmplace(T, Args...)(Args args)
{
static if (is(T == class))
immutable size_t allocSize = __traits(classInstanceSize, T);
else
immutable size_t allocSize = T.sizeof;
void* rawMemory = malloc(allocSize);
if (!rawMemory)
onOutOfMemoryErrorNoGC();
static if (is(T == class))
{
T obj = emplace!T(rawMemory[0 .. allocSize], args);
}
else
{
T* obj = cast(T*)rawMemory;
emplace!T(obj, args);
}
return obj;
}
Then a function like C++'s delete
:
/// Destroys and frees an object created with `mallocEmplace`.
void destroyFree(T)(T p) if (is(T == class) || is(T == interface))
{
if (p !is null)
{
static if (is(T == class))
{
destroyNoGC(p);
free(cast(void*)p);
}
else
{
// A bit different with interfaces,
// because they don't point to the object itself
void* here = cast(void*)(cast(Object)p);
destroyNoGC(p);
free(cast(void*)here);)
}
}
}
/// Destroys and frees a non-class object created with `mallocEmplace`.
void destroyFree(T)(T* p) if (!is(T == class) && !is(T == interface))
{
if (p !is null)
{
destroyNoGC(p);
free(cast(void*)p);
}
}
(Note: If the GC were enabled, one would maintain GC roots for the allocated memory chunk.)
It turns out this mallocEmplace
/ destroyFree
duo is an adequate replacement for class objects allocated with new
, including exceptions.
Step 4. Throwing exceptions despite @nogc
How do we throw and catch exceptions in @nogc
code?
One can construct an Exception
with manual memory management and throw
it:
// Instead of:
// throw new Exception("Message")
throw mallocEmplace!Exception("Message");
At the call site, such manual exceptions would have to be released when caught:
try
{
doSomethingThatMightThrow(userInputData);
return true;
}
catch(Exception e)
{
e.destroyFree(); // release e manually
return false;
}
Using exceptions in @nogc
code can be easy, provided both caller and callee agree on manual exceptions.
5. Results
Long story short, we brute-forced our way into having fully @nogc
programs, with the runtime left uninitialized.
As expected this fixed macOS Sierra compatibility. As a bonus Panagement and Graillon are now using 2x less memory, which is nice, but hardly life-changing.
We found no magical speed enhancement. Speed-wise nothing changed. Not registering threads in callbacks did not bring any meaningful gain. GC pauses were already never happening so disabling the GC did not help.
In conclusion, it is still our opinion that outside niche requirements, there isn't enough reasons to depart from the D runtime and its GC.