機械学習周りのプログラミング中心。 イベント情報
ポケモンバトルAI本電子書籍通販中

ゲームボーイ上で強化学習を行う part5

実装

グラフィックの実装

前回作成したアセットを画面上に表示するための実装は、以下のようにして行えます。

#include <gb/gb.h>
#include <gbdk/console.h>
#include <gbdk/platform.h>
// .cをincludeする。sizeof()を使いたいため。
#include "bg.c"
#include "bg_map.c"
#include "sprite.c"

// VBLANK割り込みで呼ばれる
void update()
{
    // スプライトの座標x, yを計算
    move_sprite(0, x, y); // スプライト0番を座標x, yに移動
}

int main()
{
    DISPLAY_OFF; // 液晶をオフ
    SHOW_BKG; // 背景を使用するフラグをセット
    SHOW_SPRITES; // スプライトを使用するフラグをセット
    set_bkg_data(0, sizeof(TileData) / 16, TileData); // VRAMに背景タイル画像を転送
    set_bkg_tiles(0, 0, 20, 18, MapData); // VRAMに各座標に表示するタイルの番号を転送

    set_sprite_data(0, sizeof(SpriteLabel) / 16, SpriteLabel); // VRAMにスプライト画像を転送
    set_sprite_tile(0, 0); // スプライト0番にタイル0番を表示
    DISPLAY_ON; // 液晶をオン

    __critical // 割り込み禁止
    {
        add_VBL(update); // VBLANK割り込み時に呼び出される関数をセット
    }

    while (1)
    {
        wait_vbl_done(); // VBLANK割り込みが発生し、割り込み処理が完了するまで待機する
        // ここにスプライトの移動処理を実装してもよい
    }
}

まず、main関数の先頭でグラフィックの設定および画像データ等の転送を行います。その後、move_sprite(uint8_t nb, uint8_t x, uint8_t y)関数により、nb番のスプライトを指定した座標(x, y)に移動することができます。x座標は、8のときにスプライトの左端と画面の左端が一致します。それより小さい値だと、スプライトの左側が画面外に隠れます。y座標は16のときにスプライトの上端と画面の上端が一致します。move_spriteなどのグラフィックの操作は、VRAMへのアクセスを伴います。グラフィックチップからのVRAMアクセスと競合しないようにするため、CPUからのVRAMアクセスは60fps(1秒間に60回)で発生するVBLANK割り込みが発生した直後に行う必要があります。アクセスのタイミングを計るには、VBLANK割り込み時に呼び出される関数をadd_VBL関数でセットするか、VBLANK割り込みの処理が完了するまで待機するwait_vbl_done関数を用います。

printfとの干渉を避ける

前節で実装した背景と、printfによる文字の表示は干渉してしまうという問題があります。背景を表示した後printfを用いると、文字が書かれた場所だけでなく画面全体から背景が消えてしまいます。printfはデバッグ用途を想定したものですが、学習したエピソード数などの文字の表示を独自に実装するのは手間がかかるため、背景を表示しつつprintfを併用する手段を検討しました。printfを最初に実行した際、背景タイル0番~101番にフォントデータが設定され、また画面全体に0番(空白)のタイルが設定されます。そのため、以下の2点のテクニックを用いることで併用が可能でした。(1)独自の背景に用いるタイルを128番から登録するようにしてフォントデータと共存させる、(2)printfを一度実行してから、背景のタイル番号を設定する。

背景に用いるタイル番号を128から始まるように変更するため、Pic2Tilesで出力されたソースコードを書き替えます。bg_map.cを開き、定数に128を加算してbg_map_offseted.cに保存します。

