2015/09/18

A quick guide to writing OpenCL kernels for PowerVR Rogue GPUs (by Doug Watt)

PowerVR Rogue GPU에서  OpenCL 커널 작성을 위한 간단한 가이드라인
(PowerVR 사의 Rogue GPU에 특화된 내용이 많지만, OpenCL과 관련된 fundamental한 내용들이 좋아서 번역하였다)

This article is written in Korean which translates an article written by Doug Watt. 

This provides the background to understand the programming guidelines for the Rogue architecturewhich are illustrated by using a case study of an image filtering program.

문서는 이미지 필터링 프로그램의 적용 사례를 설명한 “Rogue 아키텍처(GPU)에서의 프로그래밍 가이드라인 이해하기 위한 배경 지식을 설명하고 있다.

OpenCL overview
Consider a simple C program that multiplies the corresponding elements in two 2-dimensional arrays:
다음과 같이 2차원 배열의 값들을 곱하는 간단한 C 프로그램이 있다
void matrix_mul_cpu(const float *a, const float *b, float *c, int l, int h)
{
   int i;
   for (i=0; i<l; i++)
   for (j=0; j<h; j++)
   c[i][j] = a[i][j] * b[i][j];
}
The for loop consists of two parts: the vector range (two dimensions containing l*h elements), and a multiplication operation. All loop iterations are independent in that there are no data-dependencies between any iterations of the loops. This means that the multiplication operations can be executed in parallel on the GPU’s numerous threads.
For 루프문은 이중으로 구성되어 있다 벡터 영역과 연산. 모든 루프의 과정은 각각의 루프 내에서 데이터들 사이에 의존성이 없다. 이는 연산이 GPU 수많은 스레드들 위에서 병렬로 처리가 가능하다는 뜻이다.
OpenCL programs consist of two parts: one that runs on the host CPU, the other that runs on the GPU device. To execute code on the GPU, you first define an OpenCL kernel, which is written in a variant of C with some additional keywords and data types. Continuing with the above example, an OpenCL kernel that implements a single multiplication is as follows:

OpenCL 프로그램들은 개의 파트로 이루어져 있다. 하나는 host CPU 에서 동작하고, 다른 하나는 GPU 디바이스에서 동작한다.(사실 GPU 뿐만 아니라 다른 병렬 처리를 위한 벡터 디바이스를 모두 지원하는걸 목표로 한다. 역자주) 코드를 GPU 위에서 실행하기 위해 가장 먼저 할일은 OpenCL 커널을 정의하는 것이다. 커널은 C 언어를 확장한 것으로 여러 키워드들과 데이터 타입이 추가가 언어이다(OpenCL C 언어를 말한다. 역자주) 위의 예제로 돌아가서 OpenCL 커널은 아래의 코드처럼 줄의 연산으로만 구현된다.


_kernel void vector_mul(__global const float *a, __global const float *b, __global float *c)
{
  size_t i = get_global_id(0);
  size_t j = get_global_id(1);
  c[i][j] = a[i][j] * b[i][j];
}



The host program launches (or enqueues) this kernel, spawning a virtual grid of concurrent work-items as illustrated below for a 2D grid of size 512 (32×16). The grid is an N-dimensional range where N=1, 2 or 3 (or NDRange). A single work-item executes for each point on the NDRange; each work-item executes the same kernel code but has a unique ID that can be queried and used to determine the data to be processed. This ID is obtained using the built-in function get_global_id.

호스트 프로그램은 커널을 실행(또는 enqueuer 한다)하고, 아래 그림에 표현된 것처럼 512(32x16) 크기의 2D grid 처럼 동시 다발적인 work-item 가상 그리드를 생성한다. grid N-차원 영역 이다.(N= 1, 2, 또는 3, (또는 NDRange)). 하나의 단일 work-item ND Range 위의 각각의 지점을 수행한다. 각각의 work-item 동일한 커널 코드를 수행하지만, 연산을 해야할 대상 데이터를 지정하는데 사용하는 특정한 ID 갖는다. ID get_global_id() 라는 내장 함수에서 사용된다.




