『「就職氷河期」なんてあったんだろうか?』論について

ブコメだと書ききれないのでここで。

finalvent.cocolog-nifty.com

とあるもののちょっと微妙で。失業率から見てみるとまるで違った光景が見えてくるわけで

統計局ホームページ/労働力調査 長期時系列データ
労働力調査 長期時系列データ の「表3 (4) 年齢階級(5歳階級)別完全失業者数及び完全失業率」から年齢階層別の完全失業率をグラフ化したもの

f:id:ka-ka_xyz:20190407233331p:plain
年齢階層別完全失業率

これを見る限りだと

  1. 失業率を見ると1990年台中盤から2000年台前半までの所謂「氷河期」には24歳までの年齢で特に顕著に他の期間では見られない失業率の上昇と長期の高止まりが発生しており、特異な期間と言って良いのではないか
  2. 大卒者が含まれない年齢階層(15~19歳、グラフの青色系列)でも「氷河期」で失業率は高止まりしており、"安定雇用を求めて増加した大卒者"や"そもそも大学生が増えた分、就職先は争われるようになるだろう。"という話でもなさそう


といえるかなと。失業率だとここまではっきり見えている就職難が有効求人倍率で見えてこないあたりは本職の社会学者の人の守備範囲だと思うが、少なくとも失業率を見ないで有効求人倍率でだけ語るのは無理があると思う。
また、

思い返すと、私が子供のころや青年期でもいつも就職しやすかったわけでもない。当時、就職できなかった人はどうなったかというと、おそらく自営業になっていたのではないか。

とあるものの

https://www.chusho.meti.go.jp/pamflet/hakusyo/h17/hakusho/image/17330320.png
中小企業庁:2.中小企業を巡る環境の変化と開業率

を見ると、1970年台以降ずっと20代の自営業者は少なく、30代以降で増える(まあ元手とかは必要だろうし)データがあって、新規大卒者が自営業者になるというルートは70年台でもかなり珍しいものだったはず。30代以降でも1980年台からずっと減少傾向は続いていて自営業者が減少しているものの、新規大卒者云々とはまた別の話。

Javaコード内でjfrファイルを読もうとすると互換性に気をつけないといけない話

まとめ:

  • jfrファイルをjavaコードから読み込むときは、jfrの監視対象javaプロセスのバージョンに気をつける必要がある。
  • JMCから開く場合には特に気にする必要はない
  • ローカルでざっくり見た結果だから保証はしないよ

Java Flight Recorder (JFR)とは

Java Flight Recorder (JFR)は実行中のJavaプロセスのプロファイルを取得するツールです。
現在のところは本番環境で使用するには商業利用ライセンスが必要ですが、本番ではない環境でのパフォーマンス問題調査に無くてはならないものです。

Java Flight Recorderについて

注意:
Java Flight Recorderを本番で使用するには、商用ライセンスが必要です。

将来的にJava Mission Control (JMC)がオープンソースへ移行することでJFRの商用ライセンス縛りも外れるのではないか...と言われていますが、Oracleからその辺あんまり詳しいアナウンスは出ていないのでよくわかりません。
JMC Open Sourced! – Marcus Hirt


で、このJFRで生成される*.jfrファイルは、通常はJMCから読み込むのですが、実はJavaコード中で読み込むことも可能です。しかし...というのが本題。

jfrファイルを読み込む方法

1. jfr.jarを使用する (java8まで)

${JDK_HOME}/jre/lib/jfr.jar にclasspathを通すと、jfrファイルを読み込むAPIを参照できます。
読み込みはざっくりこんな感じ。

非公開APIなので注意すること。
あと、JFRファイルはデフォルトで圧縮がかかっているようなので、読み込む前に解凍処理を忘れずに。

2. java jfr APIを使用する

Java9以降、jfrを読み込むためのAPIが公開APIとして実装されました。
jdk.jfr (Java SE 9 & JDK 9 )

読み込みはざっくりこんな感じ

バージョン互換問題

Java9以降であれば公開APIを使えば良いんじゃないかと思っていましたが、思わぬ罠がありました。

測定条件は以下の通り

  • java1.8 (1.8.172)、java9 (9.0.1) 、java10 (10.0.1)でjavaアプリ(apache jmeter)を立ち上げ、JFR取得対象とする
  • java1.8、java9、java10それぞれのjcmdを使用して、上のそれぞれのアプリについてのJFRを取得する
  • 上で貼ったコードをjava9環境で実行する(java8 のjfr.jarへclasspathを通した状態)

結果は以下の通り

取得対象Java version jcmdのJava version jfr.jarによる読み込み jfr APIによる読み込み
java1.8 java1.8 問題なし エラー1
java1.8 java9 問題なし エラー1
java1.8 java10 問題なし エラー1
java 9 java1.8 エラー2 問題なし
java 9 java9 エラー2 問題なし
java 9 java10 エラー2 問題なし
java 10 java1.8 エラー3 問題なし
java 10 java9 エラー3 問題なし
java 10 java10 エラー3 問題なし

つまり、jfr.jarはjava9以降のプロセスから取得したjfrを読めないし、一方でjfr APIはjava1.8プロセスから取得したjfrを読めないという話ですね。そして、取得対象のバージョンには依存するものの取得する側のjcmd(多分JMCも)のバージョンには依存しない。
ローカル環境でしか検証していないけれど多分あってるはず。

エラー1の内容は以下の通り。

