티스토리 뷰

Parallel Hardware

 

 


Coordination

각 코어들은 작업을 협력적으로 수행(이를 Coordination이라 한다)해야 하는데, 다음을 주의해야 한다.

  • Communication : 통신, 프로세스 간 데이터를 전달할 수 있어야 한다.
  • Load balancing : 부하 분산, 한 프로세스만 고역에 시달리게 두어선 안 된다.
  • Synchronization : 동기화, 한 프로세스의 작업이 다른 프로세스들 보다 지나치게 앞서 있어선 안된다.

 


SISD

단일명령-단일자료(SISD, Single Instruction, Single Data )은 전산에서 한 프로세서가 한번에 하나의 명령어를 처리할 때 하나의 메모리에 저장되어 있는 한 데이터를 이용하여 처리하는 것을 일컫는 용어입니다. 폰 노이만 구조에 해당합니다. SISD 는 플린의 분류학에서 정의된 4개 분류중의 하나입니다. SISD는 각 데이터를 처리하기 위해서 매번 명령어를 읽어야 하기때문에(한번에 하나의 명령어를 처리하므로) 효율이 떨어집니다. 그렇지만 파이프라이닝과 같이 동시 처리를 함으로써 성능을 향상시키는 것이 일반적입니다. 즉, 명령어가 순서대로 실행되지만 실행 과정은 여러 개의 단계들로 나누어 중첩시켜 실행 속도를 높이도록 파이프라인으로 되어 있습니다. 

 


 

SIMD

 

SIMD가 사용되는 대표적인 예로 Vector ProcessorsGPU가 있습니다.SIMD(Single Instruction Multiple Data)는 병렬 프로세서의 한 종류로, 하나의 명령어로 여러 개의 값을 동시에 계산하는 방식입니다. 벡터 프로세서에서 많이 사용되는 방식으로, 비디오 게임 콘솔이나 그래픽 카드와 같은 멀티미디어 분야에 자주 사용됩니다. CPU에서는 인텔의 MMX, 스트리밍 SIMD 확장(SSE)과 AMD의 3D나우! 등의 기술에서 이를 적용했다.(data parallelism)

 

 

vector processor

 

벡터 프로세서(Vector processor) 또는 어레이 프로세서(Array processor)는 벡터라고 불리는 다수의 데이터를 처리하는 명령어를 가진 CPU를 말한다.(SIMD) 컴퓨터에서 벡터란 1차원 배열의 데이터를 뜻한다. 벡터 프로세서와 반대되는 말로는 스칼라 프로세서가 있는데 한 개의 데이터를 처리하는 명령어를 가진 프로세서를 말한다. 대부분의 CPU는 스칼라 프로세서이다.

벡터 프로세서는 1970년대 처음 나타났으며 1980년에서 1990년대 동안 슈퍼 컴퓨터의 기본적인 형태였다. 스칼라 프로세서, 특히 마이크로프로세서에서 성능을 높이기 위해 벡터 프로세서 기술을 도입한 CPU가 1990년대 초 나타났다. 오늘날 대부분의 CPU는 MMX, SSE, 알티벡(AltiVec) 같이 다수의 데이터를 처리하는 벡터 프로세싱을 위한 SIMD(Single Instruction Multiple Data) 명령어를 갖추고 있다. 벡터 프로세서 기술은 그래픽 가속기나 게임 콘솔에서도 찾아 볼 수 있다. 2000년 IBM, 도시바, 소니가 개발한 셀(Cell) 프로세서는 1개의 스칼라 프로세서와 8개의 벡터 프로세서로 구성되어 있으며 플레이스테이션 3에 사용되었다.

벡터 프로세싱을 위한 또 다른 CPU 디자인으로 다수의 명령어로 다수의 데이터를 처리하는 MIMD (Multiple Instruction Multiple Data) 가 있지만 전문적인 용도로만 사용될 뿐 일반적인 용도로 쓰이지는 않았다.

 

 

GPU(Graphic Processing Unit)

 

그래픽 처리 파이프 라인은 내부 표현을 컴퓨터 화면으로 보낼 수있는 픽셀 배열로 변환한다. 이 파이프 라인의 여러 단계 (셰이더 함수라고 함)는 프로그래밍 가능하다. 셰이더 함수는 병렬적으로 처리되며, 그래픽 스트림 안의 여러 요소에 진입할 수 있다. GPU는 SIMD를 이용해서 성능을 최적화 한다. GPU는 상대적으로 작은 문제에 대해서 더 안좋은 성능을 내게 된다.

 

 