In OpenCL, multiple work-items are grouped together to form workgroups. In the figure above, each workgroup size is 8×4 comprising a total of 32 work-items. Work-items in a workgroup can synchronize with one another and share data using local memory (to be explained in a later article).
OpenCL 에서는 여러 개의 work-item 들이 하나의 workgroup 생성한다. 위의 그림에서 각각의 workgroup 사이즈는 8x4 이고 32개의 work-item들을 묶어놓은 것이다. 하나의 workgroup 안의 work-item 들은 각각의 다른 work-item 들과 동기화를 하는데 이때 데이터를 공유하는데 사용되는 것이 local memory이다.(추후 설명하겠다)

OpenCL execution on the PowerVR Rogue architecture
PowerVR Rogue 아키텍쳐(GPU)에서 OpenCL 실행하기

As shown below, from an OpenCL perspective, PowerVR GPUs are built around scalable arrays of multithreaded processors called Unified Shading Clusters (USCs). When a program running on the CPU enqueues an OpenCL kernel, all work-items in the NDRange are enumerated. The workgroup IDs and work-item IDs are enqueued sequentially in row-major order. The Coarse Grain Scheduler assembles the work-items into groups of up to 32 threads, termed warps, and streams these warps to the multiprocessors with available execution capacity. The warps containing the work-items for a complete workgroup are always allocated to a single multiprocessor.
아래 그림에 보여지듯이 OpenCL 관점에서는 PowerVR GPU들은 확장 가능한 멀티 스레드 프로세서들의 연속이다. (Unified Shading Clusters 라고 부른다. USCs)
하나의 프로그램이 CPU위에서 동작하고 OpenCL 커널을 enqueue 하였을 , NDRange 내부의 모든 work-item들이 쭉 나열된다. Workgroup ID 들과 work-item ID들은 row 방향부터 순차적으로  enqueue 된다. Coarse Grain Scheduler라고 불리는 스케줄러는 work-item들을 최대 32 개의 스레드(termed warps) 그룹 안에 모으고, warp 들을 실행 가능한 멀티 프로세서들에 보낸다. warp 들은 완전한 하나의 workgroup 해당하는 work-item들을 포함하고 있으며, 항상 하나의 멀티프로세서에 할당된다.







The multiprocessors execute the warps obtained from the Coarse Grain Scheduler, with the assistance of one or more Texture Processing Unit (TPU), L1 and L2 cache units. The precise number and grouping of these hardware blocks is product-specific.
멀티프로세서들은 Coarse Grain Scheduler 로부터 포함된 warp들을 수행한다. 이때 Texture Processing Unit(TPU), L1, L2 캐시 유닛들이 이를 지원한다. 이러한 하드웨어 그룹과 정밀도 숫자들은 제품마다 다르다.
As shown below, each multiprocessor contains a number residency slots that at any time are either empty (illustrated as a grid with a dotted line) or occupied by a warp (illustrated as a grid with a solid line and colour fill). Each warp can be in one of three states: active (green fill) which means it is currently running on the execution unit; ready (orange fill) which means the scheduler may run the warp on the execution unit after executing the active warp; and blocked (red fill) which means that one or more of the work-items in the warp is awaiting a memory or barrier operation to complete. In this example, the multiprocessor has six warps to schedule: the first is active, four are ready to be executed and a single warp is blocked on a memory or barrier operation.

아래 그림에서 보여지듯, 각각의 멀티프로세서들은 수많은 residency slot들을 포함하며(그림에서 점으로 표시된 영역) 슬롯들은 때로는 비어 있거나, warp 의해 선점 당한다.(그림에서 색깔로 칠해진 영역). 각각의 warp 3가지의 상태를 가질수 있다. Active(초록) 현재 execution unit(실행 유닛)위에서 동작 . Ready(오렌지색) active warp 실행이 끝난 다음 스케줄러가 execution unit 위에서 실행 시킬 있는 대기 상태. Blocked(빨강) warp 안의 하나 또는 이상의 work-item들이 작업 완료를 위해 메모리 연산이나 barrier 연산을 기다리고 있는 상태이다.
그림의 예시에서, 멀티프로세서들은 스케줄링 있는 여섯 개의 warp들을 갖는다. 번째는 active, 개는 ready, 하나는 block 상태이다.







