Poplar Tutorial1 :Programs and Variables

Sungho Kim·2023년 11월 3일

IPU Programming

목록 보기
2/7

1. 소개

GitHub의 Graphcore examples repo on GitHub 저장소에 예제 프로그램과 튜토리얼이 포함되어 있다.

Tutorials: Poplar SDK 및 Graphcore 도구를 사용하여 IPU에서 코드를 실행하는 데 도움이 되는 튜토리얼
Feature examples: IPU용 코드를 개발할 때 다양한 소프트웨어 기능을 사용하는 방법을 보여주는 코드 예제
Simple application examples: IPU를 대상으로 하는 다양한 프레임워크로 작성된 기본 애플리케이션

repo에는 technical notes, 비디오 및 블로그 에 사용되는 코드도 포함되어 있습니다.

이 문서는 GitHub에서 쉽게 탐색할 수 있는 튜토리얼 목록이다. 특별히 poplar 부분의 C++ SDK 사용을 위해서 기계번역하고 발췌하였다. 원래 문서는 여기에 있다.

1.1. Prerequisites

이 문서dp 따라 작업하기 전에 IPU 시스템에 로그인할 수 있어야 한다. 자세한 내용은 시스템 시작 안내서에 나와 있습니다.

1.2. 이 튜토리얼 실행하기

다음 두 가지 방법으로 이 튜토리얼을 실행할 수 있습니다.

  • Python 코드에서 직접
  • Jupyter 노트북에서

각 튜토리얼에는 관련 지침이 제공되지만 아래의 자세한 설정 가이드도 제공됩니다.

이 시리즈는 IPU에서 Poplar와 C++ SDK에 중점을 두므로 VS Code를 활용하는 것이 편하다. 이하 내용은 Tutorials의 5장 Poplar 부분이다.

2. POPLAR

2.1. Poplar Tutorial 1: Programs and Variables
2.2. Poplar Tutorial 2: Using PopLibs
2.3. Poplar Tutorial 3: Writing Vertex Code
2.4. Poplar Tutorial 4: Profiling Output
2.5. Poplar Tutorial 5: Matrix-vector Multiplication
2.6. Poplar Tutorial 6: Matrix-vector Multiplication Optimisation

2.1. 포플러 튜토리얼 1: 프로그램 및 변수

이 튜토리얼을 시작하기 전에 IPU 프로그래머 가이드를 읽어 IPU 아키텍처에 익숙해지십시오 . 해당 섹션인 Poplar 및 PopLibs 사용자 가이드: 포플러 프로그래밍 모델에서 포플러 프로그래밍 모델에 대해 자세히 알아볼 수 있습니다.

이 튜토리얼에서는 다음을 수행합니다. 소스코드는 여기 있습니다.

  • IPU 프로그래밍을 위한 Graphcore의 하위 수준 C++ Poplar 라이브러리 구조
  • 그래프, 변수 및 프로그램을 사용하여 IPU에서 계산을 실행하는 방법
  • 스트림을 사용하여 호스트 CPU와 IPU 간에 데이터를 효율적으로 교환하는 방법
  • IPU에 데이터를 전달하고 추가하는 작은 예제 프로그램 완성
  • 선택적으로 IPU 하드웨어에서 이 프로그램 실행

이 튜토리얼의 마지막 부분에는 간략한 요약과 추가 리소스 목록이 포함되어 있습니다.

설정

IPU에서 이 튜토리얼을 실행하려면 Poplar SDK 환경을 활성화해야 합니다(IPU 시스템 시작 안내서 참조).
또한 C++11 표준과 호환되는 C++ 도구 체인이 필요합니다. 이 튜토리얼의 빌드 명령은 GCC를 사용합니다.

tut1_variables/start_here 작업 디렉터리로 사용하여 tut1.cpp 코드 편집기에서 엽니다.

파일에는 main 함수, 일부 Poplar 라이브러리 헤더 및 poplar namespace가 포함된 C++ 프로그램의 개요가 포함되어 있습니다.

Graph, variables, and programs

Poplar 프로그램은 세 가지 주요 구성 요소로 구성됩니다.

  • Graphs: 특정 하드웨어 장치를 지정합니다.
  • Variables: Graph의 일부이며 IPU 실행하는 데이터를 저장합니다.
  • Program: Graph와 Variables에 적용되는 작업을 제어합니다.

Creating the graph

