AtomicCounterBuffer (compute shaders, advanced)

I’ve been figuring out how to use atomic counters in compute shaders. You can read about them at https://wikis.khronos.org/opengl/Atomic_Counter.

In compute shaders, hundreds or thousands of GPU threads execute simultaneously. Without proper synchronization, multiple threads trying to modify the same memory location creates a race condition where the outcome depends on unpredictable thread execution order, leading to corrupted or incorrect results.

My main interest in them so far is accumulating statistics.

I’ll post below some example programs using such buffers.

ps. I’m aware I’m posting these without having explained anything about compute shaders or computeStyle. If someone asks I may write about it :slight_smile:

ps2. Thanks to @Alessandro for the help with the atomic counter buffers :slight_smile:

Visualizing an AtomicCounter

This program runs a compute shader for every pixel of an image. When processing each pixel, an atomic counter is incremented. The counter-value is visualized on every pixel using a unique color. This is not very useful, but demonstrates how to create and use an AtomicCounterBuffer and how to reset its value.

If you take a screenshot of the produced image, you can use photo-editing software to verify that every pixel has a unique color (in Gimp: use the Fuzzy Select tool, threshold 0, Sample merged).

See https://wikis.khronos.org/opengl/Atomic_Counter


import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.draw.*
import org.openrndr.extra.imageFit.imageFit
import org.openrndr.math.IntVector3
import kotlin.system.measureNanoTime

fun main() = application {
    program {
        val img = colorBuffer(1920, 1080, type = ColorType.FLOAT32)

        // The atomic counter incremented each time the compute shader processes one pixel
        val counter = AtomicCounterBuffer.create(1)

        val csInc = computeStyle {
            computeTransform = """
                ivec2 pixel = ivec2(gl_GlobalInvocationID.xy);
                
                // atomically increments the value of the counter and returns its prior value.
                uint m = atomicCounterIncrement(b_counter[0]);
                
                // Convert the current counter value into an rgb value
                float r = float(m % 256) / 256.0;
                float g = float((m / 256) % 256) / 256.0;
                float b = float((m / 65536) % 256) / 256.0;
                
                imageStore(p_img, pixel, vec4(r, g, b, 1.0));
            """.trimIndent()
        }
        // The default `workGroupSize` is (1, 1, 1). Here we choose a larger one for performance.
        // The width and height should be divisible by these values: 1920/32 = 60, 1080/24 = 45.
        // Larger values were not accepted in my AMD GPU because their product was larger than 1000.
        csInc.workGroupSize = IntVector3(32, 24, 1)

        extend {
            // Run the shader to give a unique color to each pixel in the image
            println(measureNanoTime {
                csInc.buffer("counter", counter)
                csInc.image("img", img.imageBinding(0, ImageAccess.WRITE))
                csInc.execute(img.width / csInc.workGroupSize.x, img.height / csInc.workGroupSize.y)
            } / 1_000_000.0)

            // Draw the texture generated by the compute shader
            drawer.clear(ColorRGBa.PINK)
            drawer.imageFit(img, drawer.bounds)

            // Reset the counter before the next animation frame
            counter.reset()
        }
    }
}

The program prints the compute shader execution times in milliseconds. With my laptop the values look like these:

0.126686
0.185455
0.252961
0.264633
0.405766
0.205853
0.170187
0.345844
0.244926
0.153526
0.187509
0.206926
0.31114
0.204381

I haven’t done much testing, but since we have 16.66 milliseconds to render our frame to achieve 60 FPS, those values look acceptable.

I did try in Gimp and the color of each pixel seemed to be unique. I could have written more code to verify that… maybe another day :slight_smile:

Normalize the brightness of an image

(using an atomicCounterMax)

This program is potentially more useful than the first one above.

It uses an atomic counter buffer to find the brightness of the brightest pixel on an image to do automatic brightness normalization. We could then use that value in a custom shader, or as in this example, use a tint effect to adjust the luminosity of the image.