A multiprocessor can execute at most one warp on its execution unit at any one time. The work-items in the active warp are usually executed together in lock-step parallel as shown below, and continue executing either until completion or until the work-items become blocked on a memory or barrier operation. In this example, all 32 work-items in the active warp execute the first kernel statement together and then all progress on to the second kernel statement together.
하나의 멀티프로세서는 프로세서 내의 execution unit 위에 한번에 단지 하나의 warp 실행할 있다. active warp 안의 Work-item 들은 아래 그림에서처럼 lock-step 안에서 함께 실행된다. 그리고 work-item 들이 메모리 연산이나 barrier 연산에 의해 block되거나, 작업이 끝날 때까지 실행 된다. 아래 그림 예시에서 active warp 안의 모든 32개의 work-item 들은 번째 커널 statement 함께 수행한 , 계속해서 번째 statement 수행한다.






Full lock-step parallelism is achieved when all threads in a warp follow the same control path. If the threads diverge via a conditional branch, as illustrated in the figure below, the work-items stop executing together lock-step and instead the hardware serially executes each branch path taken. In this example, all work-items in the warp follow separate paths (work-item 0 executes case 0, work-item 1 executes case 1 and so on), resulting in a 32-way branch divergence. A more common scenario is an if-else statement whereby some of the work-items follow the if-statement and the other work-items follow the else-statement, resulting in a two-way divergence. In this case the compiler and hardware can minimise the impact of the divergence by using hardware predication. The compiler translates the code sequence whereby the branch condition is first calculated (true orfalse) and then both the if and else target instructions are executed in sequence using the branch condition as an instruction predicate. In this way, all of the instructions are executed, but only the instructions with a true predicate write results.

Full lock-step 병렬화는 하나의 warp 안에 모든 스레드들이 같은 연산 로직을 따를 가능하다. 만약 스레드들이 조건문에 의해 분기가 되면(아래 그림 참조), work-item들은 lock-step 수행하고 멈춘다. 그리고 나서 하드웨어가 순서대로 각각의 branch 수행한다. 예시에서,  warp 안의 모든 work-item들은 별도의 path(work-item 0 case 0 수행, work-item1 case 1 수행) 수행한다. 그리고 결과들은 32 가지의 다양한 결과를 내놓는다.   일반적인 시나리오는 if-else 문인데 몇몇 work-item들이 if문을 따르고, 다른 몇몇 work-item들은 else문을 수행할 , 가지의 분기를 생성한다.   때엔 컴파일러와 하드웨어는 특정 하드웨어 명령(hardware predication) 이용해 분기의 영향을 최소화 하는 최적화 작업을 한다. 컴파일러가 분기문 영역의 코드 시퀀스를 먼저 계산하고(true or false), 다음 if else 모두 instruction 셋에 의해 분기문을 순차적으로 실행한다. 방법으로 모든 instruction들이 수행되지만 true 해당하는 instruction 결과를 내놓는다.






Whenever a warp is descheduled, for example due to completion, a memory or barrier operation, the multiprocessor selects another resident warp that is ready to execute. In the figure below, the first warp has reached a statement that reads from an array allocated to system memory (mem), preventing it from continuing until some point in the future when the data has been fetched; the multiprocessor is able to continue performing useful work during this time by selecting one of the other four ready warps to execute.
하나의 warp 스케줄링이 되지 않을 때마다(예를 들면 연산이 종료되거나, 메모리 접근 연산 또는 barrier 연산에 의해), 멀티프로세서는 실행 가능한 다른 잔여 warp 선택한다. 아래 그림에서, 번째 warp system memory 할당된 배열로부터 데이터를 읽는 statement 도달하였고, 나중에 데이터 fetch 완료될 때까지 이상의 진행을 멈춘다. 시간 동안 멀티프로세서는 다른 개의 ready 상태의 warp 선택해서 작업을 계속 수행할 있다.




The execution state for each work-item (and, therefore, each warp) is maintained on-chip for the entire lifetime of the work-item in the unified store; shared local memory is held in the common-store(both shown in above). As a result, a multiprocessor can context switch between warps with zero cost. The figure below shows an example timeline for a multiprocessor scheduling between four warps. The scheduler starts executing warp 0, which runs until it reaches a blocking operation such a read or write to system memory. At this time the scheduler de-schedules warp 0 and starts executing warp 1. The work-items in all warps implement the same kernel, and therefore have similar performance characteristics, reflected in the similar (phase-adjusted) timelines in the example. At a time around half way into execution of warp 2, the operation that warp 0 was blocked on completes, so that warp 0 returns to a ready state. Later, after the scheduler has executed the first statements in warp 3, the scheduler re-schedules warp 0. The effect of this concurrent scheduling is that the impact of memory latency when processing each warp is hidden. In this scenario, the system is able to completely hide the memory latency.

