I'm currently learning compute shaders and I'm trying to write an optimized Game Of Life. I have a first version working that uses a Shader Storage Buffer Object. I dispatch a thread per cell I want to update and that thread samples the SSBO 8 times to gather the cell's neighbors. This works fine.
I'm now trying to optimize this by using shared memory. Every thread in a work group will now load a single cell in shared memory wait for the memory and execution barrier to resolve and then sample the shared memory 8 times to compute its cell's state. Of course some threads need to load more than 1 cell to shared memory because if they're on the 'border' of a workgroup no thread will load the data for some of its neighbors. ( See picture )
I got this working as well. However, it is less performant than the brute force version. ( On an RTX 2070 and GTX 660M ) I'm very surprised. I'm using Vulkan and gpu queries to estimate how long the compute pass takes. Here is for example the numbers for a 4096x4096 grid. I dispatch 256x256 thread groups of size 16x16. On my old GTX 660M compute takes ~12ms in brute force mode but it takes ~17ms with TGSM. I can't test on the RTX right now but the TGSM version was less performant as well.
I was definitely expecting to see some improvement with TGSM. I need to look into it more with tools like Nvidia Nsight but I would assume this shader is limited by memory and definitely not ALU. I guess my questions are:
- Am I correct in assuming the TGSM version should run faster somehow? Search for Neighborhood Processing here
- If my assumption is correct, what went wrong?
Here is the brute force shader:
#version 450
layout (constant_id = 0) const uint CELLS_COUNT = 4096;
layout (constant_id = 1) const uint GRID_SIZE = 64;
layout (constant_id = 2) const uint THREADS_PER_GROUP_X = 8;
layout (constant_id = 3) const uint THREADS_PER_GROUP_Y = 8;
layout (constant_id = 4) const uint THREADS_PER_GROUP = 64;
layout(local_size_x_id = 2, local_size_y_id = 3) in;
layout(std430, set = 0, binding = 0) buffer SrcGrid {
uint state[CELLS_COUNT];
} srcGrid;
layout(std430, set = 0, binding = 1) buffer DstGrid {
uint state[CELLS_COUNT];
} dstGrid;
const ivec2 sampleXYOffsets[] = {
ivec2(-1, -1), ivec2(0, -1), ivec2(1, -1),
ivec2(-1, 0), ivec2(1, 0),
ivec2(-1, 1), ivec2(0, 1), ivec2(1, 1),
};
void main() {
const uint maxIdX = gl_WorkGroupSize.x * gl_NumWorkGroups.x;
const uint maxIdY = gl_WorkGroupSize.y * gl_NumWorkGroups.y;
uint aliveNeighbors = 0;
// Convert dispatch IDs into storage buffer id
const uint currentCellIndex = gl_GlobalInvocationID.y * maxIdX + gl_GlobalInvocationID.x;
// Convert storage buffer id into grid coordinates (x, y)
const uvec2 currentCoords = uvec2( currentCellIndex % GRID_SIZE, currentCellIndex / GRID_SIZE);
for( uint i = 0; i < 8; i++ ){
uvec2 coords = currentCoords;
// Bring everything above 0 to be able to use the modulo operator
coords = (coords + sampleXYOffsets[i] + GRID_SIZE) % GRID_SIZE;
// Convert grid coordinates (x, y) into storage buffer id
uint neighborIndex = coords.x + coords.y * GRID_SIZE;
aliveNeighbors += srcGrid.state[neighborIndex];
}
uint currentCellState = srcGrid.state[currentCellIndex];
if( currentCellState < 1.0 && aliveNeighbors == 3 )
{
// Dead cell comes back to life
dstGrid.state[currentCellIndex] = 1;
return;
}
// Alive cell dies
if( aliveNeighbors < 2.0 || aliveNeighbors > 3.0)
{
dstGrid.state[currentCellIndex] = 0;
return;
}
dstGrid.state[currentCellIndex] = currentCellState;
}
And the one using TGSM:
#version 450
layout (constant_id = 0) const uint CELLS_COUNT = 4096;
layout (constant_id = 1) const uint GRID_SIZE = 64;
layout (constant_id = 2) const uint THREADS_PER_GROUP_X = 8;
layout (constant_id = 3) const uint THREADS_PER_GROUP_Y = 8;
layout (constant_id = 4) const uint THREADS_PER_GROUP = 64;
layout(local_size_x_id = 2, local_size_y_id = 3) in;
layout(std430, set = 0, binding = 0) buffer SrcGrid {
uint state[CELLS_COUNT];
} srcGrid;
layout(std430, set = 0, binding = 1) buffer DstGrid {
uint state[CELLS_COUNT];
} dstGrid;
uint getSSBODataFromWorldGridCoords( uint worldGridX, uint worldGridY )
{
uvec2 worldGridCoords = uvec2( worldGridX, worldGridY );
worldGridCoords = (worldGridCoords + GRID_SIZE) % GRID_SIZE;
return srcGrid.state[ worldGridCoords.x + worldGridCoords.y * GRID_SIZE ];
}
shared uint sharedData[ THREADS_PER_GROUP_X + 2 ][ THREADS_PER_GROUP_Y + 2 ];
void main()
{
const uvec2 workGroupSize = uvec2( THREADS_PER_GROUP_X, THREADS_PER_GROUP_Y );
// Grid coords within a thead group
const uvec2 localGridCoords = gl_LocalInvocationID.xy;
// Coordinates of each thread group tile
const uvec2 worldGridOffset = gl_WorkGroupID.xy * workGroupSize.xy;
// Grid coords within the whole game
const uvec2 worldGridCoords = worldGridOffset + localGridCoords;
// Early out if not in the grid
if( worldGridCoords.x >= GRID_SIZE || worldGridCoords.y >= GRID_SIZE )
{
return;
}
// Load data into TGSM
const uvec2 tgsmCoords = localGridCoords + uvec2( 1, 1 );
// Top left corner
if( localGridCoords.x == 0 && localGridCoords.y == 0 )
{
// Top
sharedData[ tgsmCoords.x ][ tgsmCoords.y - 1 ] = getSSBODataFromWorldGridCoords( worldGridCoords.x, worldGridCoords.y - 1 );
// Top Left
sharedData[ tgsmCoords.x - 1 ][ tgsmCoords.y - 1 ] = getSSBODataFromWorldGridCoords( worldGridCoords.x - 1, worldGridCoords.y - 1 );
// Left
sharedData[ tgsmCoords.x - 1 ][ tgsmCoords.y ] = getSSBODataFromWorldGridCoords( worldGridCoords.x - 1, worldGridCoords.y );
}
// Top right corner
else if( localGridCoords.x == workGroupSize.x - 1 && localGridCoords.y == 0 )
{
// Top
sharedData[ tgsmCoords.x ][ tgsmCoords.y - 1 ] = getSSBODataFromWorldGridCoords( worldGridCoords.x, worldGridCoords.y - 1 );
// Top Right
sharedData[ tgsmCoords.x + 1 ][ tgsmCoords.y - 1 ] = getSSBODataFromWorldGridCoords( worldGridCoords.x + 1, worldGridCoords.y - 1 );
// Right
sharedData[ tgsmCoords.x + 1 ][ tgsmCoords.y ] = getSSBODataFromWorldGridCoords( worldGridCoords.x + 1, worldGridCoords.y );
}
// Bottom left corner
else if( localGridCoords.x == 0 && localGridCoords.y == workGroupSize.y - 1 )
{
// Bottom
sharedData[ tgsmCoords.x ][ tgsmCoords.y + 1 ] = getSSBODataFromWorldGridCoords( worldGridCoords.x, worldGridCoords.y + 1 );
// Bottom Left
sharedData[ tgsmCoords.x - 1 ][ tgsmCoords.y + 1 ] = getSSBODataFromWorldGridCoords( worldGridCoords.x - 1, worldGridCoords.y + 1 );
// Left
sharedData[ tgsmCoords.x - 1 ][ tgsmCoords.y ] = getSSBODataFromWorldGridCoords( worldGridCoords.x - 1, worldGridCoords.y );
}
// Bottom right corner
else if( localGridCoords.x == workGroupSize.x - 1 && localGridCoords.y == workGroupSize.y - 1 )
{
// Bottom
sharedData[ tgsmCoords.x ][ tgsmCoords.y + 1 ] = getSSBODataFromWorldGridCoords( worldGridCoords.x, worldGridCoords.y + 1 );
// Bottom Right
sharedData[ tgsmCoords.x + 1 ][ tgsmCoords.y + 1 ] = getSSBODataFromWorldGridCoords( worldGridCoords.x + 1, worldGridCoords.y + 1 );
// Right
sharedData[ tgsmCoords.x + 1 ][ tgsmCoords.y ] = getSSBODataFromWorldGridCoords( worldGridCoords.x + 1, worldGridCoords.y );
}
// Left Edge
else if( localGridCoords.x == 0 )
{
sharedData[ tgsmCoords.x - 1 ][ tgsmCoords.y ] = getSSBODataFromWorldGridCoords( worldGridCoords.x - 1, worldGridCoords.y );
}
// Right Edge
else if( localGridCoords.x == workGroupSize.x - 1 )
{
sharedData[ tgsmCoords.x + 1 ][ tgsmCoords.y ] = getSSBODataFromWorldGridCoords( worldGridCoords.x + 1, worldGridCoords.y );
}
// Top Edge
else if( localGridCoords.y == 0 )
{
sharedData[ tgsmCoords.x ][ tgsmCoords.y - 1 ] = getSSBODataFromWorldGridCoords( worldGridCoords.x, worldGridCoords.y - 1 );
}
// Bottom Edge
else if( localGridCoords.y == workGroupSize.y - 1 )
{
sharedData[ tgsmCoords.x ][ tgsmCoords.y + 1 ] = getSSBODataFromWorldGridCoords( worldGridCoords.x, worldGridCoords.y + 1 );
}
// SSBO index for this thread
const uint ssboIndex = worldGridCoords.x + worldGridCoords.y * GRID_SIZE;
uint currentCellState = getSSBODataFromWorldGridCoords( worldGridCoords.x, worldGridCoords.y );
// Load current cell into TGSM.
sharedData[ tgsmCoords.x ][ tgsmCoords.y ] = currentCellState;
// Barrier
memoryBarrierShared();
barrier();
// Count alive neighbors from TGSM
uint aliveNeighbors = 0;
const uvec2 sharedMemoryGridCoordinates = uvec2( localGridCoords.x + 1, localGridCoords.y + 1 );
aliveNeighbors += sharedData[ sharedMemoryGridCoordinates.x - 1 ][ sharedMemoryGridCoordinates.y - 1 ];
aliveNeighbors += sharedData[ sharedMemoryGridCoordinates.x ][ sharedMemoryGridCoordinates.y - 1 ];
aliveNeighbors += sharedData[ sharedMemoryGridCoordinates.x + 1 ][ sharedMemoryGridCoordinates.y - 1 ];
aliveNeighbors += sharedData[ sharedMemoryGridCoordinates.x - 1 ][ sharedMemoryGridCoordinates.y ];
aliveNeighbors += sharedData[ sharedMemoryGridCoordinates.x + 1 ][ sharedMemoryGridCoordinates.y ];
aliveNeighbors += sharedData[ sharedMemoryGridCoordinates.x - 1 ][ sharedMemoryGridCoordinates.y + 1 ];
aliveNeighbors += sharedData[ sharedMemoryGridCoordinates.x ][ sharedMemoryGridCoordinates.y + 1 ];
aliveNeighbors += sharedData[ sharedMemoryGridCoordinates.x + 1 ][ sharedMemoryGridCoordinates.y + 1 ];
if( currentCellState < 1.0 && aliveNeighbors == 3 )
{
// Dead cell comes back to life
dstGrid.state[ssboIndex] = 1;
return;
}
// Alive cell dies
if( aliveNeighbors < 2.0 || aliveNeighbors > 3.0)
{
dstGrid.state[ssboIndex] = 0;
return;
}
dstGrid.state[ssboIndex] = currentCellState;
}
Any pointers towards articles or tools that could help me figure this out would be greatly appreciated :).