The image shows an original image above, and with the brightness of its pixels stretched to cover the full luminosity spectrum below. This could be useful in situations where our program produces algorithmic visuals with unpredictable luminosity.

See https://wikis.khronos.org/opengl/Atomic_Counter

import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.draw.*
import org.openrndr.drawImage
import org.openrndr.extensions.Screenshots
import org.openrndr.extra.color.colormatrix.tint
import org.openrndr.extra.imageFit.imageFit
import org.openrndr.extra.noise.gaussian
import org.openrndr.math.IntVector3
import kotlin.system.measureNanoTime

fun main() = application {
    configure {
        width = 640
        height = 480 * 2
    }
    program {
        // Sample, dark image
        val img = drawImage(1920, 1080, type = ColorType.FLOAT32) {
            clear(ColorRGBa.BLACK)
            stroke = null
            fill = ColorRGBa.WHITE.opacify(0.01)
            repeat(25) {
                circle(
                    Double.gaussian(bounds.width * 0.5, bounds.width * 0.1),
                    Double.gaussian(bounds.height * 0.5, bounds.height * 0.1),
                    50.0
                )
            }
        }

        // The atomic counter used to compare all pixels against
        val counter = AtomicCounterBuffer.create(1)

        val csMax = computeStyle {
            computeTransform = """
                ivec2 pixel = ivec2(gl_GlobalInvocationID.xy);
                vec3 rgb = imageLoad(p_img, pixel).rgb;
                // We multiply by 1000 because we work with uint. Use a higher number for more resolution.
                uint lum = uint(dot(rgb, vec3(0.299, 0.587, 0.114)) * 1000.0);
                
                // if lum is greater that the atomic counter, make the counter equal to lum
                atomicCounterMax(b_counter[0], lum);                
            """.trimIndent()
        }
        csMax.workGroupSize = IntVector3(32, 24, 1)

        extend(Screenshots())
        extend {
            drawer.clear(ColorRGBa.PINK)
            // Draw the original image above
            drawer.imageFit(img, drawer.bounds.sub(0.0, 0.0, 1.0, 0.5))

            // Execute the compute shader to find out the brightest pixel and print how long it took in milliseconds
            println(measureNanoTime {
                csMax.buffer("counter", counter)
                csMax.image("img", img.imageBinding(0, ImageAccess.READ))
                csMax.execute(img.width / csMax.workGroupSize.x, img.height / csMax.workGroupSize.y)
            } / 1_000_000.0)

            // Calculate a color matrix to normalize the image rendering
            val maxLum = counter.read()[0] / 1000.0
            val l = 1.0 / maxLum
            val colorMat = tint(ColorRGBa(l, l, l))

            // Draw the normalized image below
            drawer.drawStyle.colorMatrix = colorMat
            drawer.imageFit(img, drawer.bounds.sub(0.0, 0.5, 1.0, 1.0))

            // Reset the counter before the next animation frame
            counter.reset()
        }
    }
}

After writing the above posts making use of atomic counters, I was adviced by Edwin to use atomic operations on images instead of counters, as counters are an older technology that is not so widely used.

I gave this approach a try and discovered and fixed a bug that made it impossible to use them, then added a test to make sure the bug doesn’t come back :slight_smile:

I’ll share below two programs that require this fix. That means either building openrndr from source, or waiting for v0.5.0-alpha5 or v0.5.0.

imageAtomicAdd

Here the first program is to demonstrate the use of atomic image operations.
The program does the following:

  • Creates a 1x1 pixel unsigned texture with one channel.
  • Creates a compute shader that increments the value in that texture by 1.
  • Binds the texture to the shader in read/write mode.
  • Runs the shader 16 million times
  • Creates a buffer to download the result of the calculation, and downloads it and prints the result.
import org.openrndr.application
import org.openrndr.draw.*
import org.openrndr.draw.font.BufferAccess
import java.nio.ByteBuffer
import java.nio.ByteOrder

