この記事は、CyberAgent エンジニア Advent Calendar 2014 の18日目の記事です。
本日12/18は@sitotkfmが担当いたします。
昨日は@yuichiro.nakazawa1さんの「ログ解析にNorikraを使ってみた」でした。
明日は@strskさんです。
さて、唐突な上に私事で大変恐縮ですが最近秋葉原オフィスの近くに引っ越しました。
場所は秋葉原の少し西なので通勤途中に両国を通過します。
相撲といえば今年大記録が生まれました。
「巨人、大鵬、卵焼き」の中央に座する歴史的横綱大鵬と並ぶ32度目の優勝!
そんな大横綱白鵬のブログが読めるのは
アメブロだけ!
(PCでみると、、、すごく、、、黒いです、、、)
さてCyberAgentのAdvent Calendarとしての義理を果たしたところで本題です。
私は秋葉原ラボに勤務しており、主にログの集積・解析システムの開発・運用をしています。
その中では弊社内製の分散ストリーミング解析基盤Onixを使ってストリーミング処理を実装したりもしているのですが、その際に調査した「HyperLogLog」についてお話させて頂きます。
- ストリーム処理について
あらかじめ決められた間隔で処理を行うバッチ処理とは違い、ストリーム処理というのは時々刻々と流れてくるデータに対し処理を行います。
利点としてはその都度解析を行うのでデータの反映が早い点で、低レイテンシが求められる場合は向いています。昨日の記事とネタ被りしてるような
しかし、長所があれば短所もあるのが世の常、ストリーミング処理には苦手な処理があります。
それはカーディナリティ(基数)を求める処理です。つまり集合の異なる要素の数を求める計算ですね。Web系でよくある指標だとユニークユーザがその一つです。
何が難しいのかというとカーディナリティというのは算出の度に集合をなめる必要がありストリーミングで次々データが流れてくるストリーミング処理とは相性が悪いのです。
都合が悪いのは計算量だけではなく、集合を持ち続ける必要があるためデータの範囲が大きくなるとメモリを逼迫してしまいます。
例えばユニークユーザを求めることを考えたとき、ユーザの集合をビット(bit)にマッピングするとします。ユーザAを「0001」(集合A),、ユーザBを「0010」(集合B)、ユーザCを「0100」(集合C)とマッピングし、カーディナリティを求めるときは全ての和集合をとりビットの数を数えます。(A∪B∪C=「0111」なのでユニークユーザは3です。)
しかし、このビットマップ方式だと日本の人口約1億2千万をカバーするのに2の27乗のビットが必要になります。
これはビットだけ考えた場合16MBですね。
「16MBって大した事無くない?」
そう思った方はすぐにキン肉マンを読んで頂きたいです。
16MBなのはあくまでユーザが来たかどうかだけしかみていないので、例えばサイト内のページ毎のユニークな来訪者を求めたい場合はそのページの数だけビットマップが必要になります。
このようなかけ算にかけ算を掛け合わせて数値が膨大になる俗に言うウォーズマン的計算法でメモリはすぐ枯渇します。
(そもそも日本人だけがサイトをみるとは限らないですし、一人が複数のidを持つ可能性もあるので16MBというのも怪しいもんですが、まああくまで仮定という事でお願いします。)
そしてメモリサイズが大きくなると集合が大きくなるので計算量はえてして増加しがちです。ストリーム処理ならこっちの方が問題になったりします。
あらどうしましょうということで、HyperLogLogの登場です。
- HyperLogLog
「HyperLogLog」というのはアルゴリズムの名前です。
どういうアルゴリズムか、かいつまんで言えば「多少精度に目をつぶって省メモリでカーディナリティを推定する」アルゴリズムです。推定値であることに気をつけて下さい。
アルゴリズムの流れは次のようになります。元論文はここですね。
- m=2^bとなmを決めてm個のカウンターを用意
- データをバイナリにハッシュ化
- バイナリを二つに分割し、一方でカウンタのindexを、他方でカウンタに追加する値をもとめる。このときの追加する値はバイナリで1が最初に出た位置とする。
- Indexに対応するカウンタの値と比較し、現在の値が大きければカウンタを更新
- 1~4を繰り返す
- カウンタの調和平均をカーディナリティとする
カウントの保持する値から統計的な手法を用いてカーディナリティを推定するのがHyperLogLogです。カウンタの値がバイナリの1の位置にすることでカウンタの上限値を押さえる事が出来ます。
相対誤差は1.04√mで、メモリサイズを減らすと誤差が大きくなる関係にあります。
また、HyperLogLogで求められる値は推定値なのでカウンタの数よりも数が小さくても誤差が生じ得ます。
なのでカーディナリティが大きい場合(大きくなると予想出来そうな場合)に使う事をおすすめしたいです。
ちなみにこのHyperLogLogはRedisでも用意されています。
prefixがPFのコマンドですね。(PFはPhilippe FlajoletにちなんでつけられたとRedis開発者@antirezのブログに書いてあります。)
既にHyperLogLogのライブラリは色々あります。
Javaだとjava-hllがあります。
なんでせっかくストリーミングアルゴリズムとしての紹介なのでストリーミング処理としてのHyperLogLogを実装をしてみようと思います。
業務ではストリーム処理を書くのに前述のOnixを使っているのですがせっかく外に公開するものなので前から使ってみたかった「Spark Streaming」で実装してみようと思います。
実装の前にSparkとSpark Streamingについて軽く触ります。
- Sparkとは
Sparkとは最近注目の分散処理システムです。
分散処理システムといえばHadoopが有名ですが,SparkはHadoopと異なり中間データを一々ディスクに書き込むということをしないのでHadoopと比べて高速に処理が行える上に
Spark SQL(SQL), MLLib(数値計算), GraphX(グラフ演算)など解析別にAPIが用意されていて様々な分野で利用です。
Sparkの特徴としてRDDというデータ構造があり、RDDというデータ構造にデータをつめこみRDDに変換(transformation)をかけることで処理を行います。
また分散したRDDの一部が欠落しても変換の手順が分かれば欠落した部分を再計算(再変換?)して復旧することができます。
本当はもっと書くべきなんでしょうが主題がぶれるのでSparkについてはこのぐらいにさせてください。
- Spark Streamingとは
Spark Streamingはストリーミングデータをウィンドウで切ってRDDに詰めて処理するAPIです。
この連続したRDDのフローをDStreamと呼びます。
なのでSpark Streamingの処理は準リアルタイム(near realtime)なのですがウィンドウサイズを小さくするとレイテンシは低下します。その粒度は要件と相談ですね。
Spark StreamingはDStreamに流れてくるRDDに変換をかけることで処理を行います。
WordCountならlines Dstreamに対して単語にパースするflatMapの変換をかける事でwords Dstreamに変換するといった具合ですね。
DStreamから流れてきたデータをSparkでバッチで処理することもできるみたいです。
(図はSpark Streamingの公式ドキュメントから引用しました。)
- Spark StreamingでHyperLogLogを実装
では早速実装に入ります。
Spark StreamingはScalaとJavaで書く事が出来るのですがJavaだとコードが若干煩雑になりそうで普段あまり使わないScalaで書いてみました。
クソコードかと思いますがご了承ください。
package jp.co.cyberagent.spark
import scala.util.hashing.MurmurHash3
import org.apache.spark.SparkConf
import org.apache.spark.SparkContext
import org.apache.spark.rdd.RDD
import org.apache.spark.streaming.Seconds
import org.apache.spark.streaming.StreamingContext
import org.apache.spark.streaming.StreamingContext.toPairDStreamFunctions
import org.apache.spark.streaming.Time
import org.apache.spark.streaming.flume.FlumeUtils
import org.apache.spark.streaming.flume.SparkFlumeEvent
object HllStreaming {
var b = 14
var window = 10;
var slide = 10;
def main(args: Array[String]) {
if (args.length >= 1) {
b = args(0).toInt
}
if (args.length >= 2) {
window = args(1).toInt
}
if (args.length >= 3) {
slide = args(2).toInt
}
var m = math.pow(2, b).intValue()
var alpha = getAlpha(m)
println("m = " + m);
println("window = " + window);
println("estimate error rate = " + 1.04 / math.sqrt(m));
val conf = new SparkConf().setMaster("local[2]").setAppName("HyperLogLogCount")
val sc = new SparkContext
val ssc = new StreamingContext(conf, Seconds(10))
val dStream = FlumeUtils.createStream(ssc, "localhost", 41414)
import StreamingContext._
val rootStream = dStream.map(hashMapping) // indexとvalueを求める
.reduceByKeyAndWindow(
(
(a:Int, b:Int) => if(a > b) a else b),
Seconds(window),
Seconds(slide)
)
.map(c => math.pow(2, -c._2)) // 2^-M[j]
rootStream
.reduce(_ + _)
.union(rootStream.count.map(c => c.toDouble)) // indexの総数をもつRDDをjoin
.foreachRDD((rdd, t) =>calcAndShow(rdd, t)(m, alpha))
ssc.start()
ssc.awaitTermination()
}
def hashMapping(event: SparkFlumeEvent) = {
val body = new String(event.event.getBody.array)
val hash = MurmurHash3.stringHash(body)
val index = hash & ((1 << b) - 1)
val value = getValue(hash)
(index, value)
}
def getValue(hash: Int): Int = {
val upperValue = hash >> b;
var value = 1
var mask = 1
for(i <- 0 to 32 ) {
if((upperValue & mask) != 0) return value;
value += 1
mask <<= 1;
}
return value;
}
def getAlpha(m: Int) = {
if(
m == 1 ||
m == 2 ||
m == 4 ||
m == 8
) {
1
} else if(m == 16) {
0.673
} else if (m == 32) {
0.697
} else if (m == 64) {
0.709
} else {
0.7213/(1+ 1.079/m)
}
}
def calcAndShow(rdd: RDD[Double], t: Time)(m: Int, alpha: Double) ={
if(rdd.collect().length != 2)
println("Data Not Found") // データが流れてこないとき
else {
val d = rdd.collect()(0) + (m - rdd.collect()(1)); // 2^-(M[j])と2^0の総数
val hll = alpha * m * (m / d); // HyperLogLogの値
printf("hyperLogLog value = %f at %s\n", hll, t)
}
}
}
まず真っ先に頭の悪い引数の取り方をしていますが、第一引数がb、第二引数はウィンドウの時間(秒)、第三引数はスライドの時間(秒)です。
例えばウィンドウの時間を30秒としてスライドの時間を10秒とするとSpark Streamingでは
12:00:00~12:00:30→12:00:10~12:00:40→12:00:20~12:00:50
とい30秒間のデータの保持をします。
データはFlumeで送られるようにしています。Spark StreamingはFlume以外にもkafkaやZeroMQ, MQTTなんかでもデータを送る事が出来ます。
一般的にHyperLogLogの実装はカウンタを用意してその値を更新して行くんですが、上記の実装では流れてきたデータに対してindexを割り当てて、その中で最大値を求めてます。
しかし、データによってはindexが割り当てられない数値が出てしまいますが、配列に入れている訳ではないので値の無いindex(M[j] = 0となるj)を判別する事ができません。
なのでrootStreamを分割してindexの総数を求め、m - (indexの総数)より割り当てられていないindexの総数を求めています。
(なんか我ながらスケールしなさそうな実装ですが、、、)
あとScalaって標準でmurmurHashがあるんですね。
- 実験(というかテスト...)
では早速動かしてみました。
環境はローカルのMac Book Pro(厚)で動かしました。spark-1.1.1をダウンロードしてsbtでコンパイルしてlibディレクトリにjarファイルを置いてspark-submitしているだけです。なので分散等は行っていないのでご了承ください。
まずデータについてですがよくSpark Streamingではtwitterのデータを流したりするんですが、今回はカーディナリティの誤差を見たいのであらかじめテストデータを作っておいてflumeを使ってワンショットで送っています。
テストデータはdata1とdata2を用意しており、ランダムに繋がれた4文字の文字列が10万行書かれたファイルとなっております。
このテストデータのカーディナリティは次のようになります。
実験なんですがbを14, ウィンドウを30, スライドを10にしてdata1→data2の順番で流しました。data2はdata1のデータを流してスライドしたのを確認して流しています。
ではその時の標準出力になります。
data2はdata1の値が出てから流したのでdata1のカーディナリティは90649.265869になりますね。
その後、164417.376140が二行連続で出ますがこの値はdata1∪data2の値となります。
そして次の行ではdata1のデータがウィンドウがら落ちているので、90901.397719はdata2のカーディナリティになります。
それぞれの誤差なんですが次のようになります。
データ | 実際のサイズ | HLLの値 | 誤差率(%) |
---|---|---|---|
data1 | 89763 | 90649.265869 | 0.987 |
data2 | 90017 | 90901.397719 | 0.982 |
data1∪data2 | 161985 | 164417.376140 | 1.501 |
b=14の時の誤差は約0.8%なのでちょっと上回ってますがまあ大体こんなもんだろという数値ですね。murmurHashのseed値を変えたりデータが変わるとまた違った結果になるかと思います。
まとめ
ここまででHyperLogLogをストリーミング用にSpark Streamingで実装して試してみました。
まあぶっちゃけそんなストリーミングでHyperLogLogを使わざる得ないってかなりのデータが流れてこないと効果が。。。
それでもHyperLogLogは膨大なデータのカーディナリティの算出には有用ですし、Spark StreamingはSparkそのものの勢いも相まって期待のプロダクトであることには間違いないです。
まあ二つを混ぜる必要は無かったですね。。。
あと上記のコードではハッシュのseed値が起動後変わらないので同じデータで同じ結果が出てしまいます。まあそれで問題はないんでしょうが、バッチだと実行時のパラメータはその都度変更出来るけどSpark Streamingのようなウィンドウがスライドするような場合は処理中にパラメータを変更させなければならないので、ここがストリーム処理の難しさだと思います。ウィンドウサイズとスライドの時間を一緒にすれば出来なくはないとは思いますが。
アドベントカレンダーって氷水被ってもらう人を指名するんでしたっけ?違うか。
それでは
ごっつぁんでした!