모든 Poplar 프로그램에는 Computational Graph를 구성하는 Graph Object가 필요합니다. Graphs는 항상 특정 Target을 위해 생성됩니다(여기서 Target은 IPU와 같이, 대상 하드웨어를 설명하는 것임). Target을 얻으려면 device를 선택해야 합니다.

기본적으로, 포플러 튜토리얼에서는 시뮬레이션된 Target을 사용합니다. 결과적으로 Graphcore 하드웨어가 연결되어 있지 않더라도 모든 컴퓨터에서 실행할 수 있습니다. Graphcore 가속기 하드웨어가 있는 시스템에서, 헤더 파일 poplar/DeviceManager.hpp 에는 연결된 하드웨어에 대한 Device Object를 열거하고 반환하는 API 호출이 포함되어 있습니다. 시뮬레이션된 devices는 IPUModel 클래스를 가지고 생성되는데, 호스트 상의 IPU 기능을 모방한다. 이 createDevice 메서드는 작업할 새 virtual device를 만듭니다. 장치가 존재하면 이를 target으로 하는 Graph Object를 생성할 수 있다.

다음 코드를 main 본문에 추가하세요.

// Create the IPU Model device
IPUModel ipuModel;
Device device = ipuModel.createDevice();
Target target = device.getTarget();

// Create the Graph object
Graph graph(target);

IPUModel 은 IPU 하드웨어 자원을 사용하지 않고 Poplar 프로그램을 작성하고 디버깅하는 편리한 방법을 제공하지만 IPU 하드웨어를 완벽하게 표현하는 것은 아닙니다. 따라서 가능하다면 IPU를 사용하는 것이 좋습니다.

IPUModel 의 제한 사항에 대한 설명은 Poplar 개발자 가이드에 나와 있습니다 . 이 자습서 예제와 하드웨어를 함께 사용하는 방법에 대한 지침은 이 자습서의 마지막 섹션 (선택 사항: IPU 사용)에서 확인할 수 있습니다.

변수 추가 및 IPU 타일에 매핑

IPU에서 실행되는 모든 프로그램이 작동하려면 데이터가 필요합니다. 이는 Graph에서 Variables로 정의됩니다.

  • 다음 코드를 추가하여 프로그램의 첫 번째 변수를 만듭니다.
// Add variables to the graph
Tensor v1 = graph.addVariable(FLOAT, {4}, "v1");

이렇게 하면 float 유형의 4개의 elements가 있는 하나의 vector variable을 Graph에 추가합니다. 마지막 문자열 매개변수인 "v1" 은 디버깅/프로파일링 도구에서 데이터를 식별하는 데 사용됩니다.

세 가지 variable을 더 추가합니다.

  • v2: 4개의 floats로 구성된 또 다른 벡터.
  • v3: float 타입의 2차원 4x4 텐서.
  • v4: 10개의 interger로 구성된 하나의 벡터(INT 타입).

중요한 점은 addVariable의 반환 타입은 Tensor 입니다. Tensor 타입은 device에 저장된 다차원 Tensor 형식을 나타냅니다. 이 유형은 전체 변수를 참조하는 데 사용되며, 나중에 살펴보겠지만 부분적인 variable 조각이나 여러 variabl로 구성된 데이터를 참조하는 데에도 사용할 수 있습니다.

Variable는 tiles 에 할당되어야 합니다. 한 가지 방법은 전체 변수를 하나의 tile 에 할당하는 것입니다.

  • 다음 코드를 추가하세요.
// Allocate v1 to reside on tile 0
graph.setTileMapping(v1, 0);

대부분의 경우 프로그램은 실제로 여러 tiles에 분산된 데이터를 처리합니다.

  • 다음 코드를 추가하세요.
// Spread v2 over tiles 0..3
for (unsigned i = 0; i < 4; ++i)
  graph.setTileMapping(v2[i], i);

setTileMapping 호출은 v2 변수의 하위 텐서를 여러 tile 에 분산시킵니다.

다른 tile에 v3v4 를 할당하는 코드를 추가하세요.

제어 프로그램 추가

이제 Graph에 몇 가지 변수를 만들었습니다. device에서 실행할 제어 프로그램을 만들 수 있습니다. ProgramProgram 클래스의 하위 클래스로 표시됩니다. 이 예제에서는 여러 단계를 순차적으로 실행되는 Sequence 하위 클래스를 사용할 것입니다.

  • 다음 선언을 추가하세요.
// Create a control program that is a sequence of steps
program::Sequence prog;