fun main() = application {
    program {
        val counter = colorBuffer(1, 1, format = ColorFormat.R, type = ColorType.UINT32_INT)
        val cs = computeStyle { computeTransform = "imageAtomicAdd(p_c, ivec2(0), 1);" }
        cs.image("c", counter.imageBinding(0, BufferAccess.READ_WRITE))
        cs.execute(4000, 4000)

        val buffer = ByteBuffer.allocateDirect(counter.width * counter.height * counter.format.componentCount * counter.type.componentSize)
        counter.read(buffer)

        buffer.order(ByteOrder.LITTLE_ENDIAN)
        println(buffer.int) // 16000000
    }
}

The arguments in imageAtomicAdd are: the target texture to update, which pixel on that texture to increment, and by how much to increment the value of the pixel.

execute()

The arguments of the execute() call indicate how many iterations we want to run. In our example we specify 4000 x 4000 iterations, representing rows and columns. So basically we are running the shader 16 million times, increasing the brightness of the top left pixel of another image.

execute() accepts between 1 and 3 arguments. Arguments not specified have a default value of 1. Doing something like cs.execute(1_000_000) might be useful when processing 1 million moving particles. If working with a 3D data structure, we might want to do cs.execute(10, 10, 10), triggering 1000 iterations.

The reason for doing this on a compute shader is that a lot of those iterations happen in parallel, which is much faster than with a loop. Never mind that this example only provides a convoluted way of incrementing a number :slight_smile:

1 Like

Normalize the brightness of an image

(using a imageAtomicMax)

Here’s an updated of the program above. To better simulate what I had in mind, instead of a static image it renders an animation of moving and sometimes overlapping circles. The circles slowly grow and shrink, because I wanted that sometimes there’s no overlap between them.
When that happens, all circles have the same brightness, and they get normalized to white.
When several circles overlap, the overlapping part is the brightest of the image, and that part turns white thanks to the program.

The program creates two render targets: one where we draw our animated graphics, and another to hold the brightest pixel. This one doesn’t need to be a render target, it could be a color buffer, but the render target makes it easy to clear. ColorBuffer does have a fill method, but it didn’t work for me with a UINT32_INT texture.

There are also two compute shaders. One is used to find the maximum brightness of the source image. The second compute shader is used to apply the normalization to the source image (instead of downloading the luminosity value to the CPU and using tint, as the original program above did).

Unrelated to the main concept: times acts a circular array to average the execution times printed to the console. Without doing this the values were jumping up and down, and I couldn’t figure out if this new approach was more or less efficient than using atomic counters. Now I know that the execution time went down for me, from 0.11 ms to 0.07 ms (give or take).

import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.draw.*
import org.openrndr.draw.font.BufferAccess
import org.openrndr.extensions.Screenshots
import org.openrndr.extra.imageFit.imageFit
import org.openrndr.extra.noise.simplex
import org.openrndr.math.IntVector3
import kotlin.math.sin
import kotlin.system.measureNanoTime

/**
 * Real time normalization of brightness levels of an image using
 * `imageAtomicMax`. See `atomicCompute03.kt` for an older approach.
 */