java.io.IOException: File version 0.9. Only Java Flight Recorder files of version 1.x can be read by this JDK.
	at jdk.jfr/jdk.jfr.internal.consumer.ChunkHeader.<init>(ChunkHeader.java:56)
	at jdk.jfr/jdk.jfr.internal.consumer.ChunkHeader.<init>(ChunkHeader.java:38)
	at jdk.jfr/jdk.jfr.consumer.ChunkParser.<init>(ChunkParser.java:35)
	at jdk.jfr/jdk.jfr.consumer.RecordingFile.findNext(RecordingFile.java:181)
	at jdk.jfr/jdk.jfr.consumer.RecordingFile.<init>(RecordingFile.java:63)
	at jdk.jfr/jdk.jfr.consumer.RecordingFile.readAllEvents(RecordingFile.java:168)
	at jfr_example.Jdk9JfrReader.read(Jdk9JfrReader.java:31)
	at jfr_example.Main.main(Main.java:23)

エラー2

java.lang.RuntimeException: oracle.jrockit.jfr.parser.ParseException: Bad descriptor section, id=65536
	at oracle.jrockit.jfr.parser.Parser$1.hasNext(Parser.java:165)
	at jfr_example.Jdk8JfrReader.read(Jdk8JfrReader.java:31)
	at jfr_example.Main.main(Main.java:18)
Caused by: oracle.jrockit.jfr.parser.ParseException: Bad descriptor section, id=65536
	at oracle.jrockit.jfr.parser.ChunkParser.readDescriptors(ChunkParser.java:555)
	at oracle.jrockit.jfr.parser.ChunkParser.begin(ChunkParser.java:230)
	at oracle.jrockit.jfr.parser.ChunkParser.<init>(ChunkParser.java:95)
	at oracle.jrockit.jfr.parser.Parser.next(Parser.java:124)
	at oracle.jrockit.jfr.parser.Parser$1.hasNext(Parser.java:163)
	... 2 more

エラー3

java.lang.RuntimeException: java.nio.BufferUnderflowException
	at oracle.jrockit.jfr.parser.Parser$1.hasNext(Parser.java:165)
	at jfr_example.Jdk8JfrReader.read(Jdk8JfrReader.java:31)
	at jfr_example.Main.main(Main.java:18)
Caused by: java.nio.BufferUnderflowException
	at java.base/java.nio.Buffer.nextGetIndex(Buffer.java:634)
	at java.base/java.nio.DirectByteBuffer.getInt(DirectByteBuffer.java:686)
	at oracle.jrockit.jfr.parser.MappedFLRInput.getInt(MappedFLRInput.java:79)
	at oracle.jrockit.jfr.parser.ChunkParser.readDescriptors(ChunkParser.java:552)
	at oracle.jrockit.jfr.parser.ChunkParser.begin(ChunkParser.java:230)
	at oracle.jrockit.jfr.parser.ChunkParser.<init>(ChunkParser.java:95)
	at oracle.jrockit.jfr.parser.Parser.next(Parser.java:124)
	at oracle.jrockit.jfr.parser.Parser$1.hasNext(Parser.java:163)
	... 2 more

なので、JFRをjavaから読む何かを書くときにはこの辺を気をつけないと「一年前に取得したJFRファイルが読めねー!」って事になるので注意が必要です。
あと、jfrファイルをJavaコードではなくJMCで開く場合には、特に問題は発生しません。

おーい磯野ー、Flashpoint Campaignsしようぜ2

前回に引き続き、Flashpoint Campaigns の紹介。"Pied Piper"シナリオのAAR。

store.steampowered.com

このAARについて

前回書いたとおり、このゲームでは一度部隊が交戦状態に入ってしまうと、なかなか指示に従ってくれません。
なので、プレイ中にプレイヤーが能動的に介入できる余地が地味というか、予備兵力の投入タイミングや間接砲撃のターゲット指定ぐらいかと。じゃあ何が面白いのかというと、事前に敵の進撃ルートを予測して兵力と予備兵力を配置していく過程がメインな気がします。事前の予想がハズレた場合に計画を修正していくあたりも面白いんですが、最初の読みが多分一番重要で面白い所。

ということで、このAARでは事前の作戦立案をメインに書いていきます。

Pied Piper シナリオについて

プレイヤーはNATO側。

開戦一日目。ソ連第一親衛戦車軍の遠距離偵察部隊は東西ドイツ国境線を突破し、ウェーザー川にかかるハーメルンの幹線道路橋を目指して進撃中。一方、NATO側が現在ハーメルンに展開している戦力は乏しいが、一時間程度で増援部隊が到着する予定。NATO側はソ連のウェーザー川渡河を阻止するために必要な可能な限りあらゆる手段を取ること。

敵の進撃ルート予想

f:id:ka-ka_xyz:20180401201039p:plain

幹線道路沿いの進撃ルートAを中心として、その両脇B, Cからもハーメルン市街と橋に向けて進撃してくると予想されます。
青矢印1は見方の増援ルート(詳細は後述)。

見方兵力

f:id:ka-ka_xyz:20180401201224p:plain

初期配置戦力は歩兵(+歩兵戦闘車)三個小隊と戦車三個小隊。ここで気をつけなければならないのが、戦車がレオパルド1であること。ゲームのシナリオである1989年においては明らかに二線級戦力であり、ソ連側の主力であるT-80と正面から打ち合ってはまず勝てません。

