Tistory View

이 글은 링크가 기술적인 문서에다가 하드코어한 문서이기에 "Sharing memory between threads in same work group"에서 마지막까지 되도록 직역을 한 버전이다. 직역은 틀리게 번역하는 것을 막아주는 좋은 점이 있다, 하지만 또다른 하드코어를 만들기도 한다, 그래도 의역의 이전 단계로 쓰기에는 괜찮기도 하다.

역자(갓댕치)가 한국사람이라 역시 문화의 차이에서 발생하는 언어문제는 여전히 있고, 오역 또한 발생 할 수 있으니, 원문과 번역문을 신중히 비교해 가며 보기를 바란다. 따로 의역 버전을 만들지는 모르겠고, 이 문서와 관련된 Compute shader의 내용 올리지 않을 지도 몰라 남겨 둔다.

 

Sharing memory between threads in same work group

같은 워크그룹내의 Thread사이에서 메모리 공유

 

A major feature of compute is that since we have the concept of a work group, we can now give the work group some shared memory which can be used by every thread in the work group. This allows compute shader threads to share their computation with other threads which can make or break certain parallel algorithms.

compute의 주요 특징은 워크그룹의 개념에 있다. 우리는 지금 워크그룹내에 모든 쓰레드에서 사용될 수 있는 공유 메모리를 줄 수 있다. 이 것은 compute shader thread들에게 그들의 연산을 다른 쓰레드(그런 병렬 알고리즘을 만들거나 부수거나)와 공유하도록 해준다.

 

 

  • Shared memory is limited in size.
  • 공유메모리는 크기에 제한이 있다.
  • Implementations must support at least 16 KiB worth of storage for a single work group.
  • 구현은 한 workgroup당 최소 16KiB 크기의 공간을 지원해야한다.
  • Shared memory is uninitialized, and not persistent.
  • 공유 메모리는 초기화 되지 않고, 영구적이 아니다.
  • It is not backed by buffer objects and only serves as scratch memory for the execution of a work group.
  • 그 것은 buffer objects로 백업되지 않는다. 한 그룹의 실행을 위해 scratch메모리(역자주:cache보다는 덜사용되지만 cache같은)로 제공된다.

A nice use case for shared memory is that it's sometimes possible to perform multi-pass algorithms in a single pass instead by using shared memory to store data between passes. Shared memory is typically backed by either cache or specialized fast local memory. Multi-pass approaches using fragment shaders need to flush out results to textures every pass, which can get very bandwidth intensive.

공유메모리의 좋은 사용(예)는 가끔 "MultiPass의 Pass들사이에서 데이터를 저장하는 공유메모리로써 사용하므로 Single-Pass에서 Multi-Pass알고리즘을 대신 수행하는 것이 가능하다". 공유메모리는 (전형적으로) 캐시나 (특화된) 빠른 로컬 메모리에 백(업)된다. fragment shaders사용한 Multi-Pass 시도(approach)는 각각의 pass마다 결과를 완전히 끝낼 필요가 있다. 그것은 매우(큰) bandwidth 강도를 가질 수 있다.

 

To declare shared memory for a compute work group, do this:

compute work group에 공유메모리를 선언하려면 이렇게 하라.

shared float shareData[1024]; // Shared between all threads in work group.

shared float shareData[1024]; // 워크그룹내의 모든 쓰레드사이에서 공유됨

 

To properly make use of shared memory however, we will need to introduce some new synchronization primitives first.

올바르게 공유메모리를 사용하려면, 새로운 syncronization 기반들을 먼저 소개할 필요가 있다.

 

 

Atomic operations

Atomic작업

 

With compute shaders, multiple threads can perform operations on the same memory location. Without some new atomic primitives, accessing such memory safely and correct would be impossible.

computeshader들을 사용하여, 여러 쓰래드가 같은 메모리 위치에서 작업을 할 수 있다. 뭔가 새로운 atomic 기반 없이, 그런 메모리를 다루는 것과 수정하는 것은 불가능 할 수 있다.

  • Atomic addition/subtraction
  • Atomic or/xor/and
  • Atomic maximum/minimum
  • Atomic exchange
  • Atomic compare exchange

Atomic operations fetch the memory location, perform the operation and write back to memory atomically. Thus, multiple threads can access the same memory without data races. Atomic operations are supported for uint and int datatypes. Atomic operations always return the value that was in memory before applying the operation in question.

There are two interfaces for using atomics in OpenGL ES 3.1, explained below.