fun main() = application {
    configure {
        width = 640
        height = 480 * 2
    }
    program {
        // A render target holding the brightest pixel and a shortcut to its colorBuffer
        val pixelRT = renderTarget(1, 1) {
            colorBuffer(format = ColorFormat.R, type = ColorType.UINT32_INT)
        }
        val pixelCB = pixelRT.colorBuffer(0)

        // A render target with our real-time visuals and a shortcut to its colorBuffer
        val canvasRT = renderTarget(1920, 1080) {
            colorBuffer(type = ColorType.FLOAT32)
            depthBuffer()
        }
        val canvasCB = canvasRT.colorBuffer(0)

        // The compute shader to update the buffer holding the brightest pixel
        val csMax = computeStyle {
            computeTransform = """
                ivec2 pixel = ivec2(gl_GlobalInvocationID.xy);
                vec3 rgb = imageLoad(p_img, pixel).rgb;
                // We multiply by 1000 because we work with uint, not floats.
                uint lum = uint(dot(rgb, vec3(0.299, 0.587, 0.114)) * 1000.0);
                
                // if lum > counter then counter = lum
                imageAtomicMax(p_counter, ivec2(0), lum);
            """.trimIndent()
        }
        csMax.workGroupSize = IntVector3(32, 24, 1)

        // The compute shader to normalize the luminosity based on brightest pixel found
        val csAdjust = computeStyle {
            computeTransform = """
                ivec2 pixel = ivec2(gl_GlobalInvocationID.xy);
                vec4 rgba = imageLoad(p_img, pixel);
                float lum = imageLoad(p_counter, ivec2(0)).r / 1000.0;
                imageStore(p_img, pixel, rgba / lum);    
            """.trimIndent()
        }
        csAdjust.workGroupSize = IntVector3(32, 24, 1)

        val times = mutableListOf<Double>()

        extend(Screenshots())
        extend {
            // Update our real-time visuals
            drawer.isolatedWithTarget(canvasRT) {
                ortho(canvasRT)
                clear(ColorRGBa.BLACK)
                stroke = null
                fill = ColorRGBa.WHITE.opacify(0.01)
                repeat(25) {
                    circle(
                        simplex(it, seconds * 0.1, it * 0.1) * bounds.width * 0.5 + bounds.width * 0.5,
                        simplex(-it, seconds * 0.1, it * 0.1) * bounds.height * 0.5 + bounds.height * 0.5,
                        sin(seconds * 0.1) * 30.0 + 50.0
                    )
                }
            }

            // Draw the non-adjusted image above
            drawer.clear(ColorRGBa.PINK)
            drawer.imageFit(canvasCB, drawer.bounds.sub(0.0, 0.0, 1.0, 0.5))

            // Execute a compute shader to find the brightest pixel and store how long it took in milliseconds
            val t = measureNanoTime {
                csMax.image("img", canvasCB.imageBinding(0, ImageAccess.READ))
                csMax.image("counter", pixelCB.imageBinding(0, BufferAccess.READ_WRITE))
                csMax.execute(canvasCB.width / csMax.workGroupSize.x, canvasCB.height / csMax.workGroupSize.y)
            }

            // Print the average execution time of the last 10 runs in milliseconds: eventually 0.07 ms on AMD GPU
            times.add(0, t / 1_000_000.0)
            times.dropLast((times.size - 10).coerceAtLeast(0))
            println(String.format("%.2f ms", times.average()))

            // Execute a compute shader to normalize the pixel luminosities based on the found value
            csAdjust.image("img", canvasCB.imageBinding(0, ImageAccess.READ_WRITE))
            csAdjust.image("counter", pixelCB.imageBinding(0, BufferAccess.READ))
            csAdjust.execute(canvasCB.width / csMax.workGroupSize.x, canvasCB.height / csMax.workGroupSize.y)

            // Draw the adjusted image below
            drawer.imageFit(canvasCB, drawer.bounds.sub(0.0, 0.5, 1.0, 1.0))

            // Reset the counter before the next animation frame
            drawer.isolatedWithTarget(pixelRT) {
                ortho(pixelRT)
                clear(ColorRGBa.TRANSPARENT)
            }
        }
    }
}

This program only demonstrates the concept, but it’s definitely not perfect. There are some issues with it:

  • A single bright pixel can alter the whole look. We might prefer to allow some white pixels in the final image. For performance, we could also scale down the texture before searching for the bright pixel: we probably don’t need to process every single pixel in the source image.
  • There’s no interpolation in the brightness changes, which is unpleasant. It would be better if we were setting the target brightness and the system interpolated towards it.
  • It might be nice to apply a luminosity curve after the normalization, to add or reduce contrast.