f:id:ka-ka_xyz:20180401203936p:plain
また、意思決定サイクルについても、NATO側16分に対しソ連側23分と、NATO側が優位では有るものの戦力差を覆すような大きな差は無いです。

f:id:ka-ka_xyz:20180401202106p:plain

ゲーム内時間で一時間ほど経過すると、M-109自走砲二個小隊が到着します。さらに20分後にレオパルド2が配備された戦車中隊(三個小隊+司令部)が到着し、さらにその20分後には歩兵三個小隊が到着します。増援はウェーザー川西岸に出現するので、戦車や歩兵については東岸へ移動して戦闘に加入するためにさらに30分程度の時間が必要です。

敵兵力

シナリオ開始時の情報によると、戦車80両以上+歩兵戦闘車70両程度を中心とした戦車旅団。

AAR: 作戦プラン1

上で書いたとおり、そもそも数的に劣勢である上に初期配置されているレオパルド1ではどうあがいてもT-80には太刀打ち出来ません...が時間をかせぐ必要があるということで、予想進撃ルートにそれぞれ歩兵+戦車各一個小隊のセットで防衛ラインを構築します。

f:id:ka-ka_xyz:20180401201835p:plain

また、ハーメルン東方を流れるウェーザー川支流にかかる橋は片っ端から爆破して時間を稼ぎます。(30分程度で復旧されるけれど)
防衛ラインAが突破されたら、防衛ラインBとCを下げてハーメルン市街で防衛ラインを作る...のは多分無理。現有戦力だと予備は作れないので、増援がくるまでとにかく死守。一時間半後に増援が来たらハーメルン市街外縁に防衛ラインを引き、迎撃する予定。

ということでシナリオスタート
1時間40分経過時の各防衛ラインの様子

f:id:ka-ka_xyz:20180401204452p:plain

侵攻ルートBのソ連部隊には戦車が存在せず、APCやIFVのみでした。また、効果的に構築された地雷原も相まってNATO軍の一方的勝利となっている模様(小さい煙が上がってるのが、撃破されたユニット)。ただし、ほぼ弾薬が尽きているので再補給の必要あり。

f:id:ka-ka_xyz:20180401204631p:plain
侵攻ルートA(画面右側)およびC(画面左側)について。ソ連軍のT-80戦車が現れてからはかなり形勢不利で突破されそう。ルートCから部隊を回して対応することに。

一方で、マップ西側には増援部隊が到着し、ハーメルン市街へ展開すべく高速移動中。ルートAが突破されるまでにハーメルン市街外縁に防御ラインを敷くことが出来るかどうかは微妙なところ。

3時間経過時の各防衛ラインの様子

増援部隊のレオパルド2中隊がハーメルン市街外縁に防御ラインを敷くことに成功。

f:id:ka-ka_xyz:20180408170805p:plain

500m以下の距離で打ち合うので、T-80を相手にすると損害が出ますが、市街地の防御補正と築城効果があるので何とか優勢です。

3時間47分経過時

ソ連側の損害が一定数を上回ったため、ゲーム終了。
f:id:ka-ka_xyz:20180408172103p:plain

評価的には大勝利。
グラフのバーは各兵科の数で青はNATO側、赤がソ連側。また、左側のバーが開始時、右側のバーが終了時のもの。
ソ連側は戦車以外の戦力がほぼ全滅状態。

AAR: 作戦プラン2

上で書いたとおり、そもそも数的に劣勢である上に初期配置されているレオパルド1ではどうあがいてもT-80には太刀打ち出来ません...が時間をかせぐ必要があるということで、ハーメルン市街に防御ラインを引き、もともと防御側に有利な市街地地形に築城の防御強化を加えて数の差をカバーしようという案。

結果

f:id:ka-ka_xyz:20180408170137p:plain

どうあがいても増援部隊がウェーザー川を渡る前にソ連部隊が市街地を制圧してしまう。渡河を防ぐ事はできるものの、橋の確保は無理。

おーい磯野ー、Flashpoint Campaignsしようぜ

store.steampowered.com

どんなゲームなの?

Flashpoint Campaignsは、1980年代末に発生していたかもしれない第三次大戦での、ヨーロッパ(西ドイツ)での陸戦を扱った戦術級シミュレーションゲームです。
ざっくばらんに言うとあれです。『レッドストーム・ライジング』とか『第三次世界大戦―チーム・ヤンキー出動』とかあの辺り。

レッド・ストーム作戦発動(ライジング)〈下〉 (文春文庫)

レッド・ストーム作戦発動(ライジング)〈下〉 (文春文庫)

第三次世界大戦―チーム・ヤンキー出動 (二見文庫―ザ・ミステリ・コレクション)

第三次世界大戦―チーム・ヤンキー出動 (二見文庫―ザ・ミステリ・コレクション)

ゲーム画面
f:id:ka-ka_xyz:20180325192340p:plain
アウトバーンのジャンクションへ殺到するワルシャワ条約機構軍(WPO)(赤ユニット)と、押し止めようとするNATO軍イギリス部隊(黄色ユニット)。NATO軍は主正面では押されつつあるが、WPO部隊の後方へ回り込んだ部隊による包囲網を完成しつつ有るところ。
1ヘクスがだいたい500m

ちなみに現在の実際の地形はこんな感じ

なかなかになまなましい。

システム面での特徴について

意思決定サイクル

ターン開始時にユニットへ命令を出し、次のターンが始まるまでの間はセミリアルタイムで時間が進行していきます。その間は命令を追加したり変更したりすることは出来ません。