Atomic작업들은 메모리위치에서 (뭔가) 가지고 오고, 연산을 수행하고, 메모리로 다시 (atomically하게) 쓰는 것이다. 그 것은, 여러 쓰레드가 data races없이 같은 메모리를 접근할 수 있다. Atomic 연산은 uint와 int에 적용된다. Atomic 연산은 항상 연산이 적용되기 이전의 메모리 값을 리턴 한다. OpenGL ES 3.1에는 atomics를 사용하기위해 2가지 인터페이스가 있다. 아래에서 설명한다.

 

Atomic counters

The first interface is the older atomic counters interface. It is a reduced interface which only supports basic increments and decrements.

첫번째 인터페이스는 older atmic counters(옛날방식) 인터페이스다. 그 것은 기본 증감만 지원하는 축소된(reduced) 인터페이스다.

In a shader, you can declare an atomic_uint like this:

쉐이서에서, atomic_uint를 이렇게 선언할 수 있다.

 

layout(binding = 0, offset = 0) uniform atomic_uint atomicCounter;
void main() {
    uint previous = atomicCounterIncrement(atomicCounter);
}

Atomic counters are backed by a buffer object GL_ATOMIC_COUNTER_BUFFER. Just like uniform buffers, they are indexed buffers. To bind an indexed buffer, use

Atomic counter들은 GL_ATOMIC_COUNTER_BUFFER로 저장된다. 딱 uniform buffers와 같이, 이 것들은 indexed buffer다. 한 indexed buffer를 바인드하려면 사용하라(다음과 같이)

glBindBufferBase(GL_ATOMIC_COUNTER_BUFFER, index, buffer_object);

glBindBufferBase(GL_ATOMIC_COUNTER_BUFFER, index, buffer_object);

or 또는

glBindBufferRange(GL_ATOMIC_COUNTER_BUFFER, index, buffer_object, offset, size);

 

Note : Any OpenGL buffer can be bound to GL_ATOMIC_COUNTER_BUFFER. Note that there are restrictions to the number of counters you can use.

주의 : OpenGL buffer의 어떤 것(거의 대부분)들은 GL_ATOMIC_COUNTER_BUFFER로 바인드 될 수 있다. 당신이 사용할  수 있는 카운터의 수는 제한 되어있다는 것을 주의하라.

 

 

Atomic operations on SSBOs and shared memory

SSBO와 공유메모리에서 Atomic 작업

 

A more flexible interface to atomics is when using shared memory or SSBOs. Various atomic*() functions are provided which accept variables backed by shared or SSBO memory.

atomics에 좀 더 유연한 인터페이스는 공유메모리나 SSBO를 사용할 때다. 공유메모리나 SSBO변수를 받을 수 있는 여러가지 atomic*() 함수들이 제공된다.

 

For example: 예를 들면

shared uint sharedVariable;
layout(std430, binding = 0) buffer SSBO {
    uint variables[];
};
void main()
{
    uint previousShared = atomicAdd(sharedVariable, 42u);
    uint previousSSBO   = atomicMax(variables[12], 11u);
}

Atomic operations on shader images

쉐이더 이미지상에 Atomic 연산

With an extension to OpenGL ES 3.1, GL_OES_shader_image_atomic, atomic operations are supported on shader images as well.

OpenGL ES 3.1의 확장 GL_OES_shader_image_atomic을 통하여 shader 이미지의 atomic 연산을 지원한다.

 

 

Synchronizing memory transactions

OpenGL ES from an API standpoint is an in-order model. The API appears to execute GL commands as-if every command is completed immediately before moving on to the next GL command. Of course, any reasonable OpenGL ES implementation will not do this, and opt for a buffered and/or deferred approach. In addition, for various hazard scenarios like reading from a texture after rendering a scene to it, the driver needs to ensure proper synchronization, etc. Users of OpenGL ES do not have to think about these low-level details. The main reason why this can be practical from an API standpoint is that up until now, the API have had full control of where shaders write data. Either we wrote data to textures via FBOs, or to buffers with transform feedback. The driver could track which data has been touched and add the extra synchronization needed to operate correctly.

