Intel GPU専用言語C for Metalの解説

2020年2月18日

アルバイトの西田です。
IntelのC for Metal(以前はC for Mediaという名前だったもの、略してC4MまたはCM)を、7月中はインターンとして、8月からはアルバイトとしてその使い方や性能を調べていました。
この記事はその成果をまとめたものです。

C for Metalとは

  • 一般的なGPUプログラミング言語で、Intel GPU上でアセンブリに近いパフォーマンスを発揮する(らしいです)。
  • プログラミングモデルはSIMDを利用しています(SIMDはシンプルなのでデータ並列プログラムを比較的簡単に書くことができる上に殆どの使用事例を満たすことができます)。
  • HW実行ユニット(EU)のビューをユーザーに公開するため、IntelGPUの潜在的な能力を最大限に活用することができます。
  • Intel GPUで動く画像処理技術の開発のためにインテル社内で使われていたものが、今回オープンソース化されました。

※MacのGPU言語のMetalとは関係ありません。

プログラムの構成

1.カーネルプログラム

  • C-for-Metal言語で書かれたソフトウェアプログラムです。
  • CMコンパイラーを通してIntelGenISA命令セットにコンパイルされます。
  • IntelGPUの実行ユニットでしか実行されません。

C for Metal言語の言語仕様については/documents/compiler/html/cmlangspec/cmlangspec.htmlに書いてあります。

2.ホストプログラム

  • カーネルプログラムの中身を実行ユニットで実行できるようにするためのC++のAPI関数セット。

大まかなプログラムはホストプログラム(vectoradd.cpp)に書いて並列処理を行う場所をカーネルプログラム(vectoradd_genx.cpp)に書きます。

環境構築の手順

まずはプラットフォームがmedia-driverに対応しているかの確認をしてください。

サポートされているプラットフォーム

  • BDW (Broadwell)
  • SKL (Skylake)
  • BXT (Broxton) / APL (Apollo Lake)
  • KBL (Kaby Lake)
  • CFL (Coffee Lake)
  • WHL (Whiskey Lake)
  • CML (Comet Lake)
  • ICL (Ice Lake)

Intel HD Graphics

※ここに書かれているプラットフォームでないとCforMetalが動かないので気をつけてください

事前にインストールする必要があるもの

  • make
  • g++

インストール方法

C for Metal公式サイトよりファイルをダウンロードする

1. パッケージのルートディレクトリに移動する

以降、ここを<package_root>とします。

2. driversフォルダ内のinstall.shを実行する

$ cd drivers    
$ sudo ./install.sh

3. メディアドライバーのインストール

drivers/media-driverからmedia-driverパッケージのインストールをします。

$ sudo dpkg -i intel-media-2.xxx.deb

CMアプリケーションの構築や実行に必要なlibva,libdrmライブラリもメディアドライバーとともにインストールされます。

4. インテルグラフィックコンパイラーのインストール

drivers/IGCからIGCパッケージをインストールします。

$ sudo dpkg -i IGC-xxx.deb

ビルドと実行(vectoradd)

1. IBVA_DRIVER_NAMEとCM_ROOTを定義する

$ export LIBVA_DRIVER_NAME=iHD
$ export LIBVA_DRIVERS_PATH=/usr/lib/x86_64-linux-gnu/dri
$ export CM_ROOT=<package_root>

2. 実行する

$ make
$ ./hw_x64.vectoradd

サンプルコードの概要

このサンプルプログラムは2つのvectorを読み込み、その和を出力するプログラムです。

<package_root>/compiler/bin/cmc vectoradd_genx.cpp -isystem <package_root>/compiler/include -o vectoradd_genx.isa

上記のコマンドを実行することで、カーネルプログラム(-genx.cpp)をCM Compilerに通してビルドし、ISAファイルを作成します。

g++ vectoradd.cpp -w -g -I<package_root>/runtime/include -msse4.1 -D__LINUX__ -DLINUX -O0 -std=gnu++11 -fPIC -c -rdynamic -ffloat-store -o vectoradd.hw_x64.og++ vectoradd.hw_x64.o -lva -ldl -fPIC -rdynamic <package_root>/runtime/lib/x64/igfxcmrt64.so -o hw_x64.vectoradd

上記のコマンドによってホストプログラムをビルドし、実行ファイルであるhw_x64.vectoraddが生成されます。

./hw_x64.vectoradd

そして、それを上記のコマンドによってその実行ファイルを実行することで、並列処理が実行されます。

カーネルプログラム内の流れ(vectoradd_genx.cpp)