메모리 인터리빙(interleaved memory)

메모리 인터리빙(memory interleaving)은 주기억장치(DRAM)를 접근하는 속도를 빠르게 하는데 사용된다. 메모리 인터리빙 기법은 인접한 메모리 위치를 서로 다른 뱅크(bank)에 둠으로써 동시에 여러 곳을 접근할 수 있게 하는 것이다. 메모리 인터리빙은 블록 단위 전송이 가능하게 하므로 캐시나 기억장치와 주변장치 사이의 빠른 데이터 전송을 위한 DMA(Direct Memory Access)에서 많이 사용한다. Distrubute elements of a vector across multiple banks, so reduce or eliminate delay in loading/storing sucessive elements.

 

 

메모리 뱅크란?

 

A memory bank is a logical unit of storage in electronics, which is hardware-dependent. In a computer, the memory bank may be determined by the memory controller along with physical organization of the hardware memory slots. In a typical synchronous dynamic random-access memory (SDRAM) or double data rate synchronous dynamic random-access memory (DDR SDRAM), a bank consists of multiple rows and columns of storage units, and is usually spread out across several chips. In a single read or write operation, only one bank is accessed, therefore the number of bits in a column or a row, per bank and per chip, equals the memory bus width in bits (single channel). The size of a bank is further determined by the number of bits in a column and a row, per chip, multiplied by the number of chips in a bank. 하닁 채널 안에서 하나 또는 그 이상의 메모리의 논리적 묶음입니다. 메모리의 테이터는 채널을 거쳐 캐시로 전달됩니다. 메모리 뱅크1과 메모리뱅크 2에 동일 메모리를 꼽아놓았다면 듀얼 채널이 구성되어 채널1, 채널2를 동시에 사용할 수 있습니다. 대역폭이 2배가 되는 것입니다. 이 경우 메모리에 데이터가 저장되는 방식은 메모리 뱅크 1에 짝수 번지주소, 메모리 뱅크 2에 홀수 번지 주소 이렇게 번갈아 가면서 저장이 됩니다.

 

 

SIMD Drawbacks

 

 

 


Shared Memory System

프로세서들은 interconnection network를 통해 메모리 시스템에 연결됩니다. 이 때 각 프로세서들은 각자 따로 메모리 주소에 접근이 가능합니다. 프로세서는 일반적으로 Shared Memory 구조에 액세스하여 데이터를 교환한다. 가장 널리 사용되는 Shared Memory 시스템은 하나 이상의 멀티 코어 프로세서를 사용합니다. (단일 칩에 여러 개의 CPU 또는 코어가 있음) Shared Memory System에는 Normal, UMA, NUMA 이렇게 3가지 종류가 있습니다. 아래는 가장 단순한 shared memory system 구조입니다.

 

다음아래는 UMA multicore system입니다.

모든 코어는 같은 시간에 메모리에 접근하게 됩니다.

 

아래는 NUMA multicore system입니다.

NUMA는 UMA구조보다 메모리가 더 클 경우에 각 칩들마다 사용가능한 메모리 구간을 나눠놓은 것입니다. 이 코어가 직접 연결되어있는 메모리 위치는 다른 칩을 통해 액세스해야하는 메모리 위치보다 빠르게 액세스 할 수 있습니다.


Distributed Memory system

분산 메모리 시스템에서 각 프로세서는 자체 메모리를 가지고 있으며 프로세서-메모리 쌍은 interconnection network를 통해 통신합니다. 통신은 프로세서 쌍들간에 메시지를 송수신함으로써 명시적으로 구현됩니다.

 

Clusters : 프로세서-메모리 쌍들을 Clusters라 하고 이들은 모두 범용 interconnection network로 연결됩니다. 즉 클러스터의 노드는 각각 통신 네트워크에 의해 결합된 독립적인 컴퓨터입니다.(하이브리드 시스템이라고 합니다)

 


Interconnection Network

다음으로 두가지 종류가 있습니다.

 

Shared Memory Interconnects

Distributed Memory Interconnects

 

 


Shared Memory Interconnects

