GPU folks: we need to talk about control flow

Testing IMG PowerVR graphics drivers through control flow transformations

[Part of a series of stories on GPU shader compiler bugs.]

[Previous stop: ARM]

In our post about Apple we tested their drivers for PowerVR GPUs from Imagination Technologies, which are included in most iPhones.

We’ve since been testing Imagination’s drivers directly, via a Nexus Player and an Azus ZenPad.

Our latest toys, featuring PowerVR GPUs from Imagination Technologies

See details of all the bugs we’ve found, for all GPU designers so far, on GitHub.

Unreachable control flow rears its ugly head again

Pretty similar to this bug, which affects a number of iOS devices, we found that adding unreachable return statements causes the Nexus player to render garbage when it otherwise renders a nice image:

Rendering garbage using a Nexus player with a PowerVR GPU

If you’ve got a Nexus player, or a (non-iOS) device with a PowerVR GPU, perhaps you’ll be able to reproduce the problem — try it out here via WebGL.

This is the original fragment shader, and it renders this image on the Nexus:

Image rendered for original fragment shader

GLFuzz produces a variant fragment shader that has two semantics-preserving additions. A return statement is added to function RenderScene, which has return type vec3:

if(injectionSwitch.x > injectionSwitch.y) {
return vec3(1.0);
}

and a similar return statement is added to main, which has return type void:

if(injectionSwitch.x > injectionSwitch.y) {
return;
}

Because injectionSwitch is set to (0.0, 1.0), these (as usual) should have no effect. Here is a still of the garbage that we see rendered:

Garbage rendered after adding unreachable return statements

We reported this bug to Imagination, who responded really quickly to confirm that they could reproduce the bug on their Nexus player with OEM drivers, but that with their latest drivers the bug appears to have been independently fixed.

Unreachable break and continue cause image differences

We found that, for another shader, we could induce a significant change in the rendered image by adding two unreachable code blocks, featuring break and continue statements. First:

if(injectionSwitch.x > injectionSwitch.y) { 
for(int i = 0; i < 10; i++) {
continue;
}
}

And second (injected inside an existing loop):

if(injectionSwitch.x > injectionSwitch.y) {
if(p.z > 60.) {
break;
}
}

These injections, applied to this original shader, changed the rendered image from this (for the original shader):

to this (for the shader with injections):

Here is the variant shader with the injections applied.

As before, the injections should have no impact on what is rendered due to the runtime values provided for injectionSwitch.

If you have a suitable device, maybe you’ll be able to reproduce the bug via WebGL.

We also reported this to Imagination and will update this story with their response when it comes in.

UPDATE: Imagination confirmed that they were able to reproduce this on their Nexus Player running the latest driver, and have forwarded the issue on to their driver team.

Spin me round once and you’ll fix me

In our post about ARM, we showed an example where making a semantics-preserving transformation actually served as a workaround for a shader compiler bug — here’s the relevant GitHub issue.

We’ve found a similar issue on the Nexus. This shader leads to this image being rendered:

An image rendered for an original shader on our Nexus player, which does not match what we see on other platforms

Though the image looks kind of attractive (again, bugs can be beautiful!), it doesn’t match what we see on other platforms.

GLFuzz finds that wrapping this existing code:

for(int i = 0; i < 6; ++i) {
float mag = dot(p, p);
p = abs(p) / mag + vec3(- .5, - .8 + 0.1 * sin(time * 0.2 + 2.0), - 1.1 + 0.3 * cos(time * 0.15));
float w = exp(- float(i) / 7.);
accum += w * exp(- strength * pow(abs(mag - prev), 2.3));
tw += w;
prev = mag;
}

in a single-iteration loop:

for(int k = 1; k != 0; k--) {
for(int i = 0; i < 6; ++i) {
float mag = dot(p, p);
p = abs(p) / mag + vec3(- .5, - .8 + 0.1 * sin(time * 0.2 + 2.0), - 1.1 + 0.3 * cos(time * 0.15));
float w = exp(- float(i) / 7.);
accum += w * exp(- strength * pow(abs(mag - prev), 2.3));
tw += w;
prev = mag;
}
}

gives the following image, which matches what we see on other platforms:

A loop-wrapping transformation causes this image to be rendered, which matches what we see on other platforms

So our theory is that the transformation has (inadvertently) served as a workaround for a shader compiler bug.

Check out our GitHub issue here.

We’ve reported the issue to Imagination, and will write more once they have a chance to respond.

UPDATE: Imagination could not reproduce this issue with their latest driver, so it seems that the issue may have been independently fixed.

What’s up with control flow?

So far, we’re finding that injections that change control flow — by adding jump statements or wrapping code in additional, single-iteration loops, seems to be particularly effective in exposing shader compiler bugs across multiple GPU drivers. Perhaps this is because strange control flow is not all that common in shader programs, so that compiler transformations are under-tested in this context.

Next stop: Intel.