API관점에서 OpenGL ES는 순서형태(순서대로) 모델이다. API는 다음 GL명령으로 이동하기 전에 마치 즉시 완료되는 것처럼 GL 명령들이 실행되는 것으로 보인다. 물론, 어떤 의미있는 OpenGL ES 처리는 이렇게 하지 않을 것이다. 그리고 버퍼형 이든 뒤로밀든(수행대기)의 접근 방식을 선택한다. 추가로, 텍스처로 렌더링한 다음 읽기와 같은 여러 위험한 시나리오 등, 드라이버는 올바른 synchronization을 확실히 할 필요가 있다. OpenGL ES 사용자는 이런 low-level의 상세한 것들을 생각할 필요는 없다. 주된 이유는 ..... (좀 번역 빠짐 별로 안중요함). 우리가 FBO를 통해서 데이터를 텍스처로 쓰거나 transform feedback으로 Buffer로 쓴다. 드라이버는 어떤 데이터가 수정되었는지 추적할 수 있고, 올바르게 동작하기위해 추가적인 synchronization을 추가할 수 있다.

 

With compute however, the model changes somewhat. Now, compute shaders can write to SSBOs, images, atomics and shared memory. Whenever we perform such writes, we need to ensure proper synchronization ourselves.

그러나, compute(shader)로는 모델이 좀 바뀐다. 이제(Now), Compute shader는 SSBO, 이미지, atomics, 공유메모리에 쓸 수 있다. 우리가 그런 쓰기를 수행할 때마다, 우리 스스로 알맞은 syncronization을 확실히 할 필요가 있다.

 

Coherent and various memory qualifiers

Coherent(일관된) 그리고 여러 메모리 한정자

 

Being able to have writes from one thread be visible to another thread running in parallel requires coherent memory. We need some new qualifiers to express how we are going to access the memory, and OpenGL ES 3.1 defines these new qualifiers.

한 쓰레드에서 (데이터를)쓴 것들이 병렬로 실행되는 다른 쓰레드에서 보이게 하는 것은 coherent 메모리를 요구한다. 어떻게 메모리를 다룰 것인지 표현하는 몇개의 새로운 한정자가 필요하다. 그리고 OpenGL ES 3.1은 이 새로운 한정자들을 정의한다.

  • coherent
  • writeonly
  • readonly
  • volatile
  • restrict

To use one or multiple qualifiers, we can apply them to SSBOs and shader images like this:

하나 이상의 한정자를 사용하려면, SSBO나 shader 이미지에 이와 같이 적용할 수 있다.

// SSBO
layout(std430, binding = 0) writeonly coherent buffer SSBO {
    float coherentVariable;
};
// Image
layout(r32f, binding = 0) restrict readonly uniform highp image2D uImage;

 

coherent

A variable declared coherent means that a write to that variable will eventually be made visible to other shader invocations in the same GL command. This is only useful if you expect that other threads are going to read the data that one thread will write. Threads which are reading data from coherent writes must also read from variables marked as coherent.

coherent로 선언된 변수는 "변수에 쓴 것은 결국 같은 GL명령에서 호출된 다른 쓰레드의 invocation에 보여지 게" 될 것이다. 이 것은 한 쓰레드에서 쓴 것을 다른 쓰레드들에서 읽고 싶은 경우에만 유용하다. 읽으려하는 쓰레드 또한 coherent로 표기되어야 한다.

 

Note

Coherent qualifier should not be used if the data written by shaders are to be consumed in a different GL command. See API level memory barriers for that case. Shared memory as you'd might expect is implicitly declared with coherent qualifiers, since its purpose is precisely to share data between threads. While using the coherent qualifier itself is fairly uncommon, the rules are still important to know since shared memory is coherent.

Coherent 한정자는 쉐이더에 의해 쓰여진 데이터를 다른 GL명령에서 쓰레드에서 읽으려 할 때는 쓰지 말아야 한다.

이 경우는 API level memory barriers를 보라. 공유메모리의 목적이 쓰레드 사이에서 데이터를 확실히 공유하기위한 것이기 때문에, 당신이 기다하는 것 처럼 공유메모리는 내부적으로 coherent 한정자로 선언되었다. 반면에 coherent한정자를 스스로 사용하는 것은 정말 일반적이지 않다. 공유메모리는 coherent라는 것 때문에, 규칙들을 여전히 아는 것이 중요하다.

 

 

writeonly/readonly

These are designed to express read-only or write-only behavior. Atomic operations both read and write variables, and variables cannot be declared with either if atomics are used.

이 것들은 읽기전용이거나 쓰기전용으로 행동하는 것을 표현하도록 설계되었다. 변수들에 읽고 쓰는 Atomic 연산, 그리고 atomic을 사용한다면 변수를 둘 다 선언할 수없다.

volatile/restrict