Bus, Switched 2가지 방식이 있습니다.

  • Bus Interconnect
    비용이 싸다.
    버스에 연결된 장치의 수가 증가하면 성능이 저하된다.
  • Switched Interconnect (Cross bar)
    비용이 많이 든다.
    서로 다른 장치 간에 동시적인 통신이 가능합니다. 아래는 크로스 바에 관한 그림입니다.
     크로스 바의 구조가 (a)와 같을 때, (b)처럼 연결을 제어하여 결국은 (c)처럼 동시적으로 통신한다는 의미이다.

Distributed Memory Interconnects

분산 메모리는 기본적으로 프로세스끼리 메모리를 따로 가지고 있다는 것을 의미한다. 눈여겨 보아야 할 것은 이런 상황에서 다른 프로세스가 가지고 있는 메모리에 어떻게 해야 효율적으로 접근할 수 있을까 입니다.

여기에는 Direct, Indirect 2가지 방식이 있습니다.

 

Direct interconnect

Direct interconnect 모델은 스위치가 프로세서-메모리쌍에 직접 연결되어 있고, 스위치들 간에도 상호 연결된 구조입니다. 그 종류로는 Ring, Toroidal mesh, Fully Connected, Hypercude 이렇게 4가지 종류가 있다. 

 (a)는 ring 형이며, (b)는 toroidal mesh 형이라고 한다. toroidal mesh는 (a)에서 차원을 확장 시켜놓은 형태로 양 극점까지 전부 다 연결해 놓은 구조를 의미한다. 이 때 Worst case에 얼마나 많은 커뮤니케이션이 동시에 발생할 수 있는 가를 Bisection Width로 나타내는데, Bisection Width이 클수록 더 좋은 성능을 낸다.

예를 들어 아래의 Ring 구조의 경우를 살펴보자. 

 A와 B는 서로 다른 Device를 의미한다. (a)는 best case에 해당하고 (b)는 worst case에 해당한다. Bisection Width는 worst case에 해당하고 이 경우 A-B 쌍의 개수는 고작 2개이다. 따라서 위 그림과 같은 구조에서 Bisection Width는 2 이다.

또, toroidal mesh도 같은 방식으로 Bisection Width를 구할 수 있다. 

 worst case는 toroidal mesh를 정 중앙으로 자르는 직선과 만나는 점의 개수이다. 즉 Bisection Width는 8 이다. 프로세서 개수를 \({p}\) 라고 할 때 toroidal mesh 에서는 Bisection Width가 \(\frac{p}{2}\) 라고 생각하면 편하다.

Fully Connected Network 모델을 생각해보자. 구조는 다음과 같다. 

 이 그림에서 Bisection Width는 얼마나 될까? 직접 세어보면 알겠지만 9개이다. Fully Connected에서는 \({\frac{p^{2}}{4}}\)으로 Bisection Width를 구할 수 있다. 하지만 이 모델은 연결되는 링크 수 에 비해 Bisection Width가 적어서 실용적이지 못하다.

다음으로 Hyper Cube모델을 살펴보자. 구조는 다음과 같다. 

 (a) > (b) > (c) 순서로 차원이 높아지는 구조이다. 차원이 높아졌다는 것은 곧 한 노드에서 다른 노드로 나가는 링크의 개수가 증가했다는 뜻이다. 만약 n차원 만큼 Hypercude를 확장시켰다고 했을 때 Bisection Width는 \(\frac{p}{2}\) 로 계산되어 진다.

 

 

 

Indirect interconnect

 

모든 스위치가 프로세서와 직접 연결되지는 않은 구조이다. Crossbar  Omega Network 이렇게 2종류가 여기에 해당 된다.