각각의 work-item 실행 상태는 unified store 안에 work-item 라이프 타임 동안에 한번에 관리가 된다. 공유된 local memory common-store 묶인( 위에서 보았다.. 뭔소리지?). 결론적으로, 하나의 멀티프로세서는 별도의 비용 없이 warp 사이에 context switch 있다. 아래 그림은 개의 warp 간의 멀티프로세서 스케줄링 타임라인을 보여준다. 스케줄러를 보면 처음 warp0 수행하고 시스템 메모리를 읽거나 쓰는 연산을 수행하면서 block 때까지 스케줄링 된다. 때에 스케줄러는 warp0 스케줄링을 취소하고 warp1 수행한다. 모든 warp들에 work-item들은 같은 커널을 구현하였다. 따라서 비슷한 성능 특성을 보여주고 예제에서 비슷한 타임라인을 보여준다. Warp 2 실행 되는 구간의 절반 정도 되는 부분에서 warp 0 연산의 block 끝났기 때문에 warp 0 다시 ready 상태로 돌아간다.  나중에 스케줄러가 warp 3 번째 실행을 수행하고 마친 이후에, warp 0 다시 스케줄링 한다. 동시다발적인 스케줄링 시나리오 그림은 사실 각각의 warp 수행될 때 메모리 지연(memory latency)이 무시되었다 . 시나리오의 시스템에서 메모리 지연(memory latency)은 완전히 무시한다고 가정한다.





The figure below shows a similar scenario but where the round-trip-time to memory is larger. In this case, by the time warp 3 blocks on its first memory operation, warp 0, 1 and 2 are all still waiting for their memory operations to complete. All warps are therefore all blocked at the same time, with a corresponding reducing in utilization and performance.
아래 그림에서는 비슷한 시나리오를 보여주지만 메모리가 round-trip-time 예시이다. 여기에서 warp 3 번째 메모리 연산에서 블락이 되었을 , warp 0, 1, 2 여전히 메모리 연산이 끝나길 기다리고 있다. 따라서 모든 warp들이 동시에 블록 되고, utilization 성능이 모두 감소한다.


In this case, utilization could be improved if there were more than four resident warps on the multiprocessor. The total number of warps that a multiprocessor can maintain on-chip depends on factors such as the time it takes to execute each warp and the memory requirements of each warp. If a work-item allocates a large amount of private memory, the total number of work-items (and, therefore, warps) that can reside on a multiprocessor may be limited by the size of the unified store.
상황에서 만약 멀티 프로세서에 여분의 warp들이 존재한다면 utilization 향상될 있다. 멀티프로세서가 하나의 칩에서 관리할 있는 Warp 개수는 각각의 warp 수행하는데 드는 시간이나, 메모리 요구사항 등에 달려 있다. 만약 work-item 아주 private 메모리를 할당하면, 멀티프로세서 위에 남아 있을 있는 work-item 수는 unified store 크기에 제한될 수도 있다.
Similarly, if a workgroup allocates a large amount of shared local memory, the total number of workgroups that can reside on a multiprocessor may be limited by the size of the common store.
비슷한 예로, 만약 workgroup 사이즈의 shared local memory 할당하면, 멀티프로세서에 남아있는 workgroup 역시 common store 사이즈에 제한될 있다.

Programming guidelines
프로그래밍 가이드라인

RECOMMENDATION: If possible use a workgroup size of 32.
가능하면 workgroup 크기를 32개로 유지하라