_GENX_MAIN_ void add(SurfaceIndex ibuf1, SurfaceIndex ibuf2, SurfaceIndex obuf) { 
    static const int N = 64;
    const int x = get_thread_origin_x() * N;    vector<unsigned char, N> m1, m2, out;
    read(ibuf1, x, m1);
    read(ibuf2, x, m2);
    out = m1 + m2;
    write(obuf, x, out)
}

addという命令セットを作成します。addという命令セットはibuf1とibuf2を読み込んでそれぞれの要素における合計をobufに返す命令セットです。

N1スレッドで処理を行う要素数。
get_thread_origin()今行っている命令セットが何個目のスレッドかを取得する関数です。
xこのスレッドで処理する先頭のインデックスであるxが得られます。

C for Metal言語ではvector<type, size>で長さsizeで要素の型がtypeであるvectorが生成されます。 read関数とwrite関数はCforMetalで標準に用意されている関数です。

read

void read(SurfaceIndex IND, int offset, vector_ref v);
  • IND:ホストプログラムから送られてきたSurfaceIndex。
  • offset:入力変数のoffset(先頭からこのスレッドの位置までの距離)でOWordに揃える必要があります。もしDWordで揃っている場合はDWALIGNEDかMODIFIED_DWALIGNEDを使うことができます。
  • v:SurfaceIndexの要素のコピー先のvector。
read (DWALIGNED(ind), ...)  // INDがDWordで構成されている場合
read(ind, ...)              // INDがOwordで構成されている場合

write

void write(SurfaceIndex IND, int offset, const vector v);
IND出力するSurfaceIndex。
offsetreadのoffsetと変わりません。
vINDに出力するvector。

ホストプログラム内の流れ(vectoradd.cpp)

1. CmDeviceの作成

CmDevice *device = nullptr;
UINT version = 0;
cm_result_check(CreateCmDevice(device, version));

CreateCmDevice

INT CreateCmDevice (CmDevice *& device, UINT & version, VADisplay vaDisplay = nullptr);

Linuxの中に、ハードウェアモードのCM Deviceを作成します。CmDeviceをイチから、またはinput vaDisplay interface上に作成します。イチから作成する場合、internal vaDisplay interfaceが作成され、その上にCmDeviceが作成されます。

device作成するCmDeviceのポインタ。
versionCM APIのバージョン。
vaDisplaynullptrではなかった場合、vaInitializeから得られたVADIsplayを参照します。

2. プログラム(isaファイル)の読み込み

FILE* isa_file = fopen("vectoradd_genx.isa", "rb");
if(isa_file == NULL) {
    std::exit(1);
}
fseek (isa_file, 0, SEEK_END);
const int isa_size = ftell (isa_file);

void *isa_code = malloc(isa_size);
rewind(isa_file);
fread(isa_code, sizeof(void), isa_size, isa_file);
fclose(isa_file);

CmProgram *program = nullptr;
cm_result_check(device->LoadProgram(isa_code,isa_size, program));

LoadProgram

virtual INT CmDevice::LoadProgram (void *pCommonISACode,const UINT size, CmProgram *&pProgram,const char * options = nullptr)
pCommonISACodeISAファイルの中身のデータ。
sizeISAファイルの大きさ。
pProgram 作成するCmProgramのポインタ。

3. カーネルの作成

CmKernel *kernel = nullptr;cm_result_check(device->CreateKernel(program, "add", kernel));

vectoradd_genx.cppの中にあるaddという関数を実行するカーネルを作成します。

CreateKernel

virtual INT CmDevice::CreateKernel(CmProgram * pProgram, const char * kernelName, CmKernel *& pKernel, const char * options=nullptr)
pProgramKernelを作成するCmProgram。
kernelNameカーネルの名前。
pKernel作成するカーネルのポインタ。

4. スレッドスペースの作成

const unsigned int thread_width = N / 64;
CmThreadSpace *thread_space = nullptr;
cm_result_check(device->CreateThreadSpace(thread_width, 1, thread_space));

スレッドスペース(Thread space)

  1. GPUの中で実行されているスレッド間の依存関係。
  2. kernelが実行されている間にそれぞれのスレッドに与えられる座標。スレッド:並列処理の実行単位。一つのスレッドで64個分を実行するのでn/64個のスレッドを作成し、それぞれに対するスレッドスペースを定義しています(このサンプルでは2の座標として用いられています)。

CreateThreadSpace

virtual INT CmDevice::CreateThreadSpace(UINT width, UINT height, CmThreadSpace *& pTS)
widthスレッドスペースの幅。
heightスレッドスペースの高さ。
pTS作成するCmThreadSpaceのポインタ。

5. タスクの作成

CmTask * task = nullptr;cm_result_check(device->CreateTask(task));

CmTaskにはCmKernelが格納され、カーネルを実行キューに入れるために使われます。