Crossbar부터 살펴보자. crossbar는 언뜻 보면 전부 이어져 있는 것 같지만 사실은 그렇지 않다. 각 노드가 프로세서에 해당되는 것이 아니라 그냥 데이터의 흐름을 컨트롤 해주는 녀석들이기 때문이다. 따라서 Crossbar와 프로세서를 같이 나타내면 다음 그림과 같다. 

 굳이 분석해 보지 않아도 엄청나게 비효율적인 모델이라는 것은 명백해 보인다. 프로세서 개수를 \({p}\) 라고 할 때 크로스바를 구현하기 위해 필요한 스위치의 개수는 \({p}^{2}\) 이다.

 Crossbar의 단점을 보완하고자 나온 모델이 바로 Omega Network이다. Butterfly Switch라는 예쁜 이름으로도 불린다. 어디가 나비 같은지 모르겠는데 아무튼 닮았다고 한다. 구조는 아래 그림과 같다. 

 그림에서 보여주듯이 왼쪽 그림의 동그라미가 오른쪽 그림(switch)에 해당한다. 스위치의 개수가 크로스바보다 줄어들게 되는데, 오메가 네트워크를 구현하기 위해 필요한 스위치의 개수는 \(2p\log{_2}{p}\)로 나타내어 진다. 결국 오메가 네트워크는 크로스바보다 비용이 훨씬 더 적게 드는 장점을 가지고 있는 모델이라고 할 수 있다.

 

 


성능 평가

성능 측정은 결국 시간 측정으로 귀결된다. 이건 하드웨어 성능평가 할 때 신물나게 해봤을 것이다. 프로세서 네트워크에서는 서킷스위칭은 안하고 패킷스위칭은 한다. 서킷 스위칭은 링크를 점유하기 때문에 데이터가 전송하는 동안 다른 데이터가 들어갈 수가 없기 떄문이다. 이 때 네트워크상에서 날아다니는 패킷은 진짜 작은 바이트 정도의 크기이고 보통 Message라고 표현한다. 그렇다면 스위치 간 Message Transmission Time 은 어떻게 구할까? 이를 구하기 위해선 다음 2가지를 알아야 한다.

  • Latency : 데이터 출발에서 받을 때까지 걸리는 시간(준비하고 보내는 시간)
  • Bandwidth : 얼마만큼 한꺼번에 전송이 가능한가
    그냥 \(l\)이 Latency, \(n\)이 Message Length, \(b\)가 bandwidth 라고 하면 Message Transmission Time은 \(l+\frac{n}{b}\) 이다. 굳이 쉬운 걸 어렵게 표현했다는 생각이 들지만 넘어가기로 하자.

 


 

캐쉬 일관성, Cache Coherence

평범한 Shared Memory System 에서 우리는 아래와 같은 작업하려고 한다. 

 우리는 현재 x라는 변수를 공유하고 있다고 가정하자.
time 0에서 Core 0은 x를 읽어와서 캐시에 넣어둔 상황이다(사용했으므로). Core 1은 3 * x를 했으므로 y1  6 이 들어간다.
time 1에서 Core 0은 x에 7을 넣었긴 하지만 Core 1 에서는 x가 아직 2 에서 업데이트 되지 않았다.
time 2에서 Core 0은 x와 관련된 이벤트가 발생하지 않아 동기화를 시켜주지 않는다. Core 1에서는 그대로 x는 2이므로 z1에는 8이 들어간다.

이것은 결국 Cache coherence를 잘 지켜내지 못한 경우이고 이를 막기 위해 2가지 방식을 사용한다.

  • Snooping Cache Coherence : 캐시와 코어 사이에 메모리 버스를 두고 이벤트가 발생할 때마다 트렌젝션을 걸어 처리하게 만든다.
  • Directory Based Cache Coherence : 각 캐시 라인의 상태를 저장하는 directory라는 자료구조를 사용해 캐시 업데이트를 처리한다.

 


Parallel Software

앞으로 설명할 병렬 소프트웨어는 모두 MIMD를 기준으로 한다.
병렬적으로 프로그래밍을 한다는 것은 각 쓰레드(프로세스)에 작업배분(load balacing)을 잘해야 한다는 뜻이다. 이 때 필연적으로 쓰레드(프로세스) 간에 데이터를 주고 받아야 하는 경우가 생기는데 이를 보고 communication이라 한다. communication은 오버헤드가 매우 크므로 최소화해야 한다.

기본적으로 병렬 프로그래밍은 Nondeterminism 하다. 순서가 정해져 있지 않다는 것인데, 아래 작업을 보자. 

 아까와 마찬가지고 변수 x를 공유하고 있는 상황이다.
Time 4 에서 Core 0이 x = 7을 해주고 Time 5 에서 Core 1이 x = 19를 해주는데, 비록 Time 5에 x = 19를 먼저 넣어줬더라도 쓰레드의 진입시간은 정해져 있기 때문에 x에는 최종으로 7이라는 값이 들어갈 수도 있다. 이것이 바로 Nondeterminism이다.