These are fairly obscure. Their meanings are the same as in C. Restrict expresses that buffers or images do not alias each other, while volatile means buffer storage can change at any time, and must be reloaded every time.

이 것들은 딱 한번에 알아볼 수 있다. 이들의 의미는 C에서와 동일하다. Restrict는 버퍼나 이미가가 다른 건으로 변경될 수 없다는 것이다. 반면에 volatile은 버퍼저장공간이 언제든 바뀔 수 있고 매번 다시 로드되어야 한다는 것이다.

(역자주 : restrict 다른 함수로 넘길수 없다. volatile 딴 곳에서 변화될 수 있으니 즉시즉시 업데이트하고, 코드최적화를 하지 않는다.)

 

 

API level memory barriers

Consider a probable use of compute where we're computing a vertex buffer and drawing the result:

우리가 vertex buffer를 연산하고 결과를 그릴 때, Compute(shader)의 올바른 사용법은 상상해보자

glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, buffer);
glUseProgram(update_vbo_program);
glDispatchCompute(GROUPS, 1, 1); // <-- Write to buffer
glUseProgram(render_program);
glBindVertexArray(vertex_array);
glDrawElements(GL_TRIANGLES, ...); // <-- Read from buffer

If this were render-to-texture or similar, code like this would be correct since the OpenGL ES driver ensures correct synchronization. However, since we wrote to an SSBO in a compute shader, we need to ensure that our writes are properly synchronized with the rest of OpenGL ES ourselves.

render-to-texture(텍스처로 그리기) 또는 이와 유사한 것을 한다면, OpenGL ES 드라이버는 올바른 synchronaztion을 확실히 하기 때문에, 이와 같은 코드는 올바라야 한다.  하지만, 우리는 compute shader에서 SSBO에 쓰기를 했다, 우리는 우리의 쓰기작업이 올바른 OpenGL ES의 synchronization을 나머지 작업을 하기위해 스스로 확실히 할 필요가 있다.

 

To do this, we use a new function, glMemoryBarrier() and our corrected version looks like:

이 것을 하려면, 우리는 새 함수, glMemoryBarrier()를 사용한다. 우리의 올바르게 수정된 버전은 이렇게 보인다.

glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, buffer);
glUseProgram(update_vbo_program);
glDispatchCompute(NUM_WORK_GROUPS, 1, 1); // <-- Write to buffer
// Ensure that writes to buffer are visible to subsequent GL commands which try to read from it.
// 버퍼에 쓴것이 그 것을 읽으려하년 다음 GL 명령들에게 보일 수 있게 확실히 한다.
// Essentially, it tells the GPU to flush and/or invalidate its appropriate caches.
// 실제로, 그 것은 GPU에게 알맞은 캐시를 완료시키기 또는 초기화하기 작업을 하도록 전한다.
glMemoryBarrier(GL_VERTEX_ATTRIB_ARRAY_BARRIER_BIT);
glUseProgram(render_program);
glBindVertexArray(vertex_array);
glDrawElements(GL_TRIANGLES, ...); // <-- Read from buffer

It's important to remember the semantics of glMemoryBarrier(). As argument it takes a bitfield of various buffer types. We specify how we will read data after the memory barrier. In this case, we are writing the buffer via an SSBO, but we're reading it when we're using it as a vertex buffer, hence GL_VERTEX_ATTRIB_ARRAY_BARRIER_BIT.

Another detail is that we only need a memory barrier here. We do not need some kind of "execution barrier" for the compute dispatch itself.

glMemoryBarrier의 의미를 기억하는 것은 중요하다.  여러버퍼타입의 bitfield를 argument로 쓴다. 우리는 메모리 베리어 후에 어떻게 데이터를 읽을 것인지 나타내야 한다. 이 경우, 우리는 SSBO를 통해 buffer에 기록을 하는 중이다. 그러나 우리는 그것을 vertex buffer처럼 사용하려 할 때 그 것을 읽고 있다. 따라서 GL_VERTEX_ATTRIB_ARRAY_BARRIER_BIT.

또다른 세부내용은 우리는 여기서 memory barrier만 필요하다는 것이다. 우리는 연산(Compute dispatch)자체를 위해 "excution barrier"와 같은 것은 필요없다는 것이다.

Shader language memory barriers

While the API level memory barriers order memory accesses across GL commands, we also need some shader language barriers to order memory accesses within a single dispatch.

