Azure Storage Gen 2は速かった

今年も早いもので、あっという間に12月になりました。個人的なAzure今年の目玉は、Azure Storageのパフォーマンスの向上(Gen2)と新しくなったWindows Azure Storage 2.0です。

IaaS、Web Site、Mobile Service、Media Serviceなど新機能満載なAzureですが、目立たないところで地味にストレージ関連は改善されています。ストレージはクラウドの足回りなので重要です。

omikuji by takekazu, on Flickr

Azure Storageのパフォーマンスの向上

2012/6/7 以降に作成されたストレージアカウントで、下記のようにパフォーマンスターゲットが引き上げられました。Gen 2と呼ばれているようです。以前のもの(Gen1)に比べ秒間のトランザクションベースだと4倍程度になっています(Azure Table 1Kエンティティの場合)

詳しくはリンク先を見てもらうとして下記の4点が注目です。

  1. ストレージ ノード 間のネットワーク速度が1Gbpsから10Gbpsに向上
  2. ジャーナリングに使われるストレージデバイスがHDDからSSDに改善
  3. 単一パーテーション 500 エンティティ/秒 -> 2,000 エンティティ/秒 (15Mbps)
  4. 複数パーテーション 5,000 エンティティ/秒 -> 20,000 エンティティ/秒 (156Mbps)

参照:Windows Azureのフラット ネットワーク ストレージと2012年版スケーラビリティ ターゲット


確認しよう

ではどれだけ速くなったのか確認しましょう。なるべく実利用環境に近いようにということでC#を使います。ライブライは、最近出たばかりですが、Azure Storage Client 2.0を使います。このライブラリのコードをざっと見た感じだと、従来のコードに比べてシンプルになって読みやすく速度も期待できそうです。

比較的限界が低い単一パーテーションで確認します。前記のGen2の記事には、エンティティが1KByteで、単一パーテーションの場合、2,000 エンティティ/秒というパフォーマンスターゲットが記述されています。これを確認しようとするとAzure外部からのネットワークアクセスだと厳しいのでWorkerRoleを立てて、リモートデスクトップでログインしてプログラムを実行します。プログラムは秒間2000オブジェクトを計測時間の間は作りづけないといけないのでCPUやGCがボトルネックになるかもしれません、今回はLargeのインスタンスを使うことにしました。

Largeだとメモリ7GByte、coreが8つ、ネットワーク400Mbpsというスペックなので気にしなくても良いかと思ったのですが、GCをなるべく減らすためにエンティティのデータ部分をCache(共有)します。1KByteぐらいだとあまり効果が無いかもしれませんが。

public class EntityNk : TableEntity
{
const int MAX_PROPERTY = 8;
private static List<byte[]> dataCache;
private static int dataSize = 1;
static EntityNk()
{
Clear();
}
public EntityNk(string partitionKey, string rowKey)
{
this.PartitionKey = partitionKey;
this.RowKey = rowKey;
this.Data0 = dataCache[0];
this.Data1 = dataCache[1];
this.Data2 = dataCache[2];
this.Data3 = dataCache[3];
this.Data4 = dataCache[4];
this.Data5 = dataCache[5];
this.Data6 = dataCache[6];
this.Data7 = dataCache[7];
}
public EntityNk() { }
public byte[] Data0 { get; set; }
public byte[] Data1 { get; set; }
public byte[] Data2 { get; set; }
public byte[] Data3 { get; set; }
public byte[] Data4 { get; set; }
public byte[] Data5 { get; set; }
public byte[] Data6 { get; set; }
public byte[] Data7 { get; set; }
public static int DataSize
{
set
{
if (value != dataSize)
{
Clear();
dataSize = value;
var x = dataSize / MAX_PROPERTY;
var y = dataSize % MAX_PROPERTY;
for (var i = 0; i < dataCache.Count(); i++)
{
dataCache[i] = GetRandomByte(x);
}
if (y != 0)
dataCache[x] = GetRandomByte(y);
}
}
}
}
view raw EntityNk.cs hosted with ❤ by GitHub

さらに、Threadを上げる数を減らして並列性を上げるために非同期呼び出しを使います。.NET 4.5 から await/async が使えるので割合簡単に非同期コードが記述できるのですが、少し手間がかかりました。

なんと残念ながら、Windows Azure Storage 2.0になっても APM (Asynchronous Programming Model) のメソッドしか用意されておらず、 await で使えるTaskAsyncの形式がサポートされていません。仕方がないので、自分で拡張メソッドを書きますが、引数が多くて intellisense があっても混乱します。泣く泣く、コンパイルエラーで期待されているシグニチャーをみながら書きました。コードとしてはこんな感じで簡単です。

public static class CloudTableExtensions
{
public static Task<TableResult> ExecuteAsync(this CloudTable cloudTable, TableOperation operation, TableRequestOptions requestOptions = null, OperationContext operationContext = null, object state = null)
{
return Task.Factory.FromAsync<TableOperation, TableRequestOptions, OperationContext, TableResult>(
cloudTable.BeginExecute, cloudTable.EndExecute, operation, requestOptions, operationContext, state);
}
}