이러한 Nondeterminism으로 인해 한 자원을 두고 서로 경쟁하는 Race Condition이 발생하는데, 이 상황에서 한 프로세스만 접근하게 할 수 있도록 Critical Section이라는 것을 만들어 냈다. 이는 특별한 하드웨어적 컨트롤이 있는 것이 아닌 프로세스 간의 약속이라고 생각해야 한다.

 Critical Section을 이용해 병렬적으로 프로그래밍하는 방식은 Busy-Waiting, Message Passing, PGAS 이렇게 3가지가 있다.

 

 


Busy-Waiting

가장 기본적인 Critical Section을 이용한 것이 Busy-Waiting이고 코드는 다음과 같다.

 

my_val = Compute_val( my_rank ) ;

if ( my_rank == 1) while ( ! ok_for_1 ); /* ok 신호가 떨어질 때까지 기다린다 */

x += my_val; /* Critical section, 쓰레드 1은 함부로 이 영역으로 들어올 수 없다. */

if ( my_rank == 0) ok_for_1 = true; /* ok 신호를 보낸다 */

 

Busy-wait loop로 구현을 하게 되면 공유자원이 선점되어 있는 동안 다른 쓰레드가 접근하지 못한다. 공유자원을 써도 되냐고 물어보는 빈도가 매우 빠르기 때문에 오히려 더 비효율적일 수도 있다.

 


Message Passing

Message Passing에서는 프로세스를 sleep시켰다가 wake up 시그널을 보내 깨우는 것을 구현했다. 시그널 통신방식을 사용하므로 Message Passing이라는 이름이 지어졌다.

 

char message [100];

...

my_rank = Get_rank(); // 프로세스 id를 구한다.

if ( my_rank == 1 ) { sprintf ( message , "Greetings from process 1" ) ;

    Send ( message , MSG_CHAR , 100 , 0 ); // thread 0 에게 message를 보낸다.

}

else if ( my_rank == 0 ) {

    Receive ( message , MSG_CHAR , 100 , 1 ); // thread 1 로부터 message를 받아 잠에서 깨어난다. printf ( "Process 0 >     Received: %s\n" , message ) ;

}


Partitioned Global Address Space (PGAS) Languages

요즘 에는 PGAS (피가스 ..라고 읽더라) 라는 걸 쓴다. 요즘 컴퓨터는 쿼드 코어 급으로 나오기 때문에 병렬 안에서 또 4개의 병렬이 생겨버린다. 이런 경우를 컨트롤하기 위해 만들어졌다.

 

shared int n = ...;

shared double x[n], y[n] ;

private int i , my_first_element , my_last_element ; my_first_element = ...;

my_last_element = ...; /* Initialize x and y */

...

for ( i = my_first_element ; i <= my_last_element; i++) x[i] += y[i];

 


Performance

우리는 추가한 프로세스 개수와 비례하게 성능이 선형적으로 향상될 것이라고 기대한다. 하지만 실제로 그렇지는 않다. 왜냐하면 프로세스를 추가할 수록 오버헤드가 늘어나기 때문이다.

이 이슈는 2가지로 설명할 수 있다.

  • 프로세스를 추가할 수록 효율이 점점 떨어지더라(프로세스 간 오버헤드가 증가하므로).
  • 문제의 스케일이 줄어들 수록 효율이 점점 떨어지더라. (바로 뒤에서 설명을 해줄 것이다) 가 바로 그것이다.

암달의 법칙

암달은 정확히는 성능의 최대치는 시리얼 섹션(Serial Section)의 크기를 넘어갈 수 없다는 것이 그 내용이다. 시리얼 섹션이란 프로그램 코드 중에서 병렬화 할 수 없는 부분을 의미하므로 어찌 보면 암달의 법칙은 매우 당연한 이야기다.

예를 들어 생각해보자. 90퍼센트의 코드만 병렬화를 시킬 수 있다면 시리얼 섹션은 그 중 10%가 될 것이다. 병렬화 한 쓰레드 개수가 \(p\), 병렬화 하지 않은 프로그램이 실행되는 데 걸리는 시간이 \(T\)고, 그 값이 \(20\)이라면 병렬화를 거칠 경우 그 프로그램이 실행되는 데 걸리는 시간은 \(0.9 * \frac{T}{p} + 0.1 * T = \frac{18}{p}+2\)이다.