API수준의 메모리 베리어는 GL 명령들 사이에 메모리 접근을 순서화하는 반면에, 우리는 한(single) dispatch에서 메모리 접근을 순서화하기 위한 어떤 (shader에서의) 베리어가 필요하다.

To understand memory barriers, we first need to consider memory ordering. An important problem with multi-threaded programming is that while memory transactions within a single thread might happen as expected, when other threads see the data written by the thread, the order of which the memory writes become visible might not be well defined depending on the architecture. In the CPU space, this problem also exists, where it's often referred to as "weakly ordered memory".

메모리 베리어를 이해하기위해, 처음에 우리는 메모리순서를 생각할 필요가 있다. multi-thread프로그래밍에서 중요한 문제는 a single thread가 기대한대로 일으킨 것 내의 메모리 트랜잭션에 반하여,  다른 쓰레드가 그 쓰여진 데이터를 읽으려할 때, 그 메모리 쓰기의 순서는 아키텍쳐에 따라 잘 정의되어있지 않다. CPU공간에서도 이 문제 또한 있다. 여기서 이것은 "weakly ordered memory"라 한다.

 

To fully grasp the consequences of weakly ordered memory is a long topic on its own. The section here serves to give some insight as to why ordering matters, and why we need to think about memory ordering when we want to share data with other threads running in parallel.

"weakly ordered memory"의 결과를 전부 파악하는 것은 그 것만으로 긴 주제다. 이 색션은 왜 병렬처리시 다른 쓰레드들과 공유할 때 순서문제인지 좀 보여주려한다.

To illustrate why ordering matters, we can consider two threads in this classic example. Thread 1 is a producer which writes data, and thread 2 consumes it.

왜 순서 문제인지를 눈으로 확인하게위해, 이 예전(역자주:보통많이 쓰이는)의 두 쓰레드를 고려할 수 있다. 쓰레드1은 생산자로 데이터를 쓰고 쓰레드2는 그 것을 소비한다.

 

shared int A;
shared int B;
// Assume A and B are initially 0 and that thread A and B are running in parallel.(오류 있음)
// A와 B는 초기에는 0, Thread 1과 2는 병렬로 수행된다.

// Thread 1
A = 1;
B = 1;
// Thread 2
int readB = B;
int readA = A;
if (readA == 0 && readB == 0)
{
    // This makes sense. Thread 2 might have read A and B
    // before thread 1 completed any of its writes.
    // 말이 된다. 쓰레드 1이 쓰기를 완료하기 전에 쓰레드 2가 A와 B를 읽을 수 있다.
}
else if (readA == 1 && readB == 0)
{
    // This makes sense, we might have read A and B in-between the write to A and B in thread 1.
    // 말이 된다. Thread 1이 A와 B를 쓰는 그 사이에 A와 B를 읽을 수 있다.
}
else if (readA == 1 && readB == 1)
{
    // This also makes sense.
    // Thread 1 have completed both its writes before thread 2 read any of them.
    // 이 것도 말이 된다. 쓰레드1이 T쓰레드2가 둘다 읽기 전에 수행이 끝날 수 있다.
}
else if (readA == 0 && readB == 1)
{
    // How can this happen? It can ...
    // 어떻게 이게 돼? 된다...
}

 

Even if thread 1 wrote to A, then B, it is possible that the memory system completes the write to B first, then A. If thread 2 reads B and A with just the right timing, the odd-ball case above is possible.

비록 Thread 1이 A에 쓰고 그 다음 B를 쓴다고 해도, memory system은 A보다 B를 먼저 쓰기를 완료하는 하는 것이 가능하다. A가 쓰고, B가 딱 올바른 타이밍(해당하는 그 타이밍) 읽으면, 위의 이상한 경우가 가능하다.

 

To resolve this case, we need to employ memory barriers to tell the memory system that a strict ordering guarantee is required. OpenGL ES defines several memory barriers.

이 경우를 해결하려고, 우리는 memory system에 "순서를 제한하는 보증"을 요구한다고 말해줘야 한다. OpenGL ES는 여러 memory barrier를 정의하고 있다.

  • memoryBarrier()
  • memoryBarrierShared()
  • memoryBarrierImage()
  • memoryBarrierBuffer()
  • memoryBarrierAtomicCounter()
  • groupMemoryBarrier()

The memoryBarrier*() functions control memory ordering for a particular memory type. memoryBarrier() enforces ordering for all memory accesses. groupMemoryBarrier() enforces memory ordering like memoryBarrier(), but only for threads in the same work group. All memory barriers except for groupMemoryBarrier() and memoryBarrierShared() enforce ordering for all threads in the compute dispatch.