f:id:ka-ka_xyz:20180325193218p:plain
で、画面右に表示されてたこのパネル(C3: Command, Control and Communication)なんですが、このゲームの一番のキモになります。
黄色いバーは、「NATO側は命令を発行するまでの時間間隔が17分である、更に次に命令を発行出来るまでの予想間隔は16分である」ことを意味しています。
一方、赤色のバーは「WPO側は命令を発行するまでの時間間隔が48分である。更に次に命令を発行出来るまでの予想間隔は36分である」ことを意味しています。将棋で例えるならば、NATO側はWPO側が一手打つ間に三手打てるということです。あるいはミリヲタ的に表現をするなら「NATO側はWPO側と比べてOODAループを三倍速く回せる」ということを意味します。

この意思決定サイクルの速さの差を見るとNATO側が圧倒的優位に見えますが、ところがどっこい、このシナリオではWPO側の戦力はNATO側の二倍程度あるので、NATO側が下手を打つとあっという間に数の力で押し切られます。
また、この値は電子攻撃(EW Hindrance)による通信妨害や司令部ユニットが攻撃を受けたり壊滅したりしているかどうか、あるいは各部隊が準備万端か(unit readiness)どうかなどにより大きく変動します。例えば、敵司令部ユニットを間接砲撃で混乱させたり、敵の後方へ浸透して司令部ユニットを戦闘に巻き込むことで相手の意思決定を麻痺させるといった戦術を取ることも出来ます。

組織階層

f:id:ka-ka_xyz:20180325204107p:plain
OB(Order of Battle)タブには組織階層が表示されます。このマップでは最高司令部ユニットが"TF Hell's Messanger"、その下に"4RTR (4th Royal Tank Regiment)"司令部ユニットがあり、更にその配下にA/4RTR, B/4RTR, CRT/4RTR 等の司令部ユニットが存在し、更にその配下には戦闘を行うための部隊が存在することが示されています。

司令部ユニットには通信範囲があり、配下の司令部や部隊をカバーしている必要があります。また、部隊が補給を受ける際には司令部ユニットの近くで行う必要があるため、効率的に部隊を使用するために組織階層を色々と変更する必要があります。
例えば歩兵大隊に戦車一個小隊を分散配置するとか、あるいは分散されている戦車小隊をまとめて中隊として使用するなどなど。そのマップでどのように戦闘を展開していくのか、計画を立てた上で組織階層を変更してください。

命令は聞いた...が、今すぐ実行するとは言っていない......

上の意思決定サイクルについての話で、「命令を下す」話をしましたが、このゲームでは命令を出したとしてすぐに実行されるとは限りません。
例えばある部隊が目の前の敵と打ち合っているときに移動命令が出たとしても、「そんなのより今は眼の前の敵と戦うほうが先」な感じでなかなか言うことを聞いてくれません。

基本的に、部隊を思うように動かせるのは交戦が始まる前までで、実際に交戦が始まってからはケリがつくまでの間は「部隊を動かせない」と思っておいたほうが良さげです。そのため、防御側は交戦が始まる前に如何に有利な地形(遮蔽物が多い森林や市街地ヘクスで、かつ標高が高くて敵の予想進路に対する見晴らしがよく、更に後退する際に射線が通らないような場所)を確保することが死活問題となります。そのような地形を確保できれば、小部隊で敵の大部隊を長時間拘束することが可能となります。逆に、有利な地形を確保できないままでなし崩し的に交戦が始まった場合...兵力が大きなWPO側にとっては一気に押せるチャンスとなりますが、兵力が少ないNATO側にとってはほぼ負けが確定する感じです。

あと、戦闘に巻き込まれていない予備兵力は超大事。

追記: 上で書いたのは部隊に"Hold"(確保)を指示した場合の挙動。"Screen"(前衛)を指示すると射撃後に移動してくれますが、これはこれで防衛ラインを作って一定時間保持するといった用途には使えません。

補給ルール

ユニットはそれぞれ燃料・弾薬をもっていて、20分~30分程度交戦すると弾薬が空になります。
司令部ユニットの近くにいると再補給が出来るのですが、問題は再補給中には攻撃に対してかなり脆弱となること。敵の射線が通らない場所まで後退して補給を受けるべきなのですが、上で書いたように後退命令を出しても即時に実行してくれるとは限りません。ということで、煙幕等を張ることで射線を一時的に遮ってそのすきに交代するか、リスクを承知でその場で再補給を行うかという判断を行う必要があって悩ましい。

戦車の強さ東西比較について

このゲームではあくまで80年台後半の欧州を扱っており、湾岸戦争のような西側戦車の圧倒的な性能の優位は再現されていません。長距離で射撃を行っているうちは西側第3世代戦車の方が優位ですが、ある程度距離が詰まると東側戦車との性能差は縮まっていきます。そして殆どのマップではWPO軍の戦車はNATO軍の数倍程度の数の優位があります。NATO側はこれを踏まえて「如何に効率的にキルゾーンを形成するか」という方針で戦車を使っていくべきです。


AAR書こうと思ったけどここで力尽きた

次回に続く

JavaでGPU演算(Aparapi)してみた

今更感は有るが、Aparapi触ってみた。特に何か使う予定は無いけど、寒さに負けて生賴範義展に行けなかったのでなんとなく。



(1/31追記) 社内LTで再利用.

4/17追記