이 때 성능향상(Speed up)은 다음과 같이 계산된다.

$$ S = \frac{T_{serial}}{0.9 * \frac{T_{serial}}{p} + 0.1 * T_{serial}} = \frac{20}{\frac{18}{p}+2} $$

그러나 암달은 문제 사이즈(problem size)를 고려하지 않았다. 앞 에서 문제 스케일(size)이 커지면 병렬 효율이 증가한다는 것을 생각하자. 위 식에서 문제 사이즈가 커지게 되면 $ T_{serial} $도 같이 증가해 분자가 커져버린다. 그래서 결국 상대적으로 더 좋은 효율을 가져갈 수 있다.

 


Scalability

문제 스케일을 조절할 수 있다면 결국 성능 향상을 꾀할 수 있다. (스케일러블하다 = 사이즈를 조절할 수 있다)

  • strongly scalable = 프로세스를 많이 투입하면 하는 대로 효율이 증가한다. (선형적으로)
  • weakly scalable = 상대적으로 그렇지 못하다. (효율 감소가 아님, 점진적 증가)

 


 

Taking Time, 시간 측정

퍼포먼스는 결국 병렬의 목적이라 했는데, 이를 측정하기 위해선 시간을 측정해야 한다. 언제 시작해서 언제 끝나느냐? 가 관건. 표준 C에서 제공하는 clock 함수는 사실 이상적인 런타임 시간은 아니다.

Wall Clock Time

Wall Clock은 장벽(start, finish) 2개 세워 놓고 그 사이 시간을 재는 것을 의미한다. C의 clock은 milli초 단위로 세기 때문에 성능측정이 잘 되지 않는다.

   

게다가 병렬 적으로 실행되므로 위의 코드는 사실 쓰레드(프로세스)마다 실행되는 것이다. 하지만 성능이라는 것은 각 프로세스의 성능을 평가하는 것이 아니라 병렬화된 전체 프로그램의 성능을 측정해야 하므로 위 방법은 별로 좋지 못하다. 게다가 이 경우 각자 프로세스는 시간을 재기 시작하는 시각이 제각각 틀리고 작업 마감 시간도 다 틀려지게 된다.

따라서 다음과 같은 방법을 쓴다.

   

성능은 결국 max(end[]) – min(start[])를 재야 하는데 min(start[])끼리 차이가 많이 나버리면 성능이 구려진다. min(start[])를 동기화 시켜줘야(쓰레드/프로세스가 동시에 출발하도록) 정확한 성능측정이 된다는 것이다. Barrier()는 바로 이 동기화에 쓰인다. 장벽에 모든 프로세스가 동작할 때 까지 잠깐 기다리세요 라는 의미이다. 이걸 거치게 되면 쓰레드/프로세스들의 start타임은 모두 똑같아 진다.

 


병렬 프로그램 디자인

Foster의 방법론에서 병렬 프로그램은 결국 작업을 할당하고, 서로 커뮤니케이션 하고, 작업하고, 종국에는 결과를 종합해주는 작업을 하게 된다.

가령 일련의 데이터 배열에서 각각 구간별로 원소의 분포를 조사해야 하는 문제가 있다고 하자. Serial Program에서는 for문 하나 돌면서 각 원소를 담는 새로운 공간에 하나씩 원소 count를 증가시키는 방식이 될 것이다.

이 작업을 4개로 분리하게 되면 배열을 4개로 쪼개 각 쓰레드/프로세스에 할당하고, 각 쓰레드/프로세스는 각각 원소를 담는 공간을 만들게 된다. 이후 카운트 하는 작업을 거친 뒤 나중에 하나로 합쳐주게 되는데, 합쳐줄 때는 본문 맨 처음 예시를 들었던 병렬화의 예시에서 처럼 진행한다.

결국 무엇이 중요한가?

다음 4가지 이슈가 병렬 프로그램 디자인에서 중요한 요소이다.

  • 속도 향상
  • 효율
  • 암달 법칙을 극복할 수 있는가?
  • 문제의 스케일을 더 키울 수 있는가?

MPI (Message Passing Interface)

 

 

출처 : https://ko.wikipedia.org/wiki/SISD, https://poqw.github.io/comarch2/

 

반응형