memoryBarrier*()함수들은 구체적인 메모리 종류의 순서를 제어한다. memoryBarrier()는 모든 메모리 접근을 순서화를 강제화한다. groupMemoryBarrier()는 meoryBarrier()처럼 순서화를 강제로 하는데, 같은 워크그룹의 쓰레드들에게 한정된다. groupMemoryBarrier()와 memoryBarrierShared()를 제외한 모든 memroyBarrier들은 해당하는 compute dispatch의 모든 쓰레드에 순서화를 강제시킨다.

 

The memory barrier ensures that all memory transactions before the barrier must complete before proceeding. Memory accesses below the barrier cannot be moved over the barrier.

Memory barrier는 "(다음을 ) 수행 전[before processing]" 그 barrier 이전의 모든 메모리 트랜잭션이 끝나야만 하는 것을 확실하게 한다.

Looking at our example, we can now fix it like this:

우리의 예제를 보자, 우리는 이제 다음과 같이 고칠 수 있다.

// Thread 1
A = 1;
memoryBarrierShared(); // Write to B cannot complete until A is completed.
                       // B에 쓰기는 A가 끝날 때 까지 수행되지 않는다.

B = 1;
// Thread 2
int readB = B;
memoryBarrierShared(); // Reads from A cannot happen before we've read B.
                       // B를 읽기 전에 A에서 읽기는 일어나지 않는다.
int readA = A;
if (readA == 0 && readB == 1)
{
    // This case is now impossible. If B == 1, we know that the write to A must have happened.
    // 이 경우는 불가능 하다. 만약 b가 1이면, 우리는 A에 쓰기가 일어났음을 알고 있다.
    // Since we are guaranteed to read A after B, we must read A == 1 as well.
    // B 후에 A를 읽기가 보증되기 때문에 A는 1로 읽어야만 한다.
}

While ordering scenarios like these are rarely relevant for compute, memory barriers in OpenGL ES also ensure visibility of coherent writes. If a thread is writing to coherent variables and we want to ensure that our writes become visible to other threads, we need memory barriers. For example:

이 것들과 같은 순서 시나리오는 compute(shader)에 덜 관련한다. OpenGL ES의 memory barrier들은 또한 coherent 쓰기를 확실하게 한다. 만약 어떤 한 Thread가 coherent변수에 쓰고 있고 다른 쓰레드에서 우리가 쓴 것이 보이게 하려면 memory barrier들이 필요하다. 예를 들면:

write_stuff_to_shared_memory();

// We can conceptually see this as "flush out my writes to shared memory now".
// 우리는 이것을 개념적으로 "우리가 공유메모리에 쓴 것을 바로 처리해버렸~"
// If other threads try to read from shared memory after this returns, 
// we are guaranteed to read the correct values.
// 만약 다른 쓰레드가 공유메모리를 읽으려 하면, 우리는 올바른 값을 읽는다는 것을 보증한다.
// [(역자주)위 내용을 memoryBarrierShared()가 한다]
memoryBarrierShared();

Note

While memory barriers ensure that writes become visible, this does not mean that omitting a memory barrier guarantees that writes never become visible. If two threads happen to share the same caches, the writes would likely become visible anyways.

memory barrier는 쓴(데이터가 쓰여진 것) 것을 보이게 하지만, 메모리 베리어를 빼면 절대 보이지 않는 다는 것을 보증하는 것은 아니다. 만약 두 쓰레드가 동일한 cache를 공유한다면 쓰는 것은 어쨋든 보이게 될 것이다.

 

Synchronizing execution of threads

쓰래드들의 Synchorizing excution(실행)

Memory barriers only ensure ordering for memory, not execution. Parallel algorithms often require some kind of execution synchronization as well.

메모리 베리어는 실행이 아닌 메모리의 순서만 확실히 한다. 병렬 알고리즘은 자주 실행 synchronization 같은 것을 또한 자주 요구한다.

A cornerstone of GPU compute is the ability to synchronize threads in the same workgroup. By having fine-grained synchronization of multiple threads, we are able to implement algorithms where we can safely know that other threads in the work group have done their tasks.

GPU 연산의 기반은 같은 Workgroup내의 쓰래드를 synchronization하는 능력이다. Work group내의 쓰래드들이 그 들의 작업을 완료 했는지를 안전하게 알 수 있을 때, 우리는 알고리즘을 수행할 수 있다.

