ディスカッション (11件)
たとえ小さなデータであっても、すべてに意味がある。「Every Byte Matters(すべてのバイトが重要である)」という意識が、パフォーマンスを劇的に向上させる鍵となります。極限までコードを削ぎ落とし、効率を追求する姿勢こそが、優れたエンジニアの証です。
だからもし速度が必要なら、オブジェクト指向プログラマとしてのプライドを飲み込んで、データを配列に詰め込むしかないってこと。
JVMは今のところメモリ割り当てがかなりひどいね。プリミティブ型以外のすべてのオブジェクトには、確か12バイトのヘッダーがあるし。でもJVM界隈には朗報があって、次のリリースではこれが8バイトに減らされる予定なんだ。それにProject Valhallaのおかげで、ケースによってはヘッダーを完全になくせるようになる。Project Valhallaにはオフヒープメモリを管理するツールも含まれていて、これは多くの場面で重要になるね。
JVMって不思議な立ち位置で、AOTコンパイルされる言語に対抗するにはヒープを使いすぎるし、かといってインタープリタ言語と比べると起動時間が遅すぎる。プラットフォームとしての重要性を維持するには、こうした改善が不可欠だと思う。
ちょっと話は逸れるけど、ミリ秒、マイクロ秒、ナノ秒単位までこだわるべきだよね。最近はレスポンスタイムや計算リソースの無駄遣いに対して、あまりにも無頓着になりすぎてる。
新しいフィールドごとのコストなんてほとんど考慮されない
Javaに限らず他のほとんどの言語でも、開発者の多くはフィールドごとのコストなんて考えないだろうね。でも、マイクロ最適化が必要な人たちは間違いなく気にかけてるよ。Javaの標準ライブラリでも、データレイアウトは非常に重要な懸念事項だ(もちろん、本当に重要な部分だけ最適化すべきで、プログラムのホットスポットにならない場所をいじっても意味はないけどね)。ただ、並行処理が絡むときにキャッシュラインの共有を避けるために、あえてレイアウトを分散させたい場合もある。標準ライブラリの中にもそういった例は見つかるはずだよ。
そうだよ、「最上位バイト(most significant bytes)」とか「最下位バイト(least significant bytes)」なんていう憎たらしい呼び方はやめるべき。全てのバイトが重要なんだ。
この記事は「すべてのバイトが重要」がいかに嘘かを見事に示しているね。まず、本当のトピックは「配列の構造体(array-of-structs)」対「構造体の配列(struct-of-arrays)」なのに、新しいフィールドのコストの話から始まっているし。それに、これを見てよ。
これがどれほどの影響を持つか?
100万体のモンスターの is_alive(1バイト)を読み込む
ここでは1バイトを読み込んでいるわけじゃなくて、100万バイトを読み込んでいるんだよね!もちろん、100万バイトへのアクセスを最適化するのは検討の価値がある。でも、たった1バイトへのアクセスを最適化することには意味がない。
個人的には記事自体は読む価値があると思うけど、タイトルはもっと適切にするべきだったね!
理想を言えば、さらに一歩進んでis_aliveをビットマスクで保持して、SIMD命令を使ってゼロをフィルタリングするくらいまでやれるといいね。
俺が始めた頃は、RAMが256「バイト」(KBじゃなくて)しかないデバイスでマシン語を書いてた。実行ファイルをインストールして、スタックを確保して、ヒープをセットアップするのに、そのたった256バイトしかなかったんだ。
情報を伝えるために、よくバイトじゃなくてビットフィールドを使ってたな。
本当に大変な時代だったよ。
とはいえ、大雑把に書けることには間違いなくメリットがある。高度に最適化されたものを作るにはすごく時間がかかるからね。もし新しいプロパティを宣言するだけで30秒、ビットフィールドを設計するのに1時間かかるとしたら、それは大きなコストの損失になる。
ただ、最近は狂気じみたことも多い。つい最近も数日間、メモリを馬鹿食いする犯人を追い回してたんだけど、結局数ギガバイトものメモリを食いつぶしていたのはApple MapKitだった。単純な回避策は見つけたけど、そこにたどり着くまでが長かったよ。OSを疑っても、大抵は自分のせいだし、OSを責める前にあれこれ全部試すには時間がかかるからね。
ヒント:MacでLキャッシュサイズを取得するコマンドはこれだよ。
$ sysctl -a | grep "l.*cachesize" | gnumfmt --field=2 --to=si
hw.perflevel1.l1icachesize: 132k
hw.perflevel1.l1dcachesize: 66k
hw.perflevel1.l2cachesize: 4,2M
hw.perflevel0.l1icachesize: 197k
hw.perflevel0.l1dcachesize: 132k
hw.perflevel0.l2cachesize: 13M
hw.l1icachesize: 132k
hw.l1dcachesize: 66k
hw.l2cachesize: 4,2M
それから、LEVEL1_DCACHE_LINESIZEに相当するのはこれ。
$ sysctl -a | grep hw.cachelinesize
hw.cachelinesize: 128
キャッシュ内のライン数と行数が異なることが多い点は、ワークロードによっては重要になるかもしれないから書き留めておこう。
通常のキャッシュサイズは「行数 × ウェイ数 × ラインサイズ」で決まって、行数は 2の「インデックスビット数」乗になる。例えば、ほとんどのIntel 64やAMD 64プロセッサは、L1キャッシュにおいて log2(ページサイズ) - log2(ラインサイズ) = 12 - 6 = 6ビットをインデックスとして使っている。だから8ウェイセットアソシアティブなL1キャッシュなら、64セット × 8ライン/セット × 64バイト/ライン = 32KBになる。12ウェイなら 64 × 12 × 64 = 48KBだ。ほとんどのプロセッサのL1キャッシュって、行数がたった64行しかないって知った時は驚いたよ!
*仮想インデックスと物理インデックスを同一にするため(そうすれば行の取得とTLBルックアップを並列で実行できるからね)。