CreateTask

virtual INT CmDevice::CreateTask(CmTask *& pTask)
pTask作成するCmTaskのポインタ。

6. カーネルをタスクに追加

cm_result_check(task->AddKernel(kernel));

AddKernel

virtual INT CmTask::AddKernel(CmKernel *pKernel)

CmTaskにCmKernelを格納する。

pKernel格納するカーネル。

7. Surfaceの作成と変数をSurfaceにコピー

CmBuffer *m1_surface = nullptr;
SurfaceIndex *m1_surface_idx = nullptr;
cm_result_check(device->CreateBuffer(N, m1_surface));
cm_result_check(m1_surface->WriteSurface((unsigned char*)m1, nullptr));
cm_result_check(m1_surface->GetIndex(m1_surface_idx));

CmBuffer *m2_surface = nullptr;
SurfaceIndex *m2_surface_idx = nullptr;
cm_result_check(device->CreateBuffer(N, m2_surface));
cm_result_check(m2_surface->WriteSurface((unsigned char*)m2, nullptr));
cm_result_check(m2_surface->GetIndex(m2_surface_idx));
CmBuffer *output_surface;SurfaceIndex *output_surface_idx = nullptr;

cm_result_check(device->CreateBuffer(N, output_surface));
cm_result_check(output_surface->GetIndex(output_surface_idx));

CreateBuffer

virtual INT CmDevice::CreateBuffer(UINT size, CmBuffer *& pSurface)

CmBufferを作成する関数です。

size作成するCmBufferの配列の大きさ。
pSurface作成するCmBufferのポインタ。

WriteSurface

virtual int32_t CmBuffer::WriteSurface(const unsigned char * sysMem, CmEvent * event, uint64_t sysMemSize=0xFFFFFFFFFFFFFFFFULL)

CPUを使ってシステムメモリの内容をCmBufferにコピーする関数です。この関数はブロッキング関数でコピーが終わるまで値を返しません。Bufferを書く操作はeventがCM_STATUS_FINISHEDになるまで行われません。WriteSurfaceのeventは普通はNULLです。もしsysMemSizeが与えられて、それがCmBufferの大きさよりも小さければ、sysMemSize分だけコピーされます。

sysMemCmBufferへコピーするシステムメモリのポインタ。
event同期のために使うイベントのポインタ。
sysMemSizeコピーするbytesの数。

GetIndex

virtual int32_t CmBuffer::GetIndex(SurfaceIndex *& index)

indexにCmBufferオブジェクトのSurfaceIndexオブジェクトを返す関数です。

index参照するSurfaceIndexのポインタ。

8. カーネルプログラムの引数にSurfaceを設定

cm_result_check(kernel->SetKernelArg(0, sizeof(SurfaceIndex), m1_surface_idx));
cm_result_check(kernel->SetKernelArg(1, sizeof(SurfaceIndex), m2_surface_idx));
cm_result_check(kernel->SetKernelArg(2, sizeof(SurfaceIndex), output_surface_idx));

kernelの引数にm1,m2,outをそれぞれ設定します。

SetKernelArg

virtual INT CmKernel::SetKernelArg(UINT index, size_t size, const void * pValue)

カーネルの引数を設定する関数です。

indexカーネル側の何番目の引数を設定するかの値。
size引数として設定する変数の型の大きさ。
pValueSurfaceIndexのポインタ。

9. タスクキューの作成

CmQueue *cmd_queue = nullptr;cm_result_check(device->CreateQueue(cmd_queue));

CreateQueue

virtual INT CmDevice::CreateQueue(CmQueue *& pQueue)

CmQueueを作成する関数です。

pQueue作成するCmQueueのポインタ。

10. 実行

CmEvent *sync_event = nullptr;
for (int i = 0; i < num_iters; ++i) {
    UINT64 time_in_ns = 0;
    cm_result_check(cmd_queue->Enqueue(task, sync_event, thread_space));
    cm_result_check(sync_event->WaitForTaskFinished(time_out));
    cm_result_check(sync_event->GetExecutionTime(time_in_ns));
    kernel_time_in_ns += time_in_ns;
}

CM eventはEnqueueを実行するために作成され、ジョブの状態が保存されます。

Enqueue

virtual INT CmQueue::Enqueue(CmTask * pTask, CmEvent *& pEvent, const CmThreadSpace * pTS = nullptr)

Enqueueはノンブロッキングな呼び出し(GPUがジョブの実行を終えるのを待たずに値を返してしまうもの)です。runtimeはハードウェアの状態を参照し、ハードウェアがジョブでいっぱい出なかったらジョブをEnqueueし、そうでなかったら待ってからEnqueueを実行します。そういう特性をもっているのでWaitForTaskFinished関数を使ってEnqueueが終わるまで待機させることが多いと思います。