For example, let's assume a case where we want to read some data from an SSBO, perform some computation on the data and share the results with the other threads in the work group.

예를 들어, 우리는 Workgroup내에서 ssbo에서 일부 데이터를 읽고, 데이터로 연산을 수행하고, 다른 쓰래드와 결과를 공유하는 경우를 가정해 보자.

#version 310 es
layout(local_size_x = 128) in; // Work group of 128 threads.
// Allow ping-ponging between two buffers.
// 두 버퍼 사이에 주고 받기를 허용한다.
shared vec4 someSharedData0[128];
shared vec4 someSharedData1[128];
layout(std430, binding = 0) readonly buffer InputData {
    vec4 someInputData[];
};
layout(std430, binding = 1) writeonly buffer OutputData {
    vec4 someOutputData[];
};
vec4 perform_computation(vec4 data)
{
    return sin(data);
}
vec4 some_arbitrary_work(vec4 a, vec4 b)
{
    return vec4(a.xy * b.yx, a.zw * b.wz);
}
void main()
{
    // If you are very lucky, this would work ...
    // 당신이 운이 아주 좋다면 이것은 동작할 것이다.
    someSharedData0[gl_LocalInvocationIndex] = perform_computation(someInputData[gl_GlobalInvocationID.x]);
    // Where is memory barrier?
    // memory barrier는 어디 있어?
    
    // Here we want to use the results that other threads have computed and do something useful. 
    // 뭔가를 쓸만하게 하려는 다른 쓰래드의 연산들은 결과를 쓰고 싶어.
    // Note the alternation between two arrays.
    // 두 배열의 변환
    // 1st pass.
    someSharedData1[gl_LocalInvocationIndex] =
        some_arbitrary_work(someSharedData0[gl_LocalInvocationIndex], someSharedData0[(gl_LocalInvocationIndex + 15u) & 127u]);
    // Again, use results from other threads.
    // 다시, 다른 Thread의 결과 사용
    // 2nd pass.
    someSharedData0[gl_LocalInvocationIndex] =
        some_arbitrary_work(someSharedData1[gl_LocalInvocationIndex], someSharedData1[(gl_LocalInvocationIndex + 15u) & 127u]);
    // Again, use results from other threads.
    // 다시, 다른 Thread의 결과 사용
    // 3rd pass, write out results to buffer.
    // 3번째 패쓰, 버퍼로 결과 쓰기
    someOutputData[gl_LocalInvocationIndex] =
        some_arbitrary_work(someSharedData0[gl_LocalInvocationIndex], someSharedData0[(gl_LocalInvocationIndex + 15u) & 127u]);
}

 

In this sample, we're missing proper synchronization. For example, we have a fundamental problem that we aren't guaranteed that other threads in the work group have executed perform_computation() and written out the result to someSharedData0. If we let one thread proceed with execution prematurely, we will read garbage, and the computation will obviously be wrong. To solve this problem we need to employ memory barriers and execution barriers.

이 예제에서, 우리는 알맞은 synchronization을 빼먹었다. 예를 들어, 우리는 WorkGroup에 다른 Thread들이 proform_computation()을 수행하고 someSharedData()로 결과를 쓰는 것을 보증하지 않았다. 우리가 한 쓰래드가 조기에 실행하게 한다면, 우리는 쓸모없는 것을 읽을 것이다. 그 연산은 명백히 잘 못 될 것이다. 이 문제를 풀기위해, 우리는 memory barriers와 exccution barriers를 사용해야 한다.