と指摘を受けたのでgithubのリンク先を https://github.com/Syncleus/aparapi に修正。



Aparapiとは

github.com

aparapi.com

JavaVMからOpenCL API経由でGPU演算を行うライブラリ。GPU側へ持っていけるのはJava プリミティブ型の配列のみだけれど、簡単に書ける。

とりあえず、任意の画像をグレースケール化する処理をループで書いた場合とAparapiで書いた場合を比較。

JVM内でループを回した場合

  private void convertToGrayScaleInJvm(final int[] pixels) {
    for (int i = 0; i < pixels.length; i++) {
      int pixel = pixels[i];
      int alpha = pixel >> 24 & 0xFF;
      int red   = pixel >> 16 & 0xFF;
      int green = pixel >>  8 & 0xFF;
      int blue  = pixel & 0xFF;
      int y = (int)(0.298912 * red + 0.586611 * green + 0.114478 * blue);
      pixels[i] = alpha << 24  | y << 16 | y << 8 | y;
    }
  }

Aparapiを使ってGPU演算した場合

  private void convertToGrayScaleInGpu(final int[] pixels) {
    
    Kernel kernel = new Kernel() {
      @Override
      public void run() {
        //GPU world
        int i = getGlobalId();
        int pixel = pixels[i];
        int alpha = pixel >> 24 & 0xFF;
        int red   = pixel >> 16 & 0xFF;
        int green = pixel >>  8 & 0xFF;
        int blue  = pixel & 0xFF;
        int y = (int)(0.298912 * red + 0.586611 * green + 0.114478 * blue);
        pixels[i] = alpha << 24  | y << 16 | y << 8 | y;
      }};
      int size = pixels.length;
      kernel.execute(Range.create(size));
  }

グレースケール化の方法については

https://ofo.jp/osakana/cgtips/grayscale.phtmlofo.jp

を参考とした。
ソース全体はこちら

https://gist.github.com/ka-ka-xyz/232bc0368ebf3c44bb46a7e1cb03810b

Aparapi注意点

JNI経由でOpenCLへアクセスするためにはネイティブライブラリが必要らしい。

aparapi/QuickReference.pdf at master · aparapi/aparapi · GitHub

無くても動くけれど、GPUは使わないよ的な事が書かれてた。 *1
あと、このPDFではAparapi関連のシステムプロパティのキー名が`com.amd.aparapi` で始まっているけれど、現在のv1.4.1では`com.aparapi` になっているので注意。

今回はここからdllを取得したけれど、これで良いんだろうか...
github.com

dllなりsoなりが存在するフォルダをJVMシステムプロパティ `java.library.path` で指定すれば読み込まれる。

実際に動かしてみる

www.pexels.com

から取得した画像をあれこれリサイズしてグレースケールへ変換してみた。

カラー版
f:id:ka-ka_xyz:20150913194329j:plain

グレースケール化した画像
f:id:ka-ka_xyz:20180128235338j:plain

ほんとにGPU呼んでるの?

NVIDIS GeForce GTX 1050の負荷をWin標準のパフォーマンスモニタで見てみた画像。
f:id:ka-ka_xyz:20180128235601p:plain

実行時にGPUさんが忙しそうにしてたので、呼ばれてるんだろう。多分。

パフォーマンスはどうよ?

f:id:ka-ka_xyz:20180128235656p:plain

X軸が画像のピクセル数、Y軸が処理にかかった時間(ms)。この時間はあくまでグレースケール化の処理時間で、ファイルの読み書きについては勘定していない。青がGPU演算のパフォーマンス。オレンジがJVM内での演算パフォーマンス。画像ピクセル数をあれこれ振り、CPU・JVM各5回の処理時間を取得して平均値をグラフ化。

グラフを見ると、GPU処理のためのオーバーヘッドがかなり高く、画像のピクセル数が少ないとJVMの中でループ回してたほうが圧倒的に速い。
13500x8598pxぐらいのサイズだとまだJVM内の処理が僅かに速く、18000x11464pxではGPU側が速い。

GPU演算だと並列処理のはずなのに計算量がO(N)に見えるのが気になる。仮説として

  • 処理自体は瞬時に終わるもののGPU側のメモリとJavaヒープとの間でのデータ転送に大半の時間が消費されている

と憶測してる(未検証だけど、上で挙げたパフォーマンスモニタはそれっぽく見える)。

*1: 一方で 「Aparapi:任意の計算タスクを実行するための新たな “Pure JavaAPIhttps://www.infoq.com/jp/news/2010/10/aparapi-java-and-gpus みたいな記事もあり、正直良くわからない。けど理屈としてはJVMの外のデバイスへアクセスするにはネイティブライブラリは必要だと思う

Redisのsortedsetで得点をランキング出来るのはわかった。で、自己最高得点でランキングする一番いい方法を頼む

ソート済みセット型 — redis 2.0.3 documentation

Redisのsorted set型を使うと、キーとなる文字列(member)と数値データ(score)のペアをソート済みの状態で保存できて、「scoreの上位100位までのランキングを行いたい」ようなシチュエーションで大変重宝します。

で、単純にscoreのランキング情報を得る場合は良いのですが、例えば「memberが過去に登録したscoreのうち、最も高いscoreのみを用いてランキングを行いたい」といった場合にどういう戦略を取るのが有効なのかなと言う点についてゆるく検証していと思います。

詳細

検証に使ったJavaソースはこちら。
Redis perftest to get max score. · GitHub

