Duff’s Device in 2021
belaycpp.com
belaycpp.com
The biggest problem with this technique, from an optimization perspective, is that it creates an irreducible control-flow graph. In simple terms, this is a cycle in the graph that has two entry points from outside the cycle, and as a result, it is no longer considered a loop. This means you're going to disable any loop optimization--so Duff's Device is only worth it if you're going to write the optimal loop form already, and the compiler isn't going to arrive at that form.
For the latter part, optimization level matters: -Og and -O1 won't run loop optimizations, and -Os and -Oz might not. But if you're compiling with -Og or -O1, you don't actually care about performance in the first place. And with -Os or -Oz, the loop unrolling implied is of questionable utility (since you're increasing code size). If you really want it anyways... just add a pragma. All the major compilers support a loop unrolling pragma.
The benchmarks introduced to justify using duff's device are... bad. In the original use, the loop was basically doing this:
void duffs(volatile int *x, int *y, int N) {
for (int i = 0; i < N; i++)
*x = *y++;
}
Here, what's being done instead is: void bad_duffs(int *x, int y, int N) {
for (int i = 0; i < N; i++)
*x = y++;
}
That doesn't seem like much, but it makes the code optimizable to x = N (N + 1) / 2; if the compiler can work out that x cannot point to y or N, and that y starts out at 0.The last example makes it even worse by doing ++*x; instead, which means it's entirely down to if the compiler can compute N in the first place. But to justify the use, the N is made obtusely hard to compute, and the "Duff's Device" version doesn't even increment it all the times it's supposed to.
Today MMIO is very slow and so you just don't need unrolling. Or I guess from another perspective, MMIO isn't as much faster as absolutely everything else is now, and so again you don't need unrolling. Your CPU can do some arithmetic, make a conditional jump, and still schedule the next MMIO write in plenty of time. Therefore Duff's device is now irrelevant.
The ++*x is completely toxic in the context Tom Duff wrote this - where we want actual writes, because then it actually amounts to this:
int tmp = read_mmio();
tmp++;
write_mmio(tmp);
And immediately you should be filled with dread, who said we could read this MMIO register? What happen if the contents of the MMIO register change while we're twiddling tmp? This all seems like a very bad idea.I mean the lex/yacc syntax has to reflect the nestability of the while/switch [1], ditto if you do recursive descent, so how is it possible this even parses?
[1] original and weirder version here https://en.wikipedia.org/wiki/Duff%27s_device#Original_versi...
switch (x) default: {
if (y) case 1: case 2: return;
/* more stuff with more labels */
}The technique is considered obsolete with todays compilers specifically because they can do loop optimizations like this without writing strange C code.
OTOH you never know what those wacky compiler guys are going to do. For example, it seems GCC hasn't been doing any vectorization on x86-64 with standard -O2 even though the ISA has supported SSE2 as a minimum from day one. This is being fixed (to some extent) in the next release.
Edit: Yeah, looking at the godbolt link they provided, at least the execute_loop_basic function does essentially no work.
The question posed early in the blog was whether Duff's Device is still relevant in 2021. The fact that the compiler can completely elide the loop without it but cannot with it is a pretty good indicator that it's likely to cause more harm than good.
It might prevent inlining of a function call in the loop body, which would make a loop much, much slower to execute. It will make the loop body larger (even without inlining) and likely cause an increase in cache misses. It will be unpredictable based on build: an unrelated change somewhere else might misalign it with a cache line. Different CPUs might have different cache behaviours and perform differently.
The right view on this sort of thing hasn't changed for decades: don't optimize prematurely. Trust your compiler, check it with profiling, look first for algorithmic improvements because you can gain FAR more converting an O(n^2) algorithm to an O(nlogn) than you can by unrolling a loop. Duff's Device is obsolete in all but a vanishingly tiny number of edge cases.
The compiler removing the loop is due to a bad test setup in which the function in the loop does nothing and compiler can figure that out and skip the whole calculation. In a real scenario the function in the loop would (presumably) actually do some work so it could not be elided. Also, note that the compiler does this for BOTH the Duff's device version and the non Duff's device version.
Also, I'm neither attacking nor defending Duff's device. I'm just saying that we can't draw any useful conclusions from this article because of poor methodology.
For trivial loop, Both GCC and clang optimize away the entire BasicLoop, but clang failed to optimize away the entire DuffDevice, while GCC did. Thus, the only thing doing the work is clang's Duff Device.
But even then, both GCC and clang optimize the BasicLoop function itself into just single add operation (data += loop_size) while it actually loops on the duff device function (though as mentioned, these functions weren't called from the benchmark)
We have long known that in general compilers are better at optimization than humans so you should write readable code until a profiler shows otherwise. However there are cases where the compiler doesn't make an optimization. There are sometimes corner cases that the compiler can't figure out doesn't apply to you, so manually doing the optimization might work. These cases need to be re-evaluated with every CPU change, and every compiler upgrade though.
Nothing above says if duff's device applies to not though. Duff's device is very likely to hit a corner cases where the compiler isn't sure if it can safely apply optimizations so it won't. As such I wouldn't be surprised if it is still relevant. (though mostly on embedded systems where we don't have nearly as fast of CPUs as more common computers)
https://www.chiark.greenend.org.uk/~sgtatham/coroutines.html
which inspired: http://dunkels.com/adam/pt/
And that stuff is just neat!
switch (foo) {
if (0) {
case X:
/* X-specific code */
} else {
case Y:
/* Y-specific code */
}
/* common code for X and Y */
break;
}Do not recommend.
If I had my time again, I would do it with a explicit state machine that runs in the I2C interrupt handler, and a submission and response queue accessed by the main app loop (the equivalent of "userland" for non-embedded folks).
A few lines of otherwise easily decompile-able Java turned into hundreds of lines of dealing with "broken" control flow (if they didn't just crash outright).
It was sometimes pure joy telling it to emit the assembly language and looking at the code to work out what it was doing. Intel must have had, and still probably has, some awesomely clever people in the basement writing that stuff.
But still, if it's not hot spot, probably won't really change in real world situation. And if it is, you want to vectorize manually anyway.
This is really something you want your compiler to take care of. And that indeed is something icc excels at. You just don't have to care about code size, because that _will_ grow, big time.
And no, Intel's new LLVM-based icx compiler is not at the same level yet as the (now 'classic') icc compiler.
It also still seems better than clang/gcc at working out whether inlining is worth it or not.
But it's also still quite buggy (it used to be as well, the number of work-arounds/#ifdefs needed to get some cross-platform applications built used to be quite annoying. In some cases, we couldn't even use exactly the same version of the compiler per platform, we had to split versions per platform!).
https://pharr.org/matt/blog/2018/04/29/ispc-retrospective#th...
Presumably intel only optimized for intel. Wouldn't the best case scenario be if everyone copied the intel optimizations leaving other chips in the dust?
Using an other ISA than x86 is something that prevents this compiler from being used for other chips. The intermediate representation on which these optimizations are made is probably not related to x86.
Also, such compilers usually use models of machine resources and latencies to perform better scheduling. But latency and resource models are micro-architecture dependent, so using it on a chip with the same architecture but different micro-architecture would produce non-optimal scheduling
Yes, shoot portability in the foot so you can keep your code free of well defined language constructs.
I can count the number of coworkers I have that have experience with inline assembly on one hand (it is less than 1). Also the first reaction I usually get to vector intrinsics are questions about the wtfness of shuffle instructions, no you can't make that readable without sacrificing performance.
> Then just write it in the simplest way and hope autovectorization will help you.
Spoiler: It wont't. Compilers often don't have the context and some of the biggest hot spots I had to deal with simply used the single value versions of vector instructions.
Or rather: If that had worked I wouldn't be there hand optimizing the code.
Clang 12.0 -O3 1.2955e-1 1.2553e-4 –3.1%
seems wrong to me. Is there a mistake?Because clang turns it into `return data+loop_size;`.
In reality of course if you're considering Duff's device, your operations should be much heavier than any of the operations the author tested. And yes, those cases still exist, but people usually consider it cheaper to perpetually throw more compute resources at the problem.
Write your code generator in a developer-efficient, compiler-oriented language like Go. Use it to generate simple C99 that GCC can optimize to produce machine-efficient object code.
This is how you get truly great object code from GCC.
void execute_loop(int & data, const size_t loop_size) { for (int i = 0 ; i < loop_size/4 ; ++i) { computation(data); computation(data); computation(data); computation(data); } }
Shouldn't it be something like: void execute_loop(int & data[], const size_t loop_size) { for (int i = 0 ; i < loop_size/4 ; ++i) { computation(data[4i]); computation(data[4i+1]); computation(data[4i+2]); computation(data[4i+3]); } } ?
Something like that? There needs to be a dependency on the index for duff's device to work right?
for (int i = 0; i < loop_size; i++) {
some_work();
}
Whatever some_work may be you want to unroll the loop to avoid the jumps. It could be an operation like: c[i] = a[i] + b[i]
In which case you'd do like you suggest. But it could also just be some repeated operation on an entire data set, like in a simulation where you want to run loop_size iterations.For it to work "right" the work inside the loop has to be quite minor. The point being that it saves on cpu cycles by reducing the number of times it does i < loop_size, i++, and the number of times it has to branch back to the beginning of the loop. It's almost always some kind of memory copy, or a simple read, lookup, write in which the inside of the loop will complete in a few cpu cycles.
If the work inside the loop is 100x the cost of implementing the loop there is no point.
No UB. Static analysis will keep you from doing things like using values in local variables across yields (use lambda captures instead).
Just avoid doing two CO_YIELD() in the same line because this macro uses __LINE__ for the switch case.