#version 310 es
layout(local_size_x = 128) in; // Work group of 128 threads.
// Allow ping-ponging between two buffers.
shared vec4 someSharedData0[128];
shared vec4 someSharedData1[128];
layout(std430, binding = 0) readonly buffer InputData {
    vec4 someInputData[];
};
layout(std430, binding = 1) writeonly buffer OutputData {
    vec4 someOutputData[];
};
vec4 perform_computation(vec4 data)
{
    return sin(data);
}
vec4 some_arbitrary_work(vec4 a, vec4 b)
{
    return vec4(a.xy * b.yx, a.zw * b.wz);
}
void synchronize()
{
    // Ensure that memory accesses to shared variables complete.
    // 공유메모리에 메모리 접근이 완료되는 것을 확실히 한다.
    memoryBarrierShared();
    // Every thread in work group must reach this barrier before any other thread can continue.
    // 워크 그룹내의 모든 쓰래드는 다른 쓰레드가 더 진행하기 전에 이 barrier에 도착해야 한다.
    barrier();
}
void main()
{
    someSharedData0[gl_LocalInvocationIndex] = perform_computation(someInputData[gl_GlobalInvocationID.x]);
    synchronize();
    // We are now guaranteed that all threads in the work group have completed their work,
    // and that their shared memory writes
    // are visible to us. We can now proceed with the computation.
    // 우리는 워크그룹내의 모든 쓰레드가 공유메모리에 쓰고, 우리에게 보인다는 것을 보증한다.
    // 우리는 이제 연산을 진행 할 수 있다.
   
    // It's important that we're writing to a separate shared array here,
    // 우리가 분리된 공유배열에 쓰고 있다는 것은 중요한다.
    // otherwise our newly computed values could stomp on the data that other threads are trying to read.
    // 그렇지 않으면, 우리의 새롭게 연산한 값들은 다른 쓰레드가 읽으려고 stomp(구르다?)할 수 있다.
    someSharedData1[gl_LocalInvocationIndex] =
        some_arbitrary_work(someSharedData0[gl_LocalInvocationIndex], someSharedData0[(gl_LocalInvocationIndex + 15u) & 127u]);
    synchronize();
    someSharedData0[gl_LocalInvocationIndex] =
        some_arbitrary_work(someSharedData1[gl_LocalInvocationIndex], someSharedData1[(gl_LocalInvocationIndex + 15u) & 127u]);
    synchronize();
    someOutputData[gl_LocalInvocationIndex] =
        some_arbitrary_work(someSharedData0[gl_LocalInvocationIndex], someSharedData0[(gl_LocalInvocationIndex + 15u) & 127u]);
}

 

Note

Many tutorials assume that calling barrier() also implies that shared memory is synchronized. While this is often the case from an implementation point of view, the OpenGL ES specification is not as clear on this and to avoid potential issues, use of a proper memoryBarrierShared() before barrier() is highly recommended. For any other memory type than shared, the specification is very clear on that the approprimate memory barrier must be used.

많은 예제(tutorials)들이 공유메모리를 syncronization하려 barrier를 호출한다. 하지만 실행의 시점[이 글이 써진 시점을 말하는 듯]에서 OpenGL ES specification은 이 것을 명확히 하고 있지 않고 있고, 이런 상황을 피하기 위해 memoryBarrerShared()를 barrier()이전에 사용하는 것을 아주아주 권장한다. shared 종류가 아닌 다른 memory 종류에는, 알맞은 메모리 베리어를 사용되어야 한다는 것은 아주 당연하다.

Flow control and barrier()

흐르제어와 barrier()

barrier() is a quite special function due to its synchronization properties. If one of the threads in the work group does not hit the barrier(), it conceptually means that no other thread can ever continue, and we would have a deadlock.

barrier()는 그 것의 synchronization기능 때문에 좀 특별한 함수다. workgroup내의 쓰레드들 중 하나가 barrier()에 도착하지 않으면, 그 것은 개념적으로 다른 쓰레드가 계속될수 없다는 것이다, 그리고 우리는 deadlock에 처한다.

These situations can arise in divergent code, and it's necessary for the programmer to ensure that either every thread calls the barrier, or no threads call it. It's legal to call barrier() in a branch (or loop), as long as the flow control is dynamically uniform. E.g.:

이런 상황을 수정된 코드에서 발생시킬 수 있다(코드를 수정하여 만들수 있다.). 프로그래머는 모든 쓰레드가 barrior가 호출할지, 아니면 어떤 쓰레드도 호출하지 않을 지를 확실히 할 필요가 있다. branch(or loop)에서 barrier()를 호출하는 것은 합법이다. 흐름제어는 변할 수있는 uniform에 따라

if (someUniform > 1.0)
{
    // Fine, since this condition will fail or pass for every thread.
    // 좋다. 이 조건은 모든 쓰레드가 통과하거나 실패할 것이다.
    barrier();
}



if (someVariable > 1.0)
{
    // Can be problematic, unless the branch is guaranteed to be taken or not for every thread the same way.
    // branch(if)가 모든 쓰래드가 만족할 지 말지를 보증하지 않는 다면, 문제가 있을 수 있다. 
    barrier();
}

 

Compute-like functionality in other graphics stages

이 거는 번역도 안할 거임, ComputeShader가 아닌 다른 곳에서 쓰는 것이라..

Replies
Reply Write