redisクライアントとしてjedisを使用。
github.com

使用するデータ

memberとしてUUID文字列を使用し、UUIDから生成したDouble型のhash値をscoreとします。

  private Map<String, Double> initData(int size) {
    Map<String, Double> data = new HashMap<String, Double>();
    System.out.println("------------ Generated value - score pair. ------------");
    for (int i = 0; i < size; i ++) {
      UUID uuid = UUID.randomUUID();
      System.out.println(uuid + ", " + Math.abs(Double.valueOf(uuid.getMostSignificantBits())));
      data.put(uuid.toString(), Math.abs(Double.valueOf(uuid.getMostSignificantBits())));
    }
    System.out.println("------------------------");
    return data;
  }

件数は10万件ぐらい。

戦略1: 過去のscoreを取得して最新scoreより大きいかどうかを比較し、大きければsorted setへ保存

Javaで書くとこんな感じ。
scoreの値を1, 2, 3...10倍することで、一つのmemberに複数のscoreを紐付けています(超適当だけど)。

  private void zscoreThenZadd(String key, int loop) {
    del(key);
    long start = System.currentTimeMillis();
    for (String value : data.keySet()) {
      for (int i = 1; i <= loop; i++) {
        try (Jedis jedis = this.pool.getResource()){
          Double currentScore = data.get(value) * i;
          Double oldScore = jedis.zscore(key, value);
          if (oldScore == null || currentScore.compareTo(oldScore) > 0) {
            jedis.zadd(key, currentScore, value);
          }
        }
      }
    }
    long total = System.currentTimeMillis() - start;
    System.out.println(key + ":" + total);
  }

毎回Redisからzscoreで古いscoreを取ってきて新しいscoreと比較。新しいscoreのほうが大きい場合は改めてzaddでmemberとscoreのペアをsorted setへ入れる。
補足すると、RedisではTransactionも使えますが、同じTransaction内でzscoreの値を取れるのはTransactionの完了時になります。SQLでいうselect for updateのように、zscoreの結果を取得してその結果をもとにzaddするかどうかを分岐するという処理は出来ません。あやうげ。

戦略2: Luaスクリプトを使い、Redis側で過去scoreの比較と保存を行う

javaで書くとこんな感じ。

  private final String scriptTemplate = 
    "local new_score = tonumber(KEYS[1])\n" +
    "local data = KEYS[2]\n" +
    "local bulk_old_score = redis.call('zscore', '%s', data)\n" +
    "local old_score = -1\n" +
    "if bulk_old_score then old_score = tonumber(bulk_old_score) end\n" +
    "if old_score < new_score then\n" +
    "  return redis.call('zadd', '%s', new_score, data)\n" +
    "else\n" +
    "  return nil\n" +
    "end";
  
  private void zscoreThenZaddInScript(String key, int loop) {
    del(key);
    String sha = null;
    try (Jedis jedis = this.pool.getResource()){
      String script = String.format(scriptTemplate, key, key);
      sha = jedis.scriptLoad(script);  
    }

    long start = System.currentTimeMillis();
    for (String value : data.keySet()) {
      for (int i = 1; i <= loop; i++) {
        try (Jedis jedis = this.pool.getResource()){
          Double currentScore = data.get(value) * i;
          String[] args = {currentScore.toString(), value};
          jedis.evalsha(sha, args.length, args);
        }
      }
    }
    long total = System.currentTimeMillis() - start;
    System.out.println(key + ":" + total);
  }

スクリプト内でzscoreとzaddを行う処理。スクリプト内の処理はatomicなので、戦略1にあったトランザクション問題も解決。ただし、スクリプト処理を行う分だけRedisの負荷が増えます。

戦略3: scoreとmemberのペアをsetとして保存しておき、アプリ側で大小比較後に最も大きいscoreをsorted setとして保存する
  private static final String KV_SPLITTER = "::";
  private void saddAllPreProcessedData(String preProcessedStoreKey, int loop) {
    del(preProcessedStoreKey);
    long start = System.currentTimeMillis();
    for (String value : data.keySet()) {
      for (int i = 1; i <= loop; i++) {
        try (Jedis jedis = this.pool.getResource()){
          String score = String.valueOf(data.get(value) * i);
          jedis.sadd(preProcessedStoreKey, value + KV_SPLITTER + score);
        }
      }
    }
    long total = System.currentTimeMillis() - start;
    System.out.println(preProcessedStoreKey + ":" + total);
  }

  private void smembersAllPreProcessedDataThenAggregateAndZadd(String preProcessedStoreKey, String key) {
    del(key);
    try (Jedis jedis = this.pool.getResource()){
      long start = System.currentTimeMillis();
      Set<String> preProcessed = jedis.smembers(preProcessedStoreKey);
      jedis.del(preProcessedStoreKey);
      Map<String, Double> map = new HashMap<String, Double>();
      for (String kv : preProcessed) {
        String[] kvAry = kv.split(KV_SPLITTER);
        String value = kvAry[0];
        Double score = Double.valueOf(kvAry[1]);
        if (map.putIfAbsent(value, score) != null) {
          map.computeIfPresent(value, (val, oldScore) -> {
            return oldScore < score? score: oldScore;
          });
        }
      }
      jedis.zadd(key,map);
      long total = System.currentTimeMillis() - start;
      System.out.println(key + ":" + total);
    }    
  }