If permitted by a kernel’s logical structuring, you are advised to use a workgroup size of 32. In this case, each warp contains a complete workgroup and all synchronization costs are free (they all happen together on the same cycle in lock-step). For larger workgroup sizes, each workgroup is implemented using multiple warps, and barrier synchronization requires the multiprocessor to perform context switches between these warps. For smaller workgroup sizes such as 4, 8 and 16, if the workgroup size is specified at compile-time, multiple workgroups are packed into a warp. For non-standard workgroup sizes, alignment restrictions on how the hardware can pack the workgroups into warps means that the warps are underpopulated with idle threads, reducing efficiency.
만약 커널에 로직 구조상 허용된다면, workgroup 사이즈를 32개로 정하는 것을 권장한다. 이렇게 하면 각각의 warp들은 하나의 완전한 workgroup 포함하며, 모든 warp들의 동기화 비용이 없다(lock-stop 내에서 같은 사이클에 동시에 수행된다). workgroup 크기를 정하면 각각의 workgroup 다중 warp 구현되며, 이럴 경우 barrier 동기화 연산은 warp 간의 context switch 수행하게 한다.  4, 8, 16 같이 작은 workgroup 사이즈일 경우(workgroup 크기가 컴파일 시에 정해질 ) 다중 workgroup들이 하나의 warp 묶여진다. 표준 workgroup 사이즈들을 사용하게 되면, 하드웨어가 workgroup warp 안에 묶을 있는 alignment 제한들이 생기게 되는데, 결국 warp 수가 감소하게 되고 이는 warp들이 idle thread 효율성의 감소를 야기한다.

RECOMMENDATION: Specify your workgroup size at compile-time.
workgroup 사이즈를 컴파일 시에 정하라

For a given kernel, you can specify the workgroup size for which it can be enqueued by specifying the following attribute.
하나의 커널에서 프로그래머는 아래와 같이 attribute 정의함으로써 한번에 enqueue 되는 workgroup 사이즈를 정의할 있다.
__attribute__((reqd_work_group_size(X, Y, Z)))
The workgroup size is the product of X, Y and Z. Specifying a fixed workgroup size allows a number of additional compile-time optimizations that improve performance such as reducing the number of instructions required for barrier synchronization.
Workgroup 사이즈는 X, Y, Z 의해 정해진다. 고정된 workgroup size 미리 정의하게 되면 컴파일 시에 몇몇 최적화 효과를 얻을 있는데, 예를 들면 barrier 동기화 연산에 사용되는 명령어(instruction)셋의 수를 줄일 수가 있다.



RECOMMENDATION: For image processing tasks, declare your kernel parameters as image and sampler data types (as opposed to character arrays) to benefit from TPU acceleration.
이미지를 처리하는 태스크에서는 커널 파라미터를 image sampler 데이터 타입으로 선언해라.(문자열 배열은 사용하지 말라). 이는 TPU 가속의 효과를 얻을 있다.

As discussed previously, the TPU provides efficient sampling of image data, image interpolation, border-pixel handling. Specifying border-pixel handling in the sampler avoids the need for special conditional code within the kernel to handle these edge conditions, reducing divergence. Using image parameters also enables your kernel to be efficiently incorporated as part of a larger heterogeneous program.
앞에서 언급 했듯이 TPU image interpolation, border-pixel handler 같은 효율적인 이미지 데이터의 sampling 효과를 제공한다. sampler에서 border-pixel handling 정의하면 커널 내에서 edge condition들을 처리하기 위한 특정한 조건문 코드 사용을 피할 있고, 분기를 줄일 있다. 이미지 파라미터를 사용하는 또한 커널이 이기종 프로그램의 일부로써 효율적으로 포함되도록 있다.



RECOMMENDATION: Use float data types inside your kernel to maximize ALU throughput.
ALU 유닛의 대역폭을 최대한 활용하기 위해 커널 내에서 float 데이터 타입을 사용하라

On Rogue devices, each thread can perform floating-point operations on up to two datasets per cycle, or it can perform integer operations on a single dataset. In practice, there is usually sufficient instruction-level parallelism in the kernel code for the compiler to generate dual-issue floating-point instructions. To maximize arithmetic throughput, you should therefore perform arithmetic on float data types wherever possible.
Rogue GPU 에서는 각각의 스레드들이 사이클에 개의 데이터 셋으로 floating-point 연산을 수행한다. 하지만 정수 연산에서는 하나의 데이터 셋으로 수행한다. 실제로는 컴파일러가 Floating-point 명령어의 dual-issue 만들기 위해 대부분 커널 내에서 충분한 명령어 레벨에서의 병렬화가 이루이지긴 한다. 따라서 산술 연산의 성능을 최대화 하기 위해 프로그래머는 가능한 float 데이터 타입의 연산을 하도록 커널을 작성해야 한다.
 Use the built-in function read_imagef to sample a pixel from an OpenCL image. This function uses hardware to fetch the pixel’s constituent values (either r/b/g or y/u/v values) and write these values into elements of the returned float4 vector.