// 変更前
const unsigned char MapData[] = {
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x02,

// 変更後
const unsigned char MapData[] = {
0x80,0x80,0x80,0x80,0x80,0x80,0x80,0x80,0x80,0x80,0x80,0x80,0x80,0x80,0x80,0x80,0x80,0x80,0x81,0x82,

もちろん手作業では大変なので、Python正規表現により実装しました。

re.sub(r'0x([0-9A-Fa-f]{2})', lambda m: '0x{:02X}'.format(int(m.group(1), 16) + 128), source)

結局、背景に文字を重ねて表示するコードは以下のようになります。

int main()
{
    DISPLAY_OFF;
    SHOW_BKG;SHOW_SPRITES;
    printf("0"); // このタイミングでフォントデータのセットが行われる
    set_bkg_data(128, sizeof(TileData) / 16, TileData); // 128番以降にタイル画像をセット
    set_bkg_tiles(0, 0, 20, 18, MapData); // 独自の背景を表示
    gotoxy(0, 16); // 次の文字が表示される座標を変更(画面下から2行目)
    puts("Training...");
    DISPLAY_ON;
}

スプライトも表示すると、以下のような画面が得られます。

背景と文字を共存させた表示

エミュレータでは、VRAM上のタイルデータを可視化することができます。車のスプライト、背景マップ、フォントが共存できています。車の右には、電源投入時に自動的にセットされるNintendoロゴのスプライトが残っています。

VRAM上のタイルデータ

あとは、スプライトを適切に移動することで車が動いている様子を表示することが可能になります。

ゲームボーイ上で強化学習を行う part4

前回、Q学習のアルゴリズムゲームボーイ向けビルドしました。今回は、学習した車の動きを画面に表示するための実装を進めます。

実装

ゲームボーイのグラフィックシステム

ゲームボーイの画面は幅160px、高さ144pxの液晶です。色はモノクロで、白、明るい灰色、暗い灰色、黒の4階調を表現できます。ゲームボーイのグラフィックシステムは、「背景」「ウインドウ」「スプライト」を重ね合わせて表示します。背景は、8×8の画像(タイル)を画面全体に敷き詰めて表示します。座標(0,0), (0,8), (0,16), ..., (8,0), ...について、あらかじめ登録したタイルのうち何番のものを表示するのかを指定します。ウインドウは、画面の一部の長方形領域にタイルを敷き詰めて表示します。本プロジェクトでは使用しません。スプライトは、8×8のタイルを1px単位で画面上の任意の位置に表示することができます。スプライトごとに、何番のタイルをどの座標に表示するかを指定します。今回はごくシンプルな機能のみを使用しますが、背景のスクロールなどの機能を利用し多彩な表現が可能になっています。

画面の構成とアセットの作成

ゲームボーイのグラフィックシステム上でMountainCarを表現していきます。

画面の完成形

コースは動かないので背景で、1px単位で動かしたい車はスプライトで表示します。画面下部の文字は、printfの機能により独自のグラフィックデータを用意することなく表示していますが背景の一部です。コースの画像は、まず160×144サイズのPNG形式の画像を作成し、それをPic2TilesというツールでC言語ソースコードに変換します。画像は、Pythonで地面となるサインカーブを描き、Photoshopでゴールの旗を描き込みました。Pic2TilesにPNG画像を読み込ませ、"C (gbdk)"形式で出力を行います。

Pic2Tilesの画面

bg.cには、タイルごとの8×8の画像がゲームボーイ用の形式で格納されます。1つのタイルは、1ピクセル当たり2ビット(4色)ですので16バイトの容量を占めます。画面全体では横20個、縦18個のタイルを表示できますので最大で360種類のタイルが定義されることになりますが、今回用意した背景は真っ白のタイルが何度も出てきますので、これに対応するタイルは1回だけ定義され、45種類のタイルで背景が表現されています。

const unsigned char TileData[] = {
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x07,0x02,0x05,0x03,0x04,0x03,0x04,0x03,0x04,0x03,0x04,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x80,0x80,0x60,0xC0,0x38,0xF0,0x0C,0xF0,0x0C,
0x03,0x04,0x03,0x04,0x02,0x05,0x00,0x06,0x00,0x04,0x00,0x04,0x00,0x04,0x00,0x0F,
0xC0,0x38,0x00,0xE0,0x00,0x80,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xFE,
// 省略: 合計45タイル

bg_map.cには、画面上の各領域にどのタイルを表示するかを指示する定数が格納されます。タイル1個は1バイトで表現されています。真っ白の領域はタイル0x00が割り当てられ、それ以外の領域は個別のタイル番号が割り振られていますので、ソースコード上におおまかなコースの形が現れています。

const unsigned char MapData[] = {
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x02,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x03,0x04,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x05,0x06,0x07,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x08,0x09,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x0A,0x0B,0x00,0x00,0x00,
0x0C,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x0D,0x0E,0x00,0x00,0x00,0x00,
0x0F,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x10,0x00,0x00,0x00,0x00,0x00,
0x11,0x12,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x13,0x14,0x00,0x00,0x00,0x00,0x00,
0x00,0x15,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x16,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x17,0x18,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x19,0x0B,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x1A,0x1B,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x1C,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x1D,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x1E,0x1F,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x20,0x21,0x00,0x00,0x00,0x00,0x00,0x22,0x23,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x24,0x1B,0x00,0x00,0x00,0x00,0x25,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x26,0x27,0x00,0x0D,0x28,0x09,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x29,0x2A,0x2B,0x2C,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
};

車のスプライトは、GBTD (Gameboy Tile Designer)を用いて作成します。ゲームボーイに特化したツールで、4階調のドット絵を作成することができます。

GBTDを用いたスプライトの作成

GBDK用のC言語ソースコードを出力すると、以下のようになります。

const unsigned char SpriteLabel[] =
{
  0x00,0x00,0x00,0x00,0x3C,0x3C,0x7E,0x78,
  0xFF,0xFF,0x99,0xFF,0x00,0x66,0x00,0x00
};

次回は、用意したアセットを画面上に表示する実装を行います。

ゲームボーイ上で強化学習を行う part3

前回、浮動小数点数演算を使わないQ学習アルゴリズムを実装しました。次にこれをゲームボーイ用にビルドしていきます。

実装

Q学習をゲームボーイ用にビルド

ゲームボーイ用のmain関数は以下のようになります。PC版との違いは、乱数シードの設定関数がinitrandという関数に変わっている点と、ファイル保存機能がないことだけです。驚くべきことに、GBDKにはprintfが実装されており、ゲームボーイ特有の入出力を一切記述することなく文字の表示が可能です。

#include <stdio.h>
#include <stdlib.h>
#include "config.h"
#include "mountaincar.h"
#include "qlearning.h"

int main() {
    QLearningState *q_state = QLearningStateCreate();
    for (int epoch = 0; epoch < 1000; epoch++) {
        initrand((unsigned int)epoch); // GBDKにおける乱数のシード設定関数
        for (int i = 0; i < 100; i++) {
            TrainEpisode(q_state);
        }
        // fix test case
        initrand((unsigned int)1);
        int32_t total_reward = 0;
        for (int i = 0; i < 10; i++) {
            uint8_t steps;
            total_reward += TestEpisode(q_state, &steps);
        }
        printf("%d,Avg:%d\n", epoch, total_reward / 10);
    }

    return 0;
}

ビルドは、GBDKに含まれるlcc.exeで行います。エミュレータで動作するROMファイルが出力されます。

lcc.exe -o mountaincar.gb gb_main.c mountaincar.c qlearning.c

エミュレータで実行した際の画面を示します。

Q学習をゲームボーイ上で実行した際の表示

ゴールに到達できず終了した場合の報酬は-25600です。これより大きな値が表示されたということは、ゴールに到達できたことを示します。これにより、ゲームボーイ上で学習が動作することが確認できました。エミュレータでは、実機の速度(60fps)に合わせて実行するほかに、PCのスペックが許す限り高速に実行することも可能です。画面例では2111fps出ており、実機の35倍の速度で動作確認をすることができます。

報酬が表示されるだけでは面白くないので、次回、車の動きを画面に表示するための実装を進めていきます。

ゲームボーイ上で強化学習を行う part2

前回の続きです。前回はQ学習をPC上で動作させ、適切なハイパーパラメータを取得しました。

実装

浮動小数点数演算を回避する

先述のプログラムでは浮動小数点数(double型)の演算を用いていましたが、GBDKのCコンパイラではこれをサポートしていません。ゲームボーイのCPUには浮動小数点数を処理する機構がないためです。それどころか、ゲームボーイのCPUには整数の加算・減算命令はありますが乗算・除算命令はありません。GBDKには、整数の乗算・除算を加算・減算命令とビットシフト、条件分岐命令を組み合わせることで処理するルーチンが組み込まれており、ゲーム開発者は命令の有無を気にせずC言語演算子を利用可能になっています。しかし、乗算は加算の数十倍の時間がかかりますので極力回避する必要があります。なお浮動小数点数演算も理論上はソフトウェア的に実現できますが、整数加算の数千倍の時間がかかるため実用性はないでしょう。浮動小数点数演算がないことに対する対策は、扱う数値を定数倍することで整数として処理できるようにすること、乗算除算の負荷が高いことに対する対策は、ビットシフト命令を用いることが解決策となります。

mountaincar.hの内容です。

#include <stdint.h>
#include "config.h"

typedef int16_t Reward;
typedef int16_t Position;
typedef int16_t Velocity;
typedef uint8_t MountainCarAction; // 0=left, 1=nothing, 2=right

typedef struct {
    Position position;
    Velocity velocity;
    uint8_t steps;
    uint8_t done;
} MountainCarState;

Reward MountainCarStep(MountainCarState *state, MountainCarAction action);
void MountainCarReset(MountainCarState *state);

報酬Reward、座標Positionなどを、16ビット整数(int16_t)で表現するように定義しています。例えば座標は本来[-1.2, 0.6]の範囲でしたが、10000倍して[-12000, 6000]の範囲を取る値として表現します。また、ゲームボーイは8bitCPUであるものの、GBDKでのintは16bit整数のため、8bitで十分な変数はuint8_t, int8_t型で定義することにより極力メモリ使用量を削減します。

mountaincar.cは以下の通りです。

#include <stdlib.h>
#include "config.h"
#include "mountaincar.h"

#define POSITION_SCALE 10000
#define FORCE 10
#define VELOCITY_MAX 700
#define VELOCITY_MIN -700
#define POSITION_MIN -12000
#define POSITION_MAX 6000
#define POSITION_DONE 5000

/*
raw_values = np.arange(512//2,18000+512,512)
(np.cos(3*(raw_values / scale - 1.2))*-0.0025*10000).astype(np.int8)
*/
static const int8_t gravity_table[] = {
    23,  24,  24,  24,  24,  23,  21,  19,  16,  13,  10,   6,   2,
        -1,  -4,  -8, -12, -15, -18, -20, -22, -23, -24, -24, -24, -23,
       -22, -20, -17, -14, -11,  -8,  -4,   0,   3, 6
};

static Velocity gravity(Position position) {
    // cos(3 * position) * (-0.0025);
    return gravity_table[(position - POSITION_MIN) >> 9];
}

Reward MountainCarStep(MountainCarState *state, MountainCarAction action) {
    Position position = state->position;
    Velocity velocity = state->velocity;
    switch (action) {
        case 0:
            velocity -= FORCE;
            break;
        case 2:
            velocity += FORCE;
            break;
    }
    velocity += gravity(position);
    if (velocity > VELOCITY_MAX) {
        velocity = VELOCITY_MAX;
    } else if (velocity < VELOCITY_MIN) {
        velocity = VELOCITY_MIN;
    }
    position += velocity;
    if (position > POSITION_MAX) {
        position = POSITION_MAX;
        velocity = 0;
    } else if (position < POSITION_MIN) {
        position = POSITION_MIN;
        velocity = 0;
    }
    state->position = position;
    state->velocity = velocity;
    state->steps += 1;


    Reward reward = REWARD_PER_STEP;

    if (position >= POSITION_DONE) {
        reward += REWARD_AT_GOAL;
    }

    if (position >= POSITION_DONE || state->steps >= MAX_EPISODE_LEN) {
        state->done = 1;
    }

    return reward;
}

void MountainCarReset(MountainCarState *state) {
    // uniform random between -0.6 and -0.4
    // (0.2 * rand() / RAND_MAX - 0.6) * 10000;
    
#ifdef __SDCC
    // randw(): 0 to 0xffff
    // randw()>>5: 0 to 2047
    state->position = (int16_t)(randw() >> 5) - 6000;
#else
    // rand(): 0 to 0x7fff
    // rand()>>4: 0 to 2047
    state->position = (rand() >> 4) - 6000;
#endif
    state->velocity = 0;
    state->steps = 0;
    state->done = 0;
}

まず、エージェントが与える入力によって速度が変化する項はPC版では(action - 1) * 0.001という乗算を用いていましたが、switch文により定数を加算・減算するように変更します。

    switch (action) {
        case 0:
            velocity -= FORCE;
            break;
        case 2:
            velocity += FORCE;
            break;
    }

重力によって速度が変化する項は、PC版ではcos(3 * position) * (-0.0025)という三角関数を用いた式になっていましたが、GBDKでは三角関数は搭載されていませんので独自に実装する必要があります。しかし、三角関数を直接実装することはこのタスクでは重要ではなく、そのあと-0.0025を乗算する処理が入るため、乗算も回避すべきです。そのため、cos(3 * position) * (-0.0025)を一括で計算するgravity関数に切り出し、極力低コストな実装を考えます。その実装は以下の通りです。

static const int8_t gravity_table[] = {
    23,  24,  24,  24,  24,  23,  21,  19,  16,  13,  10,   6,   2,
        -1,  -4,  -8, -12, -15, -18, -20, -22, -23, -24, -24, -24, -23,
       -22, -20, -17, -14, -11,  -8,  -4,   0,   3, 6
};

static Velocity gravity(Position position) {
    return gravity_table[(position + 12000) >> 9];
}

// 使用方法: velocity += gravity(position);

Position, VelocityはPC倍の値の10,000倍にスケーリングされていることに注意してください。まず、position + 12000により、負の数を回避し値を[0, 18000]の範囲に収めます。9ビット右シフトにより、数値を512 (2の9乗)で割ったのと同じ効果が得られるため、値が[0, 35]の範囲に変換されます。各値に対応するgravityの値を、gravity_tableに定数として埋め込んでおき、配列参照によって取り出します。この定数は、以下のPythonコードにより事前に計算しました。

raw_values = np.arange(512//2,18000+512,512)
(np.cos(3*(raw_values / scale - 1.2))*-0.0025*10000).astype(np.int8)

本来のPositionは18000通りの値を取りえますが、テーブルの要素数は36であり精度が失われています。そのためdouble版と厳密に同じ計算結果にはならないことに留意してください。

車の初期位置を乱数で設定する箇所では、プリプロセッサマクロによる分岐を行っています。GBDKでのコンパイル時には__SDCCというマクロが定義されているため、これによりPC用ビルドと区別します。GBDKでの乱数生成はrandw()関数を用います。これはuint16_t型で[0, 65535]の値を返します。車の座標は[-6000,-4000]の範囲にしてほしいので、5ビット右シフトして[0, 2047]の範囲に変換し、6000を引くことで[-6000,-3953]の範囲の値を得ます。ビットシフトで実装するために、若干本来の範囲とずれることを許容しています。

#ifdef __SDCC
    // randw(): 0 to 0xffff
    // randw()>>5: 0 to 2047
    state->position = (int16_t)(randw() >> 5) - 6000;
#else
    // rand(): 0 to 0x7fff
    // rand()>>4: 0 to 2047
    state->position = (rand() >> 4) - 6000;
#endif

次にQ学習アルゴリズムの実装です。qlearning.hは以下の通りです。

#include "mountaincar.h"

typedef struct {
    Reward q_table[N_STATE_0 * N_STATE_1][N_ACTIONS];
} QLearningState;

QLearningState *QLearningStateCreate();
MountainCarAction GetBestAction(const QLearningState *q_state, const MountainCarState* state);
Reward TestEpisode(const QLearningState *q_state, uint8_t *steps);
Reward TrainEpisode(QLearningState *q_state);

QLearningStateにはQテーブルが含まれていますが、Reward (=uint16_t)型の要素を256(状態数)×3(行動数)個保持しており、1536バイトを占めます。Qテーブルの値は学習中に少しずつ変化させていく必要があり、8ビットでは粗すぎるため16ビット使用します。Qテーブルは今回のプログラムで最も容量の大きなデータ構造となります。ゲームボーイのRAM容量は8kBですので余裕をもって収めることができました。今回の環境は状態の軸が座標と速度の2つでしたが、仮に状態の軸がもう一つ増えると容量が厳しくなりそうです。

qlearning.cは以下の通りです。

#include <stdlib.h>
#include "config.h"
#include "mountaincar.h"
#include "qlearning.h"

QLearningState *QLearningStateCreate() {
    QLearningState *state = malloc(sizeof(QLearningState));
    for (int i = 0; i < N_STATE_0 * N_STATE_1; i++) {
        for (int j = 0; j < N_ACTIONS; j++) {
            state->q_table[i][j] = 0;
        }
    }
    return state;
}

// (np.linspace(-1.2,0.6,16+1)*10000)[1:-1].astype(np.int32)
static const Position position_thresholds[] = {-10875,  -9750,  -8625,  -7500,  -6375,  -5250,  -4125,  -3000,
        -1875,   -749,    374,   1499,   2624,   3749,   4874};
static const Velocity velocity_thresholds[] = {-612, -525, -437, -350, -262, -175,  -87,    0,   87,  175,  262,
        350,  437,  525,  612};

static int get_state_index(const MountainCarState* state)
{
    // int position = (int)((state->position / 10000.0 + 1.2) / 1.8 * N_STATE_0);
    // int velocity = (int)((state->velocity / 10000.0 + 0.07) / 0.14 * N_STATE_1);
    uint8_t position;
    for (position = 0; position < N_STATE_0 - 1; position++) {
        if (state->position < position_thresholds[position]) {
            break;
        }
    }
    uint8_t velocity;
    for (velocity = 0; velocity < N_STATE_1 - 1; velocity++) {
        if (state->velocity < velocity_thresholds[velocity]) {
            break;
        }
    }
    return position * N_STATE_1 + velocity;
}

MountainCarAction GetBestAction(const QLearningState *q_state, const MountainCarState* state) {
    MountainCarAction action = 0;
    int state_index = get_state_index(state);
    Reward max_q = q_state->q_table[state_index][0];
    for (uint8_t i = 1; i < N_ACTIONS; i++) {
        if (q_state->q_table[state_index][i] > max_q) {
            max_q = q_state->q_table[state_index][i];
            action = i;
        }
    }
    return action;
}

Reward TestEpisode(const QLearningState *q_state, uint8_t *steps) {
    MountainCarState state;
    MountainCarReset(&state);
    Reward total_reward = 0;
    *steps = 0;
    while (!state.done) {
        // choose action
        MountainCarAction action = GetBestAction(q_state, &state);
        Reward reward = MountainCarStep(&state, action);
        total_reward += reward;
        (*steps)++;
    }
    return total_reward;
}

Reward TrainEpisode(QLearningState *q_state) {
    MountainCarState state;
    MountainCarReset(&state);
    Reward total_reward = 0;
    while (!state.done) {
        // choose action
        MountainCarAction action = 0;
        int state_index = get_state_index(&state);
        if (rand() < EPSILON_RAND_MAX) {
            // random action
            action = rand() % N_ACTIONS;
        } else {
            Reward max_q = q_state->q_table[state_index][0];
            for (uint8_t i = 1; i < N_ACTIONS; i++) {
                if (q_state->q_table[state_index][i] > max_q) {
                    max_q = q_state->q_table[state_index][i];
                    action = i;
                }
            }
        }
        Reward reward = MountainCarStep(&state, action);
        total_reward += reward;

        // update model
        Reward max_q_next = 0;
        if (!state.done) {
            int next_state_index = get_state_index(&state);
            max_q_next = q_state->q_table[next_state_index][0];
            for (uint8_t i = 1; i < N_ACTIONS; i++) {
                if (q_state->q_table[next_state_index][i] > max_q_next) {
                    max_q_next = q_state->q_table[next_state_index][i];
                }
            }
        }

        Reward q_value = q_state->q_table[state_index][action];
        q_state->q_table[state_index][action] = ((reward + max_q_next - (max_q_next >> GAMMA_BITSHIFT) - q_value) >> ALPHA_BITSHIFT) + q_value;
    }

    return total_reward;
}

get_state_index関数では、座標をN_STATE_0分割した区間のうちのどこに該当するかを算出する処理があります。浮動小数点数演算では、 (int)((state->position / 10000.0 + 1.2) / 1.8 * N_STATE_0) という処理でした。その代わりに、区間の境界となる値を定数テーブル position_thresholds に格納し、該当するインデックスを探索する処理で置き換えました。N_STATE_0=16なので線形探索で十分ですが、要素が多くなるなら二分探索のほうが効率的です。関数の最後でposition * N_STATE_1という掛け算がありますが、掛け算の一方が定数かつ2の累乗(N_STATE_1=16)の場合は、コンパイラが自動的にビットシフトに置換してくれます。

// (np.linspace(-1.2,0.6,16+1)*10000)[1:-1].astype(np.int32)
static const Position position_thresholds[] = {-10875,  -9750,  -8625,  -7500,  -6375,  -5250,  -4125,  -3000,
        -1875,   -749,    374,   1499,   2624,   3749,   4874};

static int get_state_index(const MountainCarState* state)
{
    uint8_t position;
    for (position = 0; position < N_STATE_0 - 1; position++) {
        if (state->position < position_thresholds[position]) {
            break;
        }
    }
    // velocityについて同様の処理が続く
}

次にεグリーディ法によるランダムな行動の実装です。ランダムに行動するか否かの判定は、[0,255]の乱数を与えるrand()関数と、EPSILON_RAND_MAX=(int)(0.168 * RAND_MAX)の比較で行います。浮動小数点数演算を記載していますが、定数ですのでPC上でコンパイル時に計算が行われますので問題ありません。ランダムな行動の生成には剰余演算で手抜きをしています。突き詰めるなら[0,255]の区間を3等分して境界値と比較するようなコードを書くことになるでしょう。

if (rand() < EPSILON_RAND_MAX) {
    // random action
    action = rand() % N_ACTIONS;
}

最後にQテーブルの更新式です。ビットシフトにより掛け算を回避しています。ビットシフトで処理できるようにするため、定数を丸めています。また、 max_q_next * 0.96875は、2を掛ける、2で割るしかできないビットシフトでは一見実装が難しいですが、max_q_next - max_q_next / 32という式に変形するとビットシフトと減算で処理可能です。

#define ALPHA_BITSHIFT 6 // 0.015625 ~= 0.0126; x * alphaの代わりに x >> ALPHA_BITSHIFTを使う
#define GAMMA_BITSHIFT 5 // 1-0.03125=0.96875 ~= 0.963; x * gammaの代わりに x - (x >> GAMMA_BITSHIFT)を使う
Reward q_value = q_state->q_table[state_index][action];
q_state->q_table[state_index][action] = ((reward + max_q_next - (max_q_next >> GAMMA_BITSHIFT) - q_value) >> ALPHA_BITSHIFT) + q_value;
// doubleを用いた場合の等価な式
// q_state->q_table[state_index][action] = (reward + max_q_next * 0.96875 - q_value) * 0.015625 + q_value;

学習アルゴリズムが実装できたので、まずはPC上で動作させてデバッグを行います。コードは以下のようになります。また、学習済みのQLearningStateをファイルに保存します。このファイルは、ゲームボーイのUIをデバッグする際に使います。

// PC用main関数
#include <stdio.h>
#include <stdlib.h>
#include "config.h"
#include "mountaincar.h"
#include "qlearning.h"

int main() {
    QLearningState *q_state = QLearningStateCreate();
    for (int epoch = 0; epoch < 1000; epoch++) {
        srand((unsigned int)epoch);
        for (int i = 0; i < 100; i++) {
            TrainEpisode(q_state);
        }
        // fix test case
        srand((unsigned int)1);
        int32_t total_reward = 0;
        for (int i = 0; i < 10; i++) {
            uint8_t steps;
            total_reward += TestEpisode(q_state, &steps);
        }
        printf("%d,Avg:%d\n", epoch, total_reward / 10);
    }

    FILE* result_file = fopen("trained.bin", "wb");
    fwrite(q_state, sizeof(QLearningState), 1, result_file);
    fclose(result_file);
    return 0;
}

ここまでの実装で、PC上で浮動小数点数演算を使わずに強化学習を実装することができました。次はゲームボーイソフトとしてのビルドを行います。

ゲームボーイ上で強化学習を行う part1

ゲームボーイは1989年に発売された携帯ゲーム機で、現代では非公式ながら個人でもゲームソフトを開発することができます。そこで(!?)、ゲームボーイ上で強化学習を行うソフトを試作しました。 レトロなゲーム機と言えど任意のコードが実行できる計算機ですから、強化学習を実装できないはずはありません。本連載では、初めてゲームボーイソフトの開発を行った筆者の視点で、開発に用いたツールやテクニックを解説します。 既存のゲームボーイ用ソフトを現代の強力な計算機上のエミュレータで動作させて強化学習を行う試みは存在します(例: gym-retro)が、これとは異なりゲームボーイのCPUで強化学習を行うソフトが開発された事例はおそらく存在していません。

完成形

MountainCarという、車を左右に加速して旗のところまで到達させる簡単なゲームを解きます。ソフト内にはゲームのルールと強化学習アルゴリズムが実装されており、ゲームボーイ上で試行錯誤を行うことで解き方を発見します。起動した時点ではゲームを解くことができませんが、数十分待っていると解けるようになります。

MountainCarゲーム画面

TL;DR

ゲームボーイ特有の制約について、主な感想は3点あります。

  • 浮動小数点数演算の回避は腕の見せ所。
  • メモリを食うデータ構造(Qテーブル)は、事前にRAM容量(8kB)に収まるよう検討が必要。
  • 性能にクリティカルな影響がない場所では、printfやmallocのような、贅沢な処理を書いても意外に大丈夫。

使用するツール

  • GBDK-2020
  • Emulicious
  • GBTD
    • ドット絵を作成し、GBDKでコンパイルできるCソースファイルに出力できます。
    • スプライト(動く物体)のドット絵作成に利用しました。
  • Pic2Tiles
    • 画像をGBDKでコンパイルできるCソースファイルに変換します。
    • Photoshopで作成した背景画像の変換に利用しました。

強化学習アルゴリズム

アルゴリズムはQ学習で、Q関数にはQテーブルを用います。ニューラルネットワークは用いていません。

Q学習のフォーマルな説明はこちらの資料が参考になります。(31ページ) 強化学習の基礎と深層強化学習(東京大学 松尾研究室 深層強化学習サマースクール講義資料) | PPT

MountainCar環境の説明は公式ドキュメントをご覧ください。

Mountain Car - Gymnasium Documentation

行動は離散的で、左に力を加える、力を加えない、右に力を加えるという3通りです。 観測値は、2つの連続値があります。1つ目はx座標で、[-1.2, 0.6]の範囲です。2つ目は速度で、[-0.07, 0.07]の範囲です。 Qテーブルを構築するために離散化する必要がありますが、今回の実装では、x座標、速度をそれぞれ均等に16分割します。観測値は16×16=256通りとなり、それぞれに3通りの行動のQ値を格納するため768要素のテーブルとなります。

報酬は、1ステップごとに-1で、ゴール(x座標>=0.5)に到達したときに10を得て、エピソードが終了します。オリジナルの環境ではゴール到達時の報酬はありませんが、ゴール到達のインセンティブを高めるために調整を加えた結果となります。

実装

パソコン向けのC言語実装

GBDKではC言語での開発が可能なものの、浮動小数点数(float, double)が使えないなど制約があります。強化学習では、プログラムにバグがなくともハイパーパラメータ設定が誤っていると全く学習が進まないなどデバッグが難しい要素があります。まずは制約のないパソコン向けの環境で実装を行って学習に成功することを確認してから、GBDKでコンパイルできるコードに書き換えることとします。

MountainCar環境の実装

まずはMountainCar環境を実装します。本家のMountainCarのダイナミクスを素直に実装しています。C言語はクラスがないので、MountainCarState構造体で状態を表現し、MountainCarStep関数に状態と行動を与えて状態を進め、戻り値で報酬を得るという形式で実装しました。

mountaincar.h

#pragma once

#define MAX_EPISODE_LEN 200

typedef struct {
    double position;
    double velocity;
    int steps;
    int done;
} MountainCarState;

typedef int MountainCarAction; // 0=left, 1=nothing, 2=right

double MountainCarStep(MountainCarState *state, MountainCarAction action);
void MountainCarReset(MountainCarState *state);

mountaincar.c

#include <stdlib.h>
#include <math.h>
#include "mountaincar.h"

double MountainCarStep(MountainCarState *state, MountainCarAction action) {
    double position = state->position;
    double velocity = state->velocity;
    double force = action - 1;
    velocity += force * 0.001 + cos(3 * position) * (-0.0025);
    if (velocity > 0.07) {
        velocity = 0.07;
    } else if (velocity < -0.07) {
        velocity = -0.07;
    }
    position += velocity;
    if (position > 0.6) {
        position = 0.6;
        velocity = 0.0;
    } else if (position < -1.2) {
        position = -1.2;
        velocity = 0.0;
    }
    state->position = position;
    state->velocity = velocity;
    state->steps += 1;

    double reward = -1.0;
    
    if (position >= 0.5 || state->steps >= MAX_EPISODE_LEN) {
        reward += 10.0;
        state->done = 1;
    }

    return reward;
}

void MountainCarReset(MountainCarState *state) {
    // uniform random between -0.6 and -0.4
    state->position = 0.2 * rand() / (double)RAND_MAX - 0.6;
    state->velocity = 0.0;
    state->steps = 0;
    state->done = 0;
}
Q学習の実装

次に、MountainCar環境で動作するQ学習を実装します。QLearningState構造体に、Qテーブルとハイパーパラメータとなるgamma (報酬割引率)、alpha (学習率)、 epsilon (epsilon-greedy法におけるランダムな行動の発生確率)を格納します。TrainEpisode関数を呼び出すことで、1エピソード実行してQテーブルを更新します。ポイントとして、1ステップ進むたびにQテーブルの更新が実行され、行動履歴を記憶する必要がありません。必要なメモリ量はエピソードの長さに依存しませんので、ゲームボーイのようなメモリが少ない環境でも実行が可能です。

qlearning.h

#pragma once
#define N_STATE_0 16
#define N_STATE_1 16
#define N_ACTIONS 3

typedef struct {
    double q_table[N_STATE_0 * N_STATE_1][N_ACTIONS];
    double gamma, alpha, epsilon;
} QLearningState;

QLearningState *QLearningStateCreate();
double TestEpisode(QLearningState *q_state);
double TrainEpisode(QLearningState *q_state);

qlearning.c

#include <stdlib.h>
#include "mountaincar.h"
#include "qlearning.h"


QLearningState *QLearningStateCreate() {
    QLearningState *state = malloc(sizeof(QLearningState));
    for (int i = 0; i < N_STATE_0 * N_STATE_1; i++) {
        for (int j = 0; j < N_ACTIONS; j++) {
            state->q_table[i][j] = 0.0;
        }
    }
    return state;
}

static int get_state_index(MountainCarState* state)
{
    int position = (int)((state->position + 1.2) / 1.8 * N_STATE_0);
    int velocity = (int)((state->velocity + 0.07) / 0.14 * N_STATE_1);
    return position * N_STATE_1 + velocity;
}

double TestEpisode(QLearningState *q_state) {
    MountainCarState state;
    MountainCarReset(&state);
    double total_reward = 0.0;
    while (!state.done) {
        // choose action
        MountainCarAction action = 0;
        int state_index = get_state_index(&state);
        double max_q = q_state->q_table[state_index][0];
        for (int i = 0; i < N_ACTIONS; i++) {
            if (q_state->q_table[state_index][i] > max_q) {
                max_q = q_state->q_table[state_index][i];
                action = i;
            }
        }
        double reward = MountainCarStep(&state, action);
        total_reward += reward;
    }
    return total_reward;
}

double TrainEpisode(QLearningState *q_state) {
    MountainCarState state;
    MountainCarReset(&state);
    double total_reward = 0.0;
    while (!state.done) {
        // choose action
        MountainCarAction action = 0;
        int state_index = get_state_index(&state);
        if (rand() / (double)RAND_MAX < q_state->epsilon) {
            // random action
            MountainCarAction action = rand() % N_ACTIONS;
        } else {
            double max_q = q_state->q_table[state_index][0];
            for (int i = 0; i < N_ACTIONS; i++) {
                if (q_state->q_table[state_index][i] > max_q) {
                    max_q = q_state->q_table[state_index][i];
                    action = i;
                }
            }
        }
        double reward = MountainCarStep(&state, action);
        total_reward += reward;

        // update model
        int next_state_index = get_state_index(&state);
        double max_q_next = q_state->q_table[next_state_index][0];
        for (int i = 0; i < N_ACTIONS; i++) {
            if (q_state->q_table[next_state_index][i] > max_q_next) {
                max_q_next = q_state->q_table[next_state_index][i];
            }
        }
        q_state->q_table[state_index][action] += q_state->alpha * (reward + q_state->gamma * max_q_next - q_state->q_table[state_index][action]);
    }

    return total_reward;
}
ハイパーパラメータ調整

main関数を実装し、上記のアルゴリズムを動作させます。プログラムの引数でハイパーパラメータを受け取って学習し、平均報酬を表示します。

main.c

#include <stdio.h>
#include <stdlib.h>
#include "mountaincar.h"
#include "qlearning.h"

int main(int argc, char** argv) {
    srand(1);
    QLearningState *q_state = QLearningStateCreate();
    q_state->alpha = atof(argv[2]);
    q_state->epsilon = atof(argv[3]);
    q_state->gamma = atof(argv[4]);
    for (int epoch = 0; epoch < 10; epoch++) {
        for (int i = 0; i < 10000; i++) {
            TrainEpisode(q_state);
        }
        double total_reward = 0.0;
        for (int i = 0; i < 100; i++) {
            total_reward += TestEpisode(q_state);
        }
        printf("Average reward: %f\n", total_reward / 100.0);
    }
    return 0;
}

optunaを用いて、適切なハイパーパラメータを探索します。

import optuna
import subprocess

def objective(trial: optuna.Trial):
    alpha = trial.suggest_float('alpha', 0.01, 0.3, log=True)
    epsilon = trial.suggest_float('epsilon', 0.01, 0.3, log=True)
    gamma = trial.suggest_float('gamma', 0.9, 0.999, log=True)
    x = subprocess.run(["main.exe", f"state_{trial._trial_id}.bin", str(alpha), str(epsilon), str(gamma)], capture_output=True)
    # 標準出力から報酬を抽出する
    prefix = "Average reward: "
    reward = None
    for line in reversed(x.stdout.decode("ascii").splitlines()):
        if line.startswith(prefix):
            reward = float(line[len(prefix):].strip())
            break
    print(reward, alpha, epsilon, gamma)
    return -reward # minimize

study = optuna.create_study()
# objective関数が最小値(報酬最大)となるハイパーパラメータを探索する
study.optimize(objective, n_trials=100)

print(study.best_params)

ハイパーパラメータの探索は数分で完了します。

記事が長くなったので分割します。次回はQ学習のアルゴリズムゲームボーイ用に軽量化します。

2023-11-03訂正: ソースコード上でランダムな行動を選択する箇所が誤っており、常に行動0が選ばれていました。プログラムの修正およびハイパーパラメータの探索を再実行しました。大きな変化はありませんでした。

修正前

        if (rand() / (double)RAND_MAX < q_state->epsilon) {
            // random action
            MountainCarAction action = rand() % N_ACTIONS;
        }

修正後

        if (rand() / (double)RAND_MAX < q_state->epsilon) {
            // random action
            action = rand() % N_ACTIONS;
        }

iPhone15Proの冷却とNeural Engineの速度について

Neural EngineによるDNNの推論を連続して行った際に、発熱などの影響により時間が経つにつれ処理速度が低下する現象がみられます。将棋AIの大会にiPhone 15 Proで出場することを想定し、iPhone 15 Proで使えるスマートフォン用冷却ファンを購入したので、冷却の有無と処理速度の関係について計測しました。以前iPad(第9世代)で同様の実験を行った際は、冷却がない場合とサーキュレータによる冷却でほとんど差がみられませんでした。以前とプログラムはほぼ同じですが、機種、冷却方法、気温が異なります。

select766.hatenablog.com

ベンチマーク

  • 機種 iPhone 15 Pro (iOS 17.0.3)
  • 気温 26℃
  • 冷却方法
    • 冷却無し(木製の机の上に端末を置く)
    • スマートフォン用冷却ファン
    • サーキュレータを上向きにし、その上に端末を置く
  • 計測内容
    • 将棋用CNNモデルを推論する(モデルの説明はこちら)。1回の推論が完了したら直ちに同じデータでもう一度推論を行う。
    • バッチサイズ64
    • Neural Engineを利用
    • 10秒ごとに、直近の10秒間で推論できたサンプル数を出力
    • 測定は10分間

スマートフォン用冷却ファンはAccpo製のものです。Amazonで2099円で購入しました。正確な商品名がわかりませんが、商品名の箇所に「Accpo スマホ 冷却ファン 熱対策 クーラー ペルチェ素子 【2023革新モデル・3秒 半導体 急速冷却】静音 小型 軽量 USB type-c 給電 実況専用 伸縮クリップ Phone(5-7.5インチ)等多機種対応 持ち運びが容易 ゲーム、ビデオ鑑賞、生放送、写真撮影などさまざまな状況に適しています」と記載されています。

スマートフォン用冷却ファンの装着状態

サーキュレータはコンセントに接続して使用するもので、強力な風が得られます。

サーキュレータによる冷却

結果を下図に示します。凡例noneが冷却無し、circulatorがサーキュレータ、coolerがスマートフォン用冷却ファンに対応します。

Core MLベンチマーク結果

サーキュレータが最速で、次にスマートフォン用冷却ファン、最も遅いのが冷却無しとなりました。最後の1分間の平均速度を計算すると、4721、4642、4347となります。以前のiPadでの実験と異なり、冷却手段が処理速度に影響することがわかりました。また、定量的な温度測定はできないものの、端末を手で触ったときに暖かさを感じます。処理速度だけでなく、バッテリーの劣化も懸念されるため将棋ソフトの運用時には冷却をしたほうがいいという結論になりました。ただし、サーキュレータは音が大きいこと、振動が大きいためスマートフォンにそれが伝わらない対策をすべきであると考えると(特に選手権会場に持ち込む場合に)不便です。スマートフォン用冷却ファンを使うのが妥当な策だと考えられます。

関連情報

こちらの動画でも、iPhone 15 Pro Maxを冷却したほうがベンチマーク結果が良いと報告されています。

iPhone 15 Pro Max is throttling! Should you care? - YouTube

冷却ファンの感想

今回購入した冷却ファンについて少しだけ紹介します。パッケージは以下の通りでした。

商品外観

箱の中には透明で開閉できるプラスチック製の箱が入っており、冷却ファン本体、USBA-USBC電源ケーブルと緩衝材が入っています。持ち運ぶ際にこの箱がそのまま使えるのが便利そうです。

パッケージ内容

スマートフォン側の接触面です。商品説明ではペルチェ素子が入っているようです。

スマートフォン側の接触

取付用の爪が自立してくれるので、将棋ソフトを動かすだけならこの姿勢で運用できます。充電ケーブルを挿すと重みで倒れてしまうので、固定しないと見栄えが少し悪くなりますが。

スマートフォンに取り付けた状態

音はデスクトップパソコンのファンとあまり変わらない印象です。選手権の会場に持参して運用するのに十分な仕様だと思います。

iPhone15ProでNeural Engineのベンチマーク(将棋用CNNモデル)

2023年9月22日、iPhone 15 Proが発売されました。新しい計算機を手に入れたらやることと言えばニューラルネットワークベンチマークですよね。Apple独自開発の機械学習専用チップNeural Engineのベンチマークを行いました。使用した深層学習モデルはdlshogi(将棋ソフト)用のものです。今後余裕があればより一般的なモデル(ResNet50等)に取り組みます。

モデルやCore MLを用いたコードの実装方法については過去の記事をご覧ください。

select766.hatenablog.com

使用機種

機種 SoC OS 発売日
iPhone 15 Pro Apple A17 Pro iOS 17.0.2 2023-09-22
iPhone 13 Pro Apple A15 Bionic iOS 17.0.1 2021-09-24
iPhone 11 Apple A13 Bionic iOS 16.6.1 2019-09-20
iPad 第9世代 Apple A13 Bionic iOS 17.0.1 2021-09-24

iPhone 11とiPad 第9世代はSoCが同じです。

測定条件

  • 推論する深層学習モデル: dlshogi(将棋ソフト)用の10層192チャンネルのものをCore ML形式(Apple独自形式)に変換したもの。バッチサイズ1での推論コストは1.1GFLOPS(積和演算=2FLOPS)。
  • 測定時間: 10秒間。推論1回が完了したら、すぐに次の推論を行う。発熱による速度の低下を回避するため、測定と測定の間に50秒以上空ける。最初の1回の推論は、ライブラリの初期化などが発生して遅い可能性があるので測定対象から除外。
  • 計算に使用するデバイス: Neural EngineまたはCPU。Core MLの設定ではGPUも利用可能だが、今回のモデルではエラーが発生し計算できなかった。iOS16よりCPU and Neural Engineという設定が追加されたが、今回の実験ではallを指定した場合(Neural Engineが利用可能であれば利用される)と速度の差は見られなかった。
  • バッチサイズ: 1, 8, 64を試した。

測定結果

下表に、Neural Engineを使用した際に1秒間に推論できたサンプル数を示します。

機種/バッチサイズ 1 8 64
iPhone 15 Pro 1604 5682 5476
iPhone 13 Pro 1331 424 4595
iPhone 11 664 885 964
iPad 第9世代 683 1118 1092

本実験で利用したデバイスの中では、iPhone 15 Proがどのバッチサイズでも最速でした。バッチサイズ64の場合で、iPhone 15 ProはiPhone 13 Proより20%程度高速です。iPhone 15 Proはバッチサイズ8の場合が最も効率的でした。iPhone 13 Proのバッチサイズ8は特異的に悪い値となりました。バッチサイズ7であれば4000以上の値となるため、内部構造上苦手なパターンがあるようです。

下表に、CPUの場合も示します。

機種/バッチサイズ 1 8 64
iPhone 15 Pro 382 509 584
iPhone 13 Pro 324 437 484
iPhone 11 131 195 315
iPad 第9世代 129 192 338

CPUについてもiPhone 15 Proが最速となりました。バッチサイズ64の場合で、iPhone 15 ProはiPhone 13 Proより20%程度高速です。

計算誤差について

Neural EngineはCPUより低い精度の浮動小数点数演算を行っているようで、同じ入力であっても誤差が大きくなります。誤差の評価方法は、モデルの学習に使用したPyTorchにおける出力との差です。要素ごとの差の絶対値の最大値を誤差の指標とします。モデルには「policy」「value」の2種類の出力があるため、それぞれについて誤差が得られます。

機種 演算器 policy誤差 value誤差
iPhone 15 Pro NE 0.11 0.0039
iPhone 13 Pro NE 0.11 0.0039
iPhone 11 NE 0.0081 0.0035
iPhone 15 Pro CPU 5.9e-05 1.7e-06
iPhone 13 Pro CPU 5.9e-05 1.7e-06
iPhone 11 CPU 5.9e-05 1.6e-06

NE: Neural Engine

iPhone 15 ProとiPhone 13 Proは同じ誤差になりました。そしてこの誤差はiPhone 11より大きいという結果になりました。新しい世代の演算器は精度が低下することと引き換えに高速化を達成している可能性があります。

おわりに

Neural Engine, CPUとも、旧世代機からの進歩がみられる結果となりました。iPhone 15 ProはiPhone 13 ProよりNeural Engine、CPUとも20%程度高速との結果が得られました。

ベンチマーク用のコードはこちらにあります。

Release iOS17リリース時の測定 · select766/dlshogi-model-on-coreml · GitHub