最初にmember - valueペアの文字列表現を全てRedisへsetとして保存しておいて、改めてRedisから呼び出してscoreの大小比較とzaddでの保存を行います。実環境だとまあ、別々のスレッドでrpush+blpopで非同期に処理してくようなパターン。Redis側のメモリも食うし転送量も多いしであまりパフォーマンスは良くないんだろうなと予想してました。

(12/04追加) 戦略4: スコアを別々のキーに保存しておいて、zuniscoreで集計
  private void zunionstore(String key, int loop) {
    del(key + "::*");

    long start = System.currentTimeMillis();
    for (String value : data.keySet()) {
      for (int i = 1; i <= loop; i++) {
        try (Jedis jedis = this.pool.getResource()){
          Double currentScore = data.get(value) * i;
          jedis.zadd(key + "::" + value, currentScore, value);
        }
      }
    }
    
    try (Jedis jedis = this.pool.getResource()) {
      Set<String> keys = jedis.keys(key  + "::*");
      double[] weights = new double[keys.size()];
      Arrays.fill(weights, 1d);
      ZParams params = new ZParams();
      params.weightsByDouble(weights);
      params.aggregate(Aggregate.MAX);
      jedis.zunionstore(key, params, keys.toArray(new String[keys.size()]));
      del(key + "::*");
    }
    
    long total = System.currentTimeMillis() - start;
    System.out.println(key + ":" + total);
  }

最初にmember - valueペアの文字列表現をキー名としてzscoreとして保存したあとで、改めてzuniscoreで最大値を集計するパターン。戦略3と同じく、これも別スレッドで非同期に実行する想定。

例えば定期テストの点数など、全てのmemberが同時に何かの評価を受けてscoreを保存するという要な場合であれば、「第一回定期テスト」「第二回定期テスト」のようなキーを作ってそこにmember-valueのペアを入れてからzuniscoreで集計すれば良いんですが.........
あるmemberがスコアを100回残す一方で、もう一方のmemberは10回しかスコアを残さないような想定だと、member - valueペアをキー名として保存しとくしかないのかなと。キー名が爆発的に増加するけれど。

(12/05追加) 戦略5: 更新時に毎回zunionstoreで集計
  private void zunionstore2(String key, int loop) {

    long start = System.currentTimeMillis();
    for (String value : data.keySet()) {
      for (int i = 1; i <= loop; i++) {
        try (Jedis jedis = this.pool.getResource()){
          Double currentScore = data.get(value) * i;
          String tempStoreKey = key + "::" + UUID.randomUUID().toString();
          jedis.zadd(tempStoreKey, currentScore, value);
          double[] weights = new double[2];
          Arrays.fill(weights, 1d);
          ZParams params = new ZParams();
          params.weightsByDouble(weights);
          params.aggregate(Aggregate.MAX);
          jedis.zunionstore(key, params, tempStoreKey, key);
          jedis.del(tempStoreKey);
        }
      }
    }
    long total = System.currentTimeMillis() - start;
    System.out.println(key + ":" + total);
  }

戦略4だとキー数が爆発する件について、お昼のおしごとで指摘された改善案。
値を追加するたびに一旦テンポラリキーへ値を入れ、zunionstoreで集計する。


結果

データ1万件

f:id:ka-ka_xyz:20171205090437p:plain

戦略 処理時間 (ms)
戦略1 7648
戦略2 4985
戦略3合計 3844
戦略3(未処理データの保存) 3504
戦略3(sortedsetデータの保存) 340
戦略4 4042
戦略5 362037
戦略5(Transactionあり) 362933
データ10万件

f:id:ka-ka_xyz:20171205090410p:plain

戦略 処理時間 (ms)
戦略1 77007
戦略2 51638
戦略3合計 34856
戦略3(未処理データの保存) 36049
戦略3(sortedsetデータの保存) 4069
戦略4 41087
戦略5 NA
戦略5(Transactionあり) NA

戦略1と比べると戦略2の処理時間は7割程度。戦略3の処理時間は半分程度。

一応、Redis側に10万件のデータが保存され、またいくつかのmemberについて抜取調査をして最も高いscoreが保存されていることも確認済み。

これだけ見れば戦略3が圧勝に見えるけれど、今回の結果は同じマシン内でJavaアプリとRedisが同居していてネットワーク負荷がボトルネックとならない環境であることを考慮する必要があります。JavaアプリとRedisが別サーバー上で動いているような場合であれば、ネットワーク速度がボトルネックとなり、転送量が多い戦略3が不利になる可能性もあるでしょう。
また、戦略2は他の戦略と比べるとRedisサーバー側でのスクリプト処理が重いはずです。Redisが使用できるCPU能力が低い場合には、スクリプト処理がボトルネックとなる可能性もあります。

戦略4は戦略3とほぼ同じぐらいのパフォーマンス。データ転送量は抑えられるものの、想定に寄ってはキーがかなり増えるのが欠点と言えば欠点。ただし、定期テストや大会の結果を集計するような処理であればキーの増加は無い。

戦略5は他の戦略と比べると桁一つ遅い。ただし、トランザクションが使えるのでデータ量が少数の場合だと使い勝手は良さそう。

いやほんとは負荷状況とかちゃんとモニタリングする必要はあるんだろうけれど休み中にそんなことしたくないよね。かといって、会社にいるとこの手の測定ってやりにくいんだよなあ。