// Debug print the tensor to the host console
prog.add(program::PrintTensor("v1-debug", v1));

여기서는, Sequence에는 device에 있는 데이터를 호스트를 통해 debug print를 수행하는 한 단계만 있습니다.

이제, Graph와 program이 작성되었으므로, 이 프로그램을 device에 배포하면 어떤 일이 발생하는지 확인할 수 있습니다. 그러기 위해서는 먼저 Engine Object 를 생성해야 합니다.

  • 코드에 추가하세요:
// Create the engine
Engine engine(graph, prog);
engine.load(device);

이 Object는 장치에서 실행할 준비가 된 컴파일된 GraphProgram 을 나타냅니다.

  • 제어 프로그램을 실행하려면 엔진 초기화 후에 다음 코드를 추가하세요.
// Run the control program
std::cout << "Running program\n";
engine.run(0);
std::cout << "Program complete\n";

poplar 실행 파일 컴파일하기

main 함수의 첫 번째 버전이 완료되어 컴파일할 준비가 되었습니다.

  • 터미널에서 호스트 프로그램을 컴파일합니다( -lpoplar 플래그를 사용하여 poplar 라이브러리에 연결하는 것을 잊지 마세요).
g++ --std=c++11 tut1.cpp -lpoplar -o tut1
  • 컴파일된 프로그램을 실행합니다.
./tut1

프로그램이 실행되면, 디버그 출력은 초기화되지 않은 값을 출력합니다. 이는 Graph의 variable에 초기화 되거나 쓰여지지 않은 variable을 할당했기 때문입니다.

v1-debug: [0.0000000 0.0000000 0.0000000 0.0000000]

변수 초기화 중

Graph에서 데이터를 초기화하는 한 가지 방법은 constant 값을 사용하는 것입니다. variable과 달리 constant는 컴파일 시에 Graph에 설정됩니다.

  • Graph에 vaiable을 추가하는 코드 뒤에 다음을 추가합니다.
// Add a constant tensor to the graph
Tensor c1 = graph.addConstant<float>(FLOAT, {4}, {1.0, 1.5, 2.0, 2.5});

이 선은 요소에 표시된 값이 있는 그래프에 새로운 상수 텐서를 추가합니다.

  • tile 0의 c1 에 데이터를 할당합니다 .
// Allocate c1 to tile 0
graph.setTileMapping(c1, 0);
  • 이제 다음을 sequence program 에 추가하세요. Program의 PrintTensor 바로 앞에 추가합니다.
// Add a step to initialise v1 with the constant value in c1
prog.add(program::Copy(c1, v1));

device의 tensor 간에 데이터를 복사하는 사전 정의된 Copy 제어 프로그램을 사용할 것입니다. constant tensor c1v1 variable에 복사하면, v1 에는 c1 과 동일한 데이터가 저장됩니다.

IPU 프로그래머 가이드에 설명된 IPU 실행의 synchronization 및 exchange phase는 Poplar 라이브러리 함수에 의해 자동으로 수행되므로 명시적으로 지정할 필요가 없습니다.

프로그램을 다시 컴파일하고 실행하면 초기화된 값을 보여주는 디버그 인쇄가 표시됩니다

v1-debug: [1.0000000 1.5000000 2.0000000 2.5000000]

Copy 는 변수 간에도 사용할 수 있습니다.

  • v1 디버그 인쇄 명령 뒤에, 다음을 추가합니다.
// Copy the data in v1 to v2
prog.add(program::Copy(v1, v2));
// Debug print v2
prog.add(program::PrintTensor("v2-debug", v2));

이제 프로그램을 실행하면 v1v2 가 모두 동일한 값으로 인쇄됩니다.

Device 안팎으로 데이터 가져오기

처리할 대부분의 데이터는 constant가 아니며 대개 호스트에서 얻게 된다. 호스트에서 Device 안팎으로 데이터를 가져오는 방법에는 두 가지가 있다. 가장 간단한 방법은 tensor에 연결된 읽기 또는 쓰기 핸들을 만드는 것이다. 이를 통해 호스트는 해당 variable과 직접 데이터를 주고받을 수 있다.

  • v3 변수 에 대한 읽기 및 쓰기 핸들을 생성하는 코드를 engine 생성 명령 앞에 추가하라.
// Create host read/write handles for v3
graph.createHostWrite("v3-write", v3);
graph.createHostRead("v3-read", v3);

