HadoopのWritableはJavaのSerializeよりも厳密じゃない分、軽い実装になってる。その辺の動きを少し追ってみる。
基本的なところとして、longを扱うLongWritable、doubleを扱うDoubleWritableなど、プリミティブな型に対してはそれぞれ用意されている。
あとは文字列を扱うText、値がない場合用のNullWritable、配列を格納するArrayWritable、Mapを格納するMapWritableなどが存在する。
TextはStringを扱う。シリアライズして出力される値は、その文字列のバイト長が最初に指定され、その後にbyte配列が記述される形になる。
たとえば「abc」をシリアライズすると以下のようなbyte配列が出力される。
3 97 98 99
長さが3で、あとはa(97)、b(98)、c(99)という意味。
出力する文字列が「あいう」なら以下のような10byte(バイト長の指示に1byte、UTF-8なので1文字3byte×3)になる。
9, -29, -127, -126, -29, -127, -124, -29, -127, -122
バイト長の指示は,後述のVIntWritableを利用している。
TextはStringだけでなくbyte配列も引数に取れる。ので、ちとトリッキーだけど下記のように別の文字コードでデータを入れられたりしなくもない。
Text writable = new Text("あいう".getBytes("euc-jp"));
EUC-JPは平仮名は1文字2バイトだからサイズが小さくなったりする。
6 -92 -94 -92 -92 -92 -90
文字列に戻す時はこんな感じ。
String str = new String(writable.getBytes(), "euc-jp");
変換しても問題が起きない(且つサイズが減る)文字列の場合は、自動でEUC-JPに変換する(変換したかどうかは1byteのフラグに保持する)みたいなクラスがあっても面白いかもしれない。
longを扱う。8byte固定。後述のVLongWritableの方がかさばらなくて良い。
数値の大きさに合わせて、出力するデータのサイズを調整してくれるクラス。
たとえば-112〜127の数値は1バイトで出力できる。
// 内部的にはこんな感じ
if (i >= -112 && i <= 127) {
stream.writeByte((byte)i);
return;
}
1byte目に-113〜-128が指定された場合は、-113ならその後に入っている可変長データが1byte、-114なら2byte、-115なら3byteであるものとして扱う。
実際に下記のようなコードを実行して、VLongWritableに設定した数値がどのように出力されるか見てみる。
long num = 100L;
VLongWritable vl = new VLongWritable(num);
ByteArrayOutputStream os = new ByteArrayOutputStream();
DataOutputStream out = new DataOutputStream(os);
vl.write(out);
for( Byte x : os.toByteArray() )
System.out.println(x);
下記のように、100(-112〜127の範囲内)を出力した場合は「100」という1byteの数値になるが、255を入れた場合は「-113, -1」と2バイトになって返ってくる。
入力値 | 出力結果 | バイト数 |
---|---|---|
100 | 100 | 1バイト |
255 | -113, -1 | 2バイト |
256 | -114, 1, 0 | 3バイト |
65535 | -114, -1, -1 | 3バイト |
65536 | -115, 1, 0, 0 | 4バイト |
16777216 | -116, 1, 0, 0, 0 | 5バイト |
Long.MaxValue | -120, 127, -1, -1, -1, -1, -1, -1, -1 | 9バイト |
72,057,594,037,927,936を超える数字は、LongWritable利用時の8バイトよりも多い9バイトを消費してしまうけど、72京なんて数値扱うことはそうそうないので普通はVLongWritableを利用した方がサイズは小さくなるはず。
VIntWritableも同じロジックでintの値を扱う。VIntWritableにした場合は、32bitまでの情報しか扱えなくなるというだけで、VLongWritableと比べて情報量が小さくなるわけではない。
booleanを扱う。1bitだけ使うというわけにもいかないので、1byte使用で0か1。
IntWritableはintを扱う。シリアライズ時は4byte使用。
ByteWritableはbyteを扱う。1byte。
ちなみにShortWritableは存在しないらしい。仲間はずれ。
DoubleWritableはdoubleを扱う。8byte。
FloatWritableはfloatを扱う。4byte。
Javaのシリアライズは重いという理由で、Hadoopでは独自のシリアライズ機能が利用されている。
実際のところ普通のJavaのヤツだとどうなるのか。試しにintを1つwriteObjectで出力してみる。
int i = 10;
ByteArrayOutputStream os = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(os);
oos.writeObject(i);
for (Byte x : os.toByteArray())
System.out.println(x);
結果はこんな感じ。
-84 -19 0 5 115 114 0 17 106 97 118 97 46 108 97 110 103 46 73 110 116 101 103 101 114 18 -30 -96 -92 -9 -127 -121 56 2 0 1 73 0 5 118 97 108 117 101 120 114 0 16 106 97 118 97 46 108 97 110 103 46 78 117 109 98 101 114 -122 -84 -107 29 11 -108 -32 -117 2 0 0 120 112 0 0 0 10
うん、これは重いね。int(4バイト)を格納するのに82バイトかかってます。
上記のバイト配列の中には、java.lang.Integerとか、java.lang.Numberという文字も入ってたり、各クラスごとに設定されている8byteのserialVersionUID(18 -30 -96 -92 -9 -127 -121 56の部分)が入っていたりする。
こうした情報を入れることで、シリアライズした側のクラスと、デシリアライズした側のクラスが一致していることが保証されるわけだけど、大規模データを扱うには確かに向かない。
0byteのデータを扱う。writeしても何のデータも出力しないし、readFieldsしても何のデータも読み込まない。働いたら負け派。
Key、もしくはValueが不要なケースではこれを使うと一番軽いはず。
byte配列のWritable。シリアライズすると配列の長さ(4bit)+byte配列の内容が出力される。
たとえば「abc」という文字は以下のように出力される。
0 0 0 3 97 98 99
長さの指定に4byte使ってしまうところが微妙。TextならVIntWritableで指定するので、同じ文字列でも3 97 98 99の4byteで表現できるのに。
ArrayWritableはWritableの配列を扱うことができる。例えば以下のように、IntWritableの配列を格納したり。
IntWritable i1 = new IntWritable(3);
IntWritable i2 = new IntWritable(4);
ArrayWritable w = new ArrayWritable(IntWritable.class, new Writable[] { i1, i2 });
上記のArrayWritableのシリアライズ結果は以下のようになる。
0 0 0 2 0 0 0 3 0 0 0 4
長さが2で、intが2つ(3と4)が格納されている。
ArrayWritableはこのままMapReduceで利用するとエラーになる場合がある。MapperやReducerのインプットに利用される値は、引数なしでインスタンスが生成できないと「コンストラクタねーぞ」と怒るので。
MapReduceで利用する際は下記のようなクラスを用意してやる。
class IntArrayWritable extends ArrayWritable {
public IntArrayWritable() {
super(IntWritable.class);
}
}
名前の通り、2DなArrayWritable。二次元配列のWritableを格納する
IntWritable[][] twoD = new IntWritable[2][2];
twoD[0] = new IntWritable[] { new IntWritable(3), new IntWritable(4) };
twoD[1] = new IntWritable[] { new IntWritable(5), new IntWritable(6) };
twoD[1] = new IntWritable[] { new IntWritable(7), new IntWritable(8) };
TwoDArrayWritable w = new TwoDArrayWritable(IntWritable.class, twoD);
上記のような{3, 4}{5, 6}{7, 8}が入ったTwoDArrayWritableのシリアライズ結果は、以下のようになる。
0 0 0 3 0 0 0 2 0 0 0 2 0 0 0 3 0 0 0 4 0 0 0 5 0 0 0 6 0 0 0 7 0 0 0 8
見ての通り、配列の長さが[3][2]で、値が2,3,4,5,6,7,8の順で入っている。
ArrayWritableと同じく、MapReduceで利用する場合は継承してWritableクラスの型を明示する。
class IntTwoDArrayWritable extends TwoDArrayWritable {
public IntTwoDArrayWritable() {
super(IntWritable.class);
}
}
名前の通り、Mapで値を保持できるWritableクラス。
ArrayWritableとは違い引数なしのコンストラクタを持っているので、継承しなくてもMapReduceの処理で利用できる。
どうやってWritableクラスの情報を保持しているかというと、IntWritableやTextなどの基本的なWritableクラスについてはあらかじめ1バイトのIDを定めておき、「WritableクラスのID, 値」という順序でデータを入れている。
例えば以下のようなMapWritableを生成したとする。
MapWritable writable = new MapWritable();
writable.put(new IntWritable(3), new LongWritable(4));
writable.put(new VLongWritable(5), new Text("a"));
MapWritableの中で、IntWritableのIDは-123、LongWritableのIDは-122、VLongWritableのIDは-113、TextのIDは-116と定義されている。
ので、上記のMapWritableをシリアライズすると以下のように出力される。
0 0 0 0 2 -123 0 0 0 3 -122 0 0 0 0 0 0 0 4 -113 5 -116 1 97
要素の長さが2で、IntWritable(-123)の3、LongWritable(-122)の4、VLongWritable(-113)の5、Text(-116)のaと読める。
型指定に1byteしか使わないので、そこそこに現実的なサイズでデータを記述できる。
要素を取り出す時は下記のようにキャストする。
LongWritable l = (LongWritable) writable.get(new IntWritable(3));
ObjectWritableはJavaのシリアライズのようにクラス名なども保存しておく系のWritableクラス。
例えばaという文字列(String)をObjectWritableでシリアライズすると以下のようになる。
0 16 106 97 118 97 46 108 97 110 103 46 83 116 114 105 110 103 0 1 97 j a v a . l a n g . S t r i n g
Javaのシリアライズよりは軽い。