それにしても、RDBの常識的には10万件を全件取得するようなTable Scanが走りまくる処理なんて考えられないよなーと思っていたものの、かなり良い数字が出てたのでびっくりしました。RDBでの「当たり前」を一回投げ捨てないと、Redis(だけではなくNoSQL)を上手く使うのは難しいなあ。

『この世界の片隅に』を観たときの頭の抽き出し整理

konosekai.jp

とりあえず、ここでは映画のレビューとか感想は書きません。
なんというかこー

と言う感じで、感想を書いて一区切り置いてしまうのがあまりに勿体ない。

ということで、感想の代わりに映画を見てたとき「そういえばあの本にこういう事を書いてたよな」と思っていたことについて、さらっと再読したメモ。目的としては「頭の抽き出し整理」というか、あの映画を受け止めたときの自分の手持ちカードを晒す的な。



「月給百円」のサラリーマン―戦前日本の「平和」な生活 (講談社現代新書)

「月給百円」のサラリーマン―戦前日本の「平和」な生活 (講談社現代新書)

冒頭の、昭和8年の広島の場面で思い出してたのがこの本。
昭和一桁時代の物価は大体今の二千分の一ぐらい(当時の1円 = 現在の2,000円)。都市部の中流層が「そこそこ暮らしていける」ぐらいの目安になる月収が大体100円程度。
ただし、中流層と言っても人口比でいうと10%ぐらいの少数派で、貧困層は子供を身売りすることも当たり前という状態(この辺は映画版で大幅カットされたエピソードの背景)。
日中戦争が始まってからはインフレが進み(そして、公定価格は抑えられたものの統計に現れない闇価格は高騰し)、戦後の物価上昇を経て最終的に「キャラメル一箱百円、靴下三足千円」な現在に至る。

それにしても、すずさんってあの時まで闇市場のお世話に成ったことが無かったんだろうか?そうだとしたら、それが一般的な話なのか特殊事例なのか、よくわからない。東京を舞台にした作品だと、もうちょっとカジュアルに闇に頼ってる気がする。


戦前戦中の主婦雑誌に掲載されていたレシピを元に戦時の食生活を描く本。
日中戦争が始まった頃は、意識高い系奥様向けに「軍艦サラダ」「飛行機メンチボール」のような手の込んだ料理が紹介されていたのに、戦争が進むにつれて「如何に米を水増しするか」という方向に成っていき、さらには配給制度が始まり、最後には配給では必須カロリーを満たせなくなり...という。
そういえば、この本だと隣組をベースにして複数の家から材料を持ち寄って共同炊事をやっていたということだけど、『この世界の片隅に』だと漫画版も映画版も共同炊事の描写は無かった気がする。
この辺の事情は都市部と地方で大分違うのかもしれない。

それにしても、男手二人が海軍関係に勤務していて(流石にお義父さんは年齢的に大丈夫だろうけど、周作さんもぎりぎりまで招集されず)、ふたりとも家から通勤可能で(週末に労働力としてあてに出来る)、さらに畑もあるという北條家って当時としては大分恵まれていた方なんじゃなかろうか。

あと、1939年の段階で米の国内消費量のうち20%が植民地からの移入米というデータも出てて、八月十五日慟哭シーンの背景になってる。


決戦下のユートピア (文春文庫)

決戦下のユートピア (文春文庫)

そいやこの本で、モンペ着用については余りのダサさに「都会の娘 vs. 田舎のおばさん」的な対立があるという話だったけど、地方都市だとこの辺の対立はあんまり無かったんだろうかと気になった。すずさんがその辺に頓着しないのは分かるけど、元モガの徑子さんはどういう思いでモンペを着てたんだろうか。


造船士官の回想〈上〉 (新戦史シリーズ)

造船士官の回想〈上〉 (新戦史シリーズ)

造船士官の回想〈下〉 (新戦史シリーズ)

造船士官の回想〈下〉 (新戦史シリーズ)

著者は戦争中に呉工廠へ赴任。大和や武蔵の修理に関わってたりする。あと、第十一海軍航空廠で航空関連の仕事もしているので、北條のお義父さんと関わりが有ってもおかしくないのな。度重なる呉空襲についての記述も有ったりと、『この世界の片隅に』と関連しそうな部分が結構ある。
あと、海軍病院で兵員が多い病棟ではレコードでアメリカの曲ばかりがかかっていたという記述も有ったりと、映画中のワンシーンを思い起こさせる。


源田の剣 改訂増補版 米軍が見た「紫電改」戦闘機隊全記録

源田の剣 改訂増補版 米軍が見た「紫電改」戦闘機隊全記録

艦載機による呉空襲について、米軍側から見た記録を中心に再現した記述あり。"色彩豊かに弾幕が張られていた"というあのシーンについての米側証言も出てる。
あと、呉上空でドッグファイトしていたシーンが有ったけれど、あのときの日本機は第343海軍航空隊 戦闘301飛行隊所属で他に該当する部隊は無いとの事*1。隊長は最近一部でものっそ有名になってる、あの菅野直大尉。
第十一海軍航空廠で紫電改を作ってたし、あの光景を見たらそりゃお義父さんもたぎるよね。



とまあ色々書いたけど、特にこの手の知識とか無くても楽しめる映画です。画面一杯に情報がつぎ込まれているものの、別にそれを全て受け止める必要は無いはずです。むしろそういう余計なモノは放り投げて、笑い、泣き、あの「日常」を過ごした人たちに思いを馳せれば良いと思います。

*1:旧版による。増補改訂版だと変わってるかもしれない