이러한 핸들은 engine이 생성된 후에 사용됩니다.

engine 생성 명령 뒤에 다음 코드를 추가합니다.

// Copy host data via the write handle to v3 on the device
std::vector<float> h3(4 * 4, 0);
engine.writeTensor("v3-write", h3.data(), h3.data() + h3.size());

여기서는 h3는 호스트 데이터를 보관하며(0으로 초기화됨), writeTensor 호출은 PCIe 버스를 통해 device의 tensor에 대한 synchoronous write를 수행합니다. device의 v3 은 호출 후에 0으로 세팅된다.

engine.run(0) 호출 다음에 아래를 추가하세요.

// Copy v3 back to the host via the read handle
engine.readTensor("v3-read", h3.data(), h3.data() + h3.size());

// Output the copied back values of v3
std::cout << "\nh3 data:\n";
for (unsigned i = 0; i < 4; ++i) {
  std::cout << "  ";
  for (unsigned j = 0; j < 4; ++j) {
    std::cout << h3[i * 4 + j] << " ";
  }
  std::cout << "\n";
}

여기서는, device에서 데이터를 호스트에 다시 복사하고 출력합니다. 프로그램을 다시 컴파일하고 실행하면 모두 0 이 인쇄됩니다(device의 프로그램이 v3 변수를 수정하지 않기 때문입니다).

h3 data:
  0 0 0 0
  0 0 0 0
  0 0 0 0
  0 0 0 0

장치에서 v3 가 수정되면 어떤 일이 발생하는지 살펴보자. 다시 Copy 사용하지만 Tensor 타입의 유연한 데이터 참조 기능도 살펴보기 시작합니다 .

  • v1과 v3의 slice를 생성하기 위한 다음 코드를 추가하세요. v3에 대한 호스트 읽기/쓰기 핸들 생성 직후에 다음 코드를 추가합니다 .
// Copy a slice of v1 into v3
Tensor v1slice = v1.slice(0, 3);
Tensor v3slice = v3.slice({1,1},{2,4});

Tensor이 선은 그래프의 데이터를 참조하는 새 개체를 생성합니다 . v1 이는 새 상태를 생성하지 않고 및 의 일부만 참조합니다 v3.

이제 다음 복사 프로그램을 추가하세요.

prog.add(program::Copy(v1slice, v3slice));

이 단계에서는 의 v1중간에 세 개의 요소를 복사합니다 v3. 결과를 보려면 프로그램을 다시 컴파일하고 다시 실행하세요.

h3 data:
  0 0 0 0
  0 1 1.5 2
  0 0 0 0
  0 0 0

Data streams

기계 학습 애플리케이션의 훈련 및 추론 중에 호스트에서 IPU로 데이터를 효율적으로 전달하는 것은 높은 처리량을 활성화하는 데 중요한 경우가 많습니다. Device 안팎으로 데이터를 가져오는 가장 효율적인 방법은 데이터 스트림을 사용하는 것입니다( 자세한 내용은 Poplar 및 PopLibs 사용자 가이드: 데이터 스트림 참조). Poplar에서는 데이터 스트림을 생성하고 Graph에서 명시적으로 이름을 지정해야 합니다. 아래 코드 조각에서는 FIFO(first-in-first-out) 입력 스트림을 추가하고, 이를 메모리 버퍼(길이 30의 벡터)에 연결한 다음 해당 버퍼의 10개 요소 청크를 device에 스트리밍합니다.

프로그램 정의에 다음 코드를 추가하세요.

// Add a data stream to fill v4
DataStream inStream = graph.addHostToDeviceFIFO("v4-input-stream", INT, 10);

// Add program steps to copy from the stream
prog.add(program::Copy(inStream, v4));
prog.add(program::PrintTensor("v4-0", v4));
prog.add(program::Copy(inStream, v4));
prog.add(program::PrintTensor("v4-1", v4));

이 명령어는 입력 스트림에서 변수 v4 로 두 번 복사됩니다. 각 복사 후 v4 는 호스트로부터 새 데이터를 가지게 된다.

engine이 생성된 후 데이터 스트림은 호스트의 데이터에 연결되어야 합니다. 이는 Engine::connectStream 함수를 통해 달성됩니다.

엔진 생성 후 다음 코드를 추가하세요.

// Create a buffer to hold data to be fed via the data stream
std::vector<int> inData(10 * 3);
for (unsigned i = 0; i < 10 * 3; ++i)
  inData[i] = i;