この辺りは、下記のサイトが詳しくお勧めです。

参照:++C++; // 未確認飛行C 非同期処理

非同期で同時接続数が上がらない?

このコードを動かしてみたら、「単一スレッド+非同期の組み合わせだと、おおよそ2から3程度のコネクションしか作成されない」ことに気が付きました。場合によっては、5ぐらいまで上がることもあるようですが、どうしてこうなるのか不思議です。

#### ** これは、Azure Storage Client 2.0のBUG ** だったようです。2.0.2で修正されています。WindowsAzure/azure-sdk-for-net Issue #141

** [2012/12/26 このFIXに関するまとめを書きました](Azure Storage Client 2.0 CompletedSynchronously FIX) **

非同期でガンガンリクエストが飛ぶのかと思ったのですが、それほどでもなかったので、今回のコードは複数スレッド(Task)をあげて、それぞれのスレッド内で非同期呼び出しを使って処理を行うようになっています。Taskの起動には、Parallel.ForEach を使っています。

さらに、上限に挑戦するためにEntity Group Transactionを使います。TableBatchOperation のインスタンスを作って操作を追加していってCloudTableのExecuteBatchAsync()で実行します。この辺りは以前の使い方とだいぶ違っています。今回は時間を測っているだけですが、resultにはEntityのリストが帰ってきて、それぞれにtimestampとetagがセットされています。

var batchOperation = new TableBatchOperation();
foreach (var e in entityFactory(n))
{
batchOperation.Insert(e);
}
var cresult = new CommandResult { Start = DateTime.UtcNow.Ticks };
var cbt = 0L;
var context = GetOperationContext((t) => cbt = t);
try
{
var results = await table.ExecuteBatchAsync(batchOperation, operationContext: context);
cresult.Elapsed = cbt;
}
catch (Exception ex)
{
cresult.Elapsed = -1;
Console.Error.WriteLine("Error DoInsert {0} {1}", n, ex.ToString());
}
return cresult;

結果

いくつかパラメータを調整して実行し、スロットリングが起きる前後を探して4回測定しました。ピークe/sは、もっとも時間当たりのエンティティの挿入数が大きかった時の数字で秒間のエンティティ挿入数を表しています。単一プロセスでスレッドを増やしていく方法では頭打ちになってしまうので、複数のプロセスを起動して測定ています。(このあたりも少しオカシイです)下記の表の最初のカラムは起動するプロセス数です。

失敗が無かったケースで6,684、 6,932 エンティティ/秒で処理できており、Gen2で挙げられているパフォーマンスターゲットは十分達成できているようです。

測定時間の、Table Metricsを見るとThrottlingErrorと同時に、ClientTimeoutErrorも出ているのでプロセスを3つ上げているケースではクライアント側でサーバからの戻りが受けきれずにエラーになっている場合も含まれているようです。

表1 条件:エンティティサイズ 1KByte、単一パーテーション、スレッド数12、バッチサイズ100
プロセス数 最少 中央値 平均 最大 90%点 95%点 99%点 ピークe/s 成功数 失敗数
2 97.27 166.6 258 14,800 359.578 472.373 1,106.28 6,684 40,000 0
2 94.17 260.5 333.7 5,320 564.774 723.272 1,339.03 6,932 40,000 0
3 90.13 174.8 734.1 21,270 1,621.49 1,845.90 3,434.26 7,218 59,377 623
3 90.35 341.6 610.1 27,490 1,064.59 1,380.42 4,431.79 8,005 59,740 260

最後に

今回、第一世代(Gen 1)の単一パーテーションで500 エンティティ/秒というパフォーマンスターゲットに比べ10倍近いパフォーマンスを出しているのが測定できました。測定時間が短かったので、継続してこのパフォーマンスがでるのかどうかなど検証の余地はありますが、劇的に向上していると言えます。takekazuomi/WAAC201202のレポジトリに計測に使ったコードをいれてあります。

12/2の担当でしたが、JSTでは日付も変わってだいぶ遅くなってしました。データの解析に最近お気に入りの(慣れない)「R」を使ったのですが、いろいろ手間取ってしまいました。最初はRで出した図なども入れたいと思ったのですが、軸や凡例の設定がうまくできずに時間切れで断念です。

レポジトリには、なんかずいぶん古い履歴まで上がってしましたが、手元のコードを使いまわしたら出てしまいました。スルーでお願いします。


おまけ

数時間振り回してみると、エンティティ/秒の中央値は2000から3000エンティティ/秒程度になりそうです。負荷がかかり始めると、Gen 1ではスロットリングをかけてエラーにしてしまうという動きでしたが、Gen 2 ではスロットリングを随時掛けつつ2000から3000エンティティ/秒程度に絞っていくという動きになったようです。`