チューニング③「NDKの導入」
前節まではDalvik/ART上でのチューニングを行ってきましたが、本節ではさらなるチューニングの手段として「NDKの導入」を行います。
アプリの開発中にパフォーマンスで困ったらNDKで高速化という話はよく出てきますが、具体的にどれくらいのパフォーマンス改善を望めるのかの目安がありません。そもそも、DalvikはJITによって、ARTはAOT(Ahead-Of-Time)によって、重要な部分は何もしなくてもNativeで実行されるのですから、わざわざ開発者が高速化を目的にNDKを利用する必要があるのか、という疑問もあります。
そこで、NDKの導入により実際にどれほど高速化されるのかを、スライドパズルアプリで調べてみます。
実装にはC言語を採用しました。C言語での実装の場合、Dalvik/ARTと違い、ヒープ領域の管理は開発者に任されます。そこで、Dalvik/ARTでのGCに変わる手段として今回は参照カウント方式を採用しました。「参照がゼロになった時点で即時解放」という単純な仕組みですが、GCで問題になっていた生存オブジェクトの探索コストがかからなくなるため、大幅な高速化が期待できます。
NDKを導入するのはスライドパズルアプリの中核である探索エンジン部分です。UI制御や問題の入力などはボトルネックではないので、実装はJavaのままとします。
また、アルゴリズムや処理内容での差異をなくすため、C言語で実装する探索エンジンはJava版と同一処理を実装します。実は、NDKによるC言語での実装を元々見越していたため、Java版の探索エンジンにはMap/Setなど標準コレクションを一切利用せず、代わりに2種類の軽量コレクションを独自に実装してあります。つまり、オブジェクトの確保・解放処理以外はほぼ同一の処理内容となります。
JavaからC言語への置き換えの例として、前節でも出てきた盤面を表すJava版の Field クラスを、C言語版では Field 構造体として実装しています。両者を比較してみましょう。
public static class Field {
// 盤面
private int[] field;
// equalsの呼び出し回数が多いのでhashCodeを事前に計算して保持しておく。
// 盤面(field)のみに依存
private int hashCode;
// 移動の回数
private short operationCount;
// 移動の記録(アクティブ)、operationsと合わせることで完全な移動の履歴になる
private byte currentOperation;
// 移動の記録(非アクティブ)、immutable。
// 子孫およびFieldOperationと同一オブジェクトを共有する
private byte[] operations;
public Field(FieldOperation fieldOperation) {
init(fieldOperation);
}
public void init(FieldOperation fieldOperation) {
this.field = fieldOperation.field;
this.operations = fieldOperation.operations;
this.currentOperation = fieldOperation.currentOperation;
this.operationCount = fieldOperation.operationCount;
this.hashCode = 0;
for (int fieldEntry : this.field) {
this.hashCode ^= fieldEntry;
this.hashCode *= 13;
}
}
@Override
public int hashCode() {
return hashCode;
}
@Override
public boolean equals(Object obj) {
if (obj == null || !(obj instanceof Field)) {
return false;
}
Field another = (Field) obj;
if (hashCode != another.hashCode
|| !Arrays.equals(field, another.field)) {
return false;
}
return true;
}
public String getOperations(boolean reverseMode) {
return FieldOperation.getOperations(operations, currentOperation,
operationCount, reverseMode);
}
}
typedef struct {
Uint32Array *field;
Uint8Array *operations;
int reference;
int hashCode;
uint16_t operationCount;
uint8_t currentOperation;
} Field;
Field *Field_alloc(FieldOperation *fieldOperation) {
Field *this = malloc(sizeof(Field));
if (this == NULL) {
return NULL;
}
this->reference = 1;
this->field = fieldOperation->field;
Uint32Array_retain(this->field);
this->operations = fieldOperation->operations;
Uint8Array_retain(this->operations);
this->currentOperation = fieldOperation->currentOperation;
this->operationCount = fieldOperation->operationCount;
int i;
this->hashCode = 0;
for (i = 0; i < this->field->length; ++i) {
this->hashCode ^= this->field->list[i];
this->hashCode *= 13;
}
return this;
}
int Field_equals(Field *this, Field *another) {
if (another == NULL) {
return 0;
}
if (this->hashCode != another->hashCode
|| Uint32Array_equals(this->field, another->field) == 0) {
return 0;
}
return 1;
}
void Field_retain(Field *this) {
this->reference++;
}
void Field_release(Field *this) {
if (--this->reference <= 0) {
Uint32Array_release(this->field);
Uint8Array_release(this->operations);
free(this);
}
}
void Field_getOperations(Field *this, unsigned char reverseMode,
char *operationsString) {
FieldOperation_getOperations(this->operations, this->currentOperation,
this->operationCount, reverseMode, operationsString);
}
Javaではコンストラクタで自動的にヒープ領域に確保されるのに対し、C言語では Field_alloc() および Field_release() 関数の内部でmalloc/freeを利用して領域の確保・解放を行っています。また、Javaでは equals() および hashCode() メソッドの規約に従うために int hashCode() メソッドをオーバーライドしているとか、C言語の Field_getOperations() 関数と値の戻し方が異なるとかいうように細かな差はあるものの、両者は同一の処理となっています。
では、測定結果を見てみましょう。
| プール | Field | 処理時間(秒) | 処理速度(問/分) | |
|---|---|---|---|---|
| Dalvik | OFF | 標準 | 38,959 | 7.70 |
| Dalvik | ON | 標準 | 28,529 | 10.52 |
| Dalvik | ON | 配列化 | 16,740 | 17.92 |
| ART | OFF | 標準 | 24,169 | 12.41 |
| ART | ON | 標準 | 21,950 | 13.67 |
| ART | ON | 配列化 | 18,067 | 16.60 |
| NDK | OFF | 標準 | 8,784 | 34.15 |
| PC(Corei5 3.4GHz) | OFF | 標準 | 179 | 1,675.98 |
| PC(Corei5 3.4GHz) | ON | 標準 | 179 | 1,675.98 |
| PC(Corei5 3.4GHz) | ON | 配列化 | 230 | 1,304.35 |
Dalvikの初期状態から比べると4.44倍、最も高速化したプールON、Fieldオブジェクトの配列化状態と比べても1.91倍というパフォーマンスが出ました。高速化に最も寄与したのは、GCが一切不要になったことです。他にも、JIT/AOTで生成されるJava特有のオーバーヘッドを含んだネイティブコードよりも、開発者がシンプルに書いた実装のほうがAndroidの環境では実行効率が良いことが考えられます。