// Connect the data stream
engine.connectStream("v4-input-stream", &inData[0], &inData[10 * 3]);

여기서는 데이터 circular buffer로 사용하기 위하여 stream을 호스트의 데이터 버퍼에 연결했습니다. 프로그램을 다시 컴파일하고 실행하면, 스트림에서 복사할 때마다 호스트 메모리 버퍼에서 복사된 새 데이터가 v4에 유지되는 것을 볼 수 있습니다.

v4-0: [0 1 2 3 4 5 6 7 8 9]
v4-1: [10 11 12 13 14 15 16 17 18 19]

(Optional) IPU 사용

이 섹션에서는 IPU 하드웨어를 사용하도록 프로그램을 수정하는 방법을 설명합니다. 필요한 유일한 변경 사항은 IPU를 사용할 수 있는지 확인하고 이를 획득하는 것과 관련됩니다.

새 파일을 복사하여 tut1.cpptut1_ipu_hardware.cpp 에 복사하고 에디터에서 열어보겠습니다.

  • 다음 선언을 제거하세요.
#include <poplar/IPUModel.hpp>
  • 다음을 추가하세요.
#include <poplar/DeviceManager.hpp>
#include <algorithm>
  • main 시작 부분에서 다음 줄을 바꿉니다 .
// Create the IPU Model device
IPUModel ipuModel;
Device device = ipuModel.createDevice();

아래 코드를 사용하면:

// Create the DeviceManager which is used to discover devices
auto manager = DeviceManager::createDeviceManager();

// Attempt to attach to a single IPU:
auto devices = manager.getDevices(poplar::TargetType::IPU, 1);
std::cout << "Trying to attach to IPU\n";
auto it = std::find_if(devices.begin(), devices.end(), [](Device &device) {
   return device.attach();
});

if (it == devices.end()) {
  std::cerr << "Error attaching to device\n";
  return 1; //EXIT_FAILURE
}

auto device = std::move(*it);
std::cout << "Attached to IPU " << device.getId() << std::endl;

이는 호스트에 연결된 단일 IPU로 구성된 모든 device 목록을 가져오고 성공할 때까지 각 장치에 차례로 연결을 시도합니다. 이는 호스트에 여러 사용자가 있는 경우 유용한 접근 방식입니다. getDevice 함수와 함께 device-manager ID를 사용하여 특정 device를 가져오는 것도 가능합니다 .

  • 이제 프로그램을 컴파일할 준비가 되었습니다.
g++ --std=c++11 tut1_ipu_hardware.cpp -lpoplar -o tut1_ipu_hardware
  • 프로그램을 실행하고 동일한 결과를 확인하세요.
./tut1_ipu_hardware

IPU 하드웨어를 사용하기 위해 다른 튜토리얼의 프로그램을 비슷하게 수정할 수 있습니다.

요약

이 튜토리얼에서는 Poplar를 사용하여 Graphcore IPU를 대상으로 하는 간단한 애플리케이션을 구축하는 방법을 배웠습니다. 우리는 Graph객체를 사용하여 텐서를 IPU의 특정 타일에 매핑하고 Sequence클래스를 사용하여 간단한 작업으로 프로그램을 정의했습니다. 마지막으로 데이터 스트림을 사용하여 데이터를 장치에 전달하고 작업 결과를 호스트 CPU 프로세스로 다시 반환했습니다.

이 자습서에 사용된 이 프로세스와 클래스는 Poplar 및 PopLibs 사용자 가이드: 포플러 라이브러리 사용에 요약되어 있습니다 .

이 세 단계는 Poplar 애플리케이션의 기초를 형성하며 다음 자습서에서 재사용됩니다. 두 번째 튜토리얼 에서는 Poplar의 수학 및 텐서 연산을 포함하는 Graph 및 program의 정의를 간소화하는 popops 라이브러리 사용법을 배웁니다.

이 튜토리얼에서 논의된 IPU의 프로그래밍 모델에 대해 자세히 알아보려면 IPU 프로그래머 가이드를 참조 하거나 Poplar 및 PopLibs 사용자 가이드를 참조하십시오 . 자세한 내용은 API 설명서를 참조하세요 . Graphcore는 또한 일반적인 Python 딥 러닝 프레임워크인 PyTorch 및 TensorFlow 2를 사용하여 IPU의 새로운 사용자를 대상으로 하는 튜토리얼을 제공합니다 .

profile
오복, 무심

0개의 댓글