Parallel Computer Architecture and Programming: Lecture 2 - 1-19-18 - Carnegie Mellon University
Instruction Stream Coherence를 Cache Coherence와 혼동 금지
이번 파트에서는 일반적인 Computer Architecutre/Parallel Program에 대해서 알아보기 위해 다음과 같이 N개의 Floating-Point 배열의 모든 Sin 값을 구하는 예제를 사용하려고 한다. gcc로 컴파일 되는 아래 프로그램은 단순히 하나의 Processor에서 하나의 Thread로 실행될 것이다. (현재의 코드는 아무런 Parallelism이 표현되어 있지 않다.)
void sinx(int N, int terms, float* x, float* result){
for(int i=0; i<N; i++){
float value = x[i];
float numer = x[i] * x[i] * x[i];
int denom = 6; // 3!
int singn = -1;
for(int j=1; j<=terms; j++){
value += sign * numer / denom;
numer *= x[i] * x[i];
denom *= (2*j+2) * (2*j+3);
sign *= -1;
}
result[i] = value;
}
}
일반적으로 간단한 Processor는 매 Clock마다 하나의 Instruction을 실행한다. 하지만 이전 파트에서 언급한 SuperScalar Processor는 Instruction-Level Parallelism(ILP)을 통해 매 Clock마다 두 개의 Instruction을 해독하고 실행이 가능하다.
초기의 Multi-Core에서는 하나의 Instruction Stream을 빠르게 수행하도록 돕는 연산을 수행하기 위해 많은 Chip Transistor를 사용하곤 했다. Transistor가 많을수록 Larger Cache, Smarter Out-Of-Order Logic, Smarter Branch Predictor 등 특징을 가졌다.
초기와는 다르게 최근에는 Single Instruction Stream을 가속화하는 Processor Logic의 정교함을 높이기 위해 Transisotr를 사용하기 보다 Processor에 더 많은 Core를 추가하기 위해서 더 많은 Transistor Count를 사용한다.
생각하기와는 다르게 초기의 좋은 Core보다 각각의 Core는 하나의 Single Instruction Stream을 실행할 때 더 느리다(e.g. 0.75 times as fast). 하지만 지금은 Multi-Core가 되면서 잠재적으로 속도가 향상된다고 볼 수 있다(P * 0.75).
아래에서 pthread는 프로그래머가 직접 Parallelism을 위해 Thread를 생성하는 반면, forall은 프로그래머에 의해 독립적으로 선언된 Loop Iterations의 정보를 통해 pthread와 다르게 어떻게 Compiler가 자동적으로 Parallel Thread Code를 생성한다.
typedef struct{
int N;
int terms;
float* x;
float* result;
}my_args;
void parallel_sinx(int N, int terms, float* x, float* result){
pthread_t thread_id;
my_args args;
args.N = N/2;
args.terms = terms;
args.x = x;
args.result = result;
pthread_create(&thread_id, NULL, my_thread_start, &args); // lanuch thread
six(N - args.N, terms, x + args.N, result + args.N); // do work
pthread_join(thread_id, NULL);
}
void my_thread_start(void* thread_arg){
my_args* thread_args = (my_args*)thread_arg;
sinx(args->N, args->terms, args->x, args->result); // do work
}
void sinx(int N, int terms, float* x, float* result){
// declare independent loop iterations
forall(int i from 0 to N-1){
float value = x[i];
float numer = x[i] * x[i] * x[i];
int denom = 6; // 3!
int singn = -1;
for(int j=1; j<=terms; j++){
value += sign * numer / denom;
numer *= x[i] * x[i];
denom *= (2*j+2) * (2*j+3);
sign *= -1;
}
result[i] = value;
}
}
Processor에 존재하는 산술 논리 장치(Arithmetic Logic Unit, ALU)의 수를 증가시켜 Instruction Stream 관리의 Cost와 Complexity에 도움을 준다.
초기의 Compiler Program에서의 Instruction Stream은 Scalar Registers(e.g. 32bit floats)에서 Scalar Instructions을 사용하여 한 번에 하나의 배열 요소만 처리한다.
하지만 SIMD(Single Instruction, Multiple Data) 방법을 통해 Compute Capability를 증가시킬 수 있다. SIMD는 Parallel Processor의 한 종류로 하나의 명령어로 여러 개의 값을 동시에 계산하는 방식이다. 즉, 동일한 Instruction을 모든 ALU에게 Broadcast하고, 모든 ALU에서 병렬적으로 Instruction을 실행하는 것이다.
Intel의 AVX intrinsics를 이용하면 Vector Programming이 가능해진다. 즉, 256bit Vector Registers에서 N개의 배열 요소들을 Vector Instructions을 사용하여 동시에 처리할 수 있어진다.
Idea#1과 동일하게 Idea#2에서도 Data-Parallel을 표현할 수 있다. Compiler는 Loop Iterations이 독립적인 것으로 이해하고, 아래의 Loop Body는 많은 데이터에서 실행되어질 수 있다. 즉, Data-Parallel은 Multi-Core Parallel Code와 Core안에서의 SIMD Processing Capabilities을 사용하기 위한 Vector Instructions 모두를 자동 생성하여 활용할 수 있도록 한다.
#include <immintrin.h>
void sinx(int N, int terms, float* x, float* sinx)
{
float three_fact = 6; // 3!
for (int i=0; i<N; i+=8)
{
__m256 origx = _mm256_load_ps(&x[i]);
__m256 value = origx;
__m256 numer = _mm256_mul_ps(origx, _mm256_mul_ps(origx, origx));
__m256 denom = _mm256_broadcast_ss(&three_fact);
int sign = -1;
for (int j=1; j<=terms; j++)
{
// value += sign * numer / denom
__m256 tmp = _mm256_div_ps(_mm256_mul_ps(_mm256_broadcast_ss(sign),numer),denom);
value = _mm256_add_ps(value, tmp);
numer = _mm256_mul_ps(numer, _mm256_mul_ps(origx, origx));
denom = _mm256_mul_ps(denom, _mm256_broadcast_ss((2*j+2) * (2*j+3)));
sign *= -1;
}
_mm256_store_ps(&sinx[i], value);
}
}
void sinx(int N, int terms, float* x, float* result){
// declare independent loop iterations
forall(int i from 0 to N-1){
float value = x[i];
float numer = x[i] * x[i] * x[i];
int denom = 6; // 3!
int singn = -1;
for(int j=1; j<=terms; j++){
value += sign * numer / denom;
numer *= x[i] * x[i];
denom *= (2*j+2) * (2*j+3);
sign *= -1;
}
result[i] = value;
}
}
하지만 만약 코드 내에 조건문이 존재한다면 어떻게 될까? 결과적으로 일부 ALU는 조건문에 의해서 제한되기 때문에 모든 ALU의 성능을 사용하지 못한다. 하지만 분기 부분을 지나게 되면 정상적으로 모든 ALU의 성능을 사용할 수 있다.
왜 일반적인 Processor에는 여러 Cache를 가지고 있을까? 그 이유는 Latency를 줄이는 것(Reducing)에 목적을 두고 있다. Processor는 Memory와의 상호작용보다 Cache에 존재하는 데이터를 효율적으로 실행시킬 수 있다. 또한 Cache는 CPU에 데이터를 전달하기에 높은 Bandwidth를 가지고 있어 결과적으로 Latency를 줄일 수 있다.
모든 일반적인 CPU는 Cache에서 데이터를 Prefetch하기 위한 Logic을 가지고 있다. 동적으로 프로그램 접근 패턴을 분석하여 다음에 어떠한 데이터가 접근할 것인지를 예측하는 것이다. 이것은 위의 Cache와는 다르게 직접적으로 Latency를 줄이는 것보다 줄여지는 것(Hiding)처럼 보이게 한다. 또한 만약 접근하려는 데이터가 Cache에 존재한다면 Stalls 현상을 줄일 수도 있다. 다만, 예측을 Logic에 의한 예측이 잘못된 경우 오히려 성능을 떨어뜨릴 수 있다. 즉, Bandwidth를 낭비하거나 Cache를 불필요한 데이터로 채울 수 있는 문제가 발생한다.
Multi-Threading은 Stalls 상황을 줄이기 위해 동일한 Core에서 여러 Thread의 Interleave Processing을 수행한다. 또한 Prefetching과 마찬가지로 Latency를 줄이는게 아니라 줄이는 것처럼 보이게 하기도 한다. Throughput-Oriented System의 핵심적인 요소는 여러 Thread를 실행할 때 전체적인 System Throughput을 늘리기 위해 하나의 Thread에서 하나의 작업을 완료하는데 걸리는 시간을 잠재적으로 늘린다.
Hardware-Supported Multi-Threading은 여러 Thread를 Core가 Execution Context를 관리한다. 즉, OS가 Thread를 관리하는 것이 아니라 매 Clock마다 어떠한 Thread를 실행시킬지를 Processor가 결정한다는 것이다. 또한 Core는 여전히 동일한 개수의 ALU를 가지고 있어 Multi-Threading은 Memoery Access처럼 High-Latecny Operation에서 효율적으로 사용할 수 있도록 돕는다. Temporal Multi-Threading이라고 불리는 Interleaved Multi-Threading은 매 Clock마다 Core에 의해 선택된 하나의 Thread는 여러 ALU에서 Instruction을 실행하게 된다. Simultaneous Multi-Threading(SMT)는 매 Clock마다 Core가 여러 ALU에서 여러 Thread의 Instructions을 선택한다. 이것은 SuperScalar CPU 디자인의 확장으로 볼 수 있다.
만약 Processor가 상당히 높은 Bandwith으로 데이터를 요구하게 된다면 어떻게 될까? Memory System은 이를 따라갈 수 없게 된다. 즉, 더이상 Latency Hiding은 이러한 상황을 도와줄 수 없게 된다. 이러한 Bandwidth Limit을 해결하려는 문제는 Throughput-Optimized System에서 Application 개발자에게 상당히 흔한 것이다.
Bandwidth는 상당히 중요한 자원이기 때문에 우리는 조금이나마 문제를 해결하기 위해 두 가지 방법을 지키는 것이 좋다. 첫 번째, 고성능의 병렬 Program은 Memory에서 데이터를 덜 자주 가져오도록 연산을 구성하는게 좋다. 즉, 동일한 Thread에서 불러진 이전의 데이터를 재사용하거나 Thread 간에 데이터를 공유하는 것이다. 두 번째, 데이터를 덜 자주 요청하는 것이 좋다. 즉, 데이터를 요청하는 것보다 산술적인 수학 연산의 비중을 높이는 것이다. 왜냐하면 산술적인 연산은 Bandwidth에 제약이 없기 때문이다.