OpenCL 이미지의 pixel 접근할 read_imagef()내장 함수를 사용해라. 함수는 하드웨어를 사용해서 픽셀의 값들을 접근하고 float4 타입 벡터에 값을 쓴다.



RECOMMENDATION: Trade precision for speed wherever possible. Use the compiler flag
-cl-fast-relaxed-math to enable arithmetic optimizations that trade precision for speed.
가능하다면 속도를 위해 정밀도를 조절하라. 컴파일러 flag –cl-fast-relaxed-math 이용하면 정밀도를 조절할 있으며 산술 연산 최적화가 가능하다.


Depending on the precision requirements of the application, you can often improve speed by limiting the use of arithmetic operations with long execution times.
어플리케이션에서 요구되는 산술 정밀도(배정밀도 ) 경우에 따라 다르므로 실행 시간이 오래 걸리는 산술 연산 사용에 제한을 둠으로써 속도 향상을 얻을 있다.
·         Use the -cl-fast-relaxed-math build option, which enables many aggressive compiler optimizations for floating-point arithmetic.
·         -cl-fast-relaxed-math 옵션을 사용하여 빌드를 하면, floating-point 산술 연산들에 대해 상당히 강력한 컴파일러 최적화가 이루어진다.

·         Use native_* and half_* math built-ins, which have lower precision but are faster than their un-prefixed variants.
·         native_*  half_* 시작하는 내장 함수들을 사용하면 낮은 정밀도를 사용하지만 훨씬 빠른 속도를 얻을 있다.





RECOMMENDATION: Minimize conditional code.
조건문을 최소화 하라

In general you should minimise conditional code, especially with regards to nested conditional statements. Flow control statements (if, switch, do, for, while) can reduce instruction throughput by causing threads to diverge in their execution paths requiring the hardware to serialize execution of work-items in a warp. The compiler attempts to reduce the effect of divergence using hardware predication.
일반적으로 프로그래머는 조건문을 최대한 적게 사용해야 한다. 특히 if-else 난무하는 nested 조건 문은 지양하라. Flow control statement들은(if, switch, do, for, while) 각각의 스레드들이 하나의 warp안에서 work-item 순차 실행을 위해 하드웨어에 요구하는 명령들이 많아지고 다양해 지기 때문에 명령어의 성능을 저하시킨다. 컴파일러는 hardware predication 이용해 분기를 최적화 하려고 한다.



RECOMMENDATION: Avoid short kernels with low arithmetic intensity.
산술 연산이 적은 짧은 커널 작성을 피하라

In general, the smaller the ratio of the number of arithmetic to memory instructions (or arithmetic intensity), the higher the occupancy is required to hide the memory latency. If this ratio is 50, for example, then to hide a memory access latency of about 500 clock cycles about 10 warps are required. If the multiprocessor contains 16 residency slots, this translates to a required occupancy of around 63%.
일반적으로 메모리 연산(또는 산술 집중도. Arithmetic intensity라고 한다) 대한 산술 연산의 비중이 작을수록 메모리 지연을 죽이기 위한 비용이 높아진다. 만약 비율이 50%라고 하면, 10개의 warp들에 대해 500 clock 사이클이 메모리 접근 지연을 처리하기 위해 요구된다. 만약 멀티프로세서가 16개의 잔여 slot 포함하고 있다면 이는 63% 점유율을 요구한다.

Barrier instructions can also force the multiprocessor to idle as more and more warps wait for other warps in the same workgroup to complete execution of instructions prior to the barrier. Having multiple resident workgroups in the multiprocessor can help reduce idling in this case, as warps from different workgroups do not wait for each other at barriers. To increase the number of resident workgroups per multiprocessor, consider reducing your workgroup size.Barrier 명령어 또한 barrier 명령 이전에 연산을 처리하기 위해 workgroup 내의 다른 warp 기다리도록 강제할 있다. 멀티프로세서 안에 여러 개의 여분의  workgroup 있다면 다른 workgroup warp들이 각각의 barrier 연산을 기다릴 필요 없기 때문에 idling 줄일 있다. 멀티프로세서당 잔여 workgroup 수를 늘리기 위해서는 workgroup size 줄여야 한다.

No comments:

Post a Comment