pTaskCmQueueにプッシュするTaskのポインタ。
pTS参照するCmThreadSpaceのポインタ。

WaitForTaskFinished

virtual INT CmEvent::WaitForTaskFinished(DWORD dwTimeOutMs=2*1000)

CmEventが終わるまで待機する関数です。ここではEnqueueの実行が完全に終了するまで待機します。

dwTimeOutMsタイムアウトする時間。

GetExecutionTime

virtual INT CmEvent::GetExecutionTime(UINT64 & time)

GPUでジョブが実行された時間を返す関数です。

time実行時間を保存する変数。

11. 結果の読み込み

cm_result_check(output_surface->ReadSurface((unsigned char*)out, sync_event));

output surfaceが読み込まれるときにCM eventが使われているのはCM_STATUS_FINISHEDになるまで読み込まれないようにするためです。

ReadSurface

virtual int32_t CmBuffer::ReadSurface(unsigned char * sysMem, CmEvent * event, uint64_t sysMemSize=0xFFFFFFFFFFFFFFFFULL)

CPUを使ってCmBufferの内容をシステムメモリにコピーする関数です。この関数はブロッキング関数です。Bufferを書く操作はeventがCM_STATUS_FINISHEDになるまで行われません。もしsysMemSizeが与えられて、それがCmBufferの大きさよりも小さければ、sysMemSize分だけコピーされます。

sysMemCmBufferへコピーするシステムメモリのポインタ。
event同期のために使うイベントのポインタ。
sysMemSizeコピーする大きさ。

12. 事後処理

cm_result_check(device->DestroyTask(task));
cm_result_check(device->DestroyThreadSpace(thread_space));
cm_result_check(cmd_queue->DestroyEvent(sync_event));
cm_result_check(::DestroyCmDevice(device));

DestroyTask

virtual INT CmDevice::DestroyTask(CmTask *& pTask)

CmTaskを破壊する関数です。CmTaskはCmDeviceが破壊されるときに破壊されますが、ここでは単独で破壊しています。

pTask破壊するCmTaskのポインタ。

DestroyThreadSpace

virtual INT CmDevice::DestroyThreadSpace(CmThreadSpace *& pTS)

CmThreadSpaceを破壊する関数です。CmThreadSpaceもまたCmDeviceが破壊されるときに破壊されますが、ここでは単独で破壊してます。

pTS破壊するスレッドスペースのポインタ。

DestroyEvent

virtual INT CmQueue::DestroyEvent(CmEvent *& pEvent)

CmEventを破壊する関数です。CmEventは明示的に破壊しなければなりません。

pEvent破壊するCmEventのポインタ。

DestroyCmDevice

INT DestroyCmDevice (CmDevice *& device)

CmDeviceを破壊する関数です。CmDeviceが破壊されると、そのCmDeviceで初期化されたsurface、kernel、task、thread space、queueも破壊されます。

device破壊するCmDeviceのポインタ。

サンプルコードの実行時間の比較

実行環境

CPUIntel(R) Core(TM) i7-8700 CPU @ 3.20GHz
OSUbuntu 18.04.3 LTS
メモリ16GiB
vectorの大きさ25610244096819216384245763276840960491525734465536
CPU[msec]0.00050.00270.00910.01810.03660.05440.07150.08900.10890.12370.1429
C for Metal[msec]0.0630.0630.0630.0620.0640.0650.0650.0660.0670.0670.068
C for Metal(kernel)[msec]0.00620.00690.00640.00680.00790.00850.00910.00960.01040.01100.0118

1000回実行したときの中央値を表示しています。

CPUCPU操作だけでvectoraddを行った際の実行時間。
C for MetalEnqueueをしてから計算を終えるまでの実行時間。
C for Metal(kernel)上の実行時間の中でkernelが計算した時間。

CPUとC for Metalを比較することでC for Metalの速さが比較できます。kernelのみの時間とCforMetal全体の時間のギャップは同期処理や排他制御によって生じる時間であると考えられます。

まとめ

今回はCforMetalの概要とCPUとの速度比較を行いました。

配列が小さいうちは、カーネルを呼び出す時間などのオーバーヘッドなどを考慮するとCPUのほうが実行時間が短くなりますが、配列の大きさが65536になるとCPUよりもCforMetalのほうが高速になっています。

今後の課題は、OpenCLやCUDAとの速度比較、更にはもう少し複雑なプログラムでの速度比較を行っていきたいです。

Tags

About Author

takeshi.nishida

Leave a Comment

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください

Recent Comments

Social Media