ISUCON12予選敗戦記

すでにずいぶんと時間が経ってしまったのですが、ISUCON12の予選に参加していました。結果は思わしくなく、予選敗退に終わってしまいました。
もう記憶がおぼろげになってしまっている部分もあるのですが、覚えている部分だけでも振り返って反省していきたいと思います。

事前準備

今回はメンバーは2人でした。

事前練習用にはさくらインターネットさんのご厚意でさくらインターネットのクーポンが参加者に配布されて練習環境を整備することができたので、ありがたく使わせていただきました。
練習課題としては「達人が教えるWebパフォーマンスチューニング 〜ISUCONから学ぶ高速化の実践」の題材であるprivate-isuを用いました。
ただし、Rust実装は公式ではなかったので他の人が実装したものを使いました。

この練習会で、ansibleで最低限のbashrcや各種ツールをリモート環境にセットアップするものや、slackにalpでのログの結果を集計してアップロードするスクリプトなどを整備しました。

連絡はslackを用いることにしました。

予選本番

時刻は大体の時間です。

10:00 予選開始。ルール確認やコードをgithubに上げる作業などを行う。

10:33 Rustの初期実装でベンチを回す。Score: 2504

11:10 Docker上で動いていたアプリケーションを直接動くように変更。 Score: 2775

昔のDockerだとローカルホスト間のネットワーク通信のパフォーマンスが悪かったはずなのだが、解説でもそこは重要ではないとのことだったので、多分もう関係ない話だと思われる。
ただし、自分たちはRustのコンパイル済みのバイナリを手元からアップロードするデプロイ形式をとっていて、そのままのDockerスクリプトだとまずコンパイルが走ってしまうという仕組みになっていて非常にデプロイが遅くなってしまうため、外すという判断をした。
他のチームだとサーバーでコンパイルしてしまうとメモリ不足で困ったとかいう話も聞いたので、この判断は正解だったと思う。

11:31 DBアクセス時のロック周りが怪しいということで適当にはずしてみる変更をする。

何回か走らせて確かにパフォーマンスは上がるが、failedになるケースがあるのでむやみに外せないということになった。

ここで、ロックをファイルを用いる形式からRedisを使う形式に変更するという判断をして、自分が実装することになった。
しかし、Redisの習熟度が十分でなく、実装にかなり手こずってしまう。

11:42 もう1人のメンバーがMySQLのid_generatorを使っていた箇所をuuidを用いてIDを生成する変更する。 Score: 4639

このへんでロックを適当に外したりとか自明にいらないクエリを削除したりなどを相方が試すがたいしてスコアは改善せず。

14:18 ロックを改善する実装がようやく出来上がる。 Score: 4376でたいして改善せず

このへんから自分はvisit_historymin(created_at)なものしか引っ張ってきていないことを利用した最適化の実装にとりかかる。
相方はsqliteを使っている箇所をMySQLにしようとする。

15:43 MySQLを別サーバーに分離 Score: 4315

スコアこそ改善していないが、CPU使用率などのメトリックは改善していたので、将来的には有利なるはずと判断。

しかし、これ以降、やりたい改善の実装がうまく動かず、小手先の細かい改善をいくつか入れる程度で予選終了。なんの成果も得られませんでした。

延長戦

まず自分が担当していたvisit_historyの最適化ですが、DBのトランザクションを貼った時commitをし忘れるとかいうイージーミスでした。
書いていたときの気分としてはdropハンドラでやってくれるだろうと思っていたのですが、実際に呼ばれるのはrollbackです。冷静に考えればそのとおりですよね。

sqliteの移植作業の方はほぼほぼ完成していたものの、player_scoreテーブルが巨大でインスタンス起動時にすべてインサートできないという問題がありました。
しかし、player_scorerow_numが最大のものしか利用されないという性質があるので、それを利用することでインサートする量を圧倒的に減らせます。
これには本番中には気がついていたのですが、実装が間に合いませんでした。

これらを直してplayer_scoreのCSV入稿による一括insertをbulk insertにしたり、transactionを取ることによって不要な自前ロックを外す、MySQL化できたのでN+1だった箇所を撲滅する、Redisでキャッシュするなどを実装することで最終的には5万点以上の点数をとることができました。

bulk insertをする場合、たまにMySQL側でデッドロックを起こすことに気が付きました。どうやらおなじテーブルへのbulk insertが複数スレッドで実行されると、トランザクションを貼っている状態でもindex更新のためのロックを取り合ってdead lockを起こすようです。
正直なんでMySQLがこれを正しくハンドルできないかはよくわからなかったのですが、Mutexを使いbulk insertできるのを1スレッドに限定することでなんとか回避しました。

反省

まず、素振りが足りていなかったのが大きかったかなと思います。
ライブラリを使い慣れていればtransactionがdropでrollbackされるなどでつまずくことはなかったし、
Redisでファイルロックを置き換える作業ももっとスムーズにできたはずです。

また、チームメイトを悪い意味で信頼しすぎていて、作業を任せっきりにしてしまったのもよくなかったです。
コンテスト中はどうしても焦りが出てしまったり、そもそも知識が足りていない分野だと気づきが共有できていなかったりします。
ロックについて相方とじっくり挙動を調べたりすれば、Redis置き換えなどという無駄なステップを踏まずにすんだかもしれないし、
SQLite移行ももう少しすんなりいったかもしれません。
ペアプロや一緒に考える時間はもう少し積極的にとって良いと思いました。

RustのWebアプリケーションでのプロファイリング手法についてもちゃんと検討したいなと思いました。
MySQLやnginxのログ解析はpt-query-digestmysqldumpslowalpなどで確立されていますが、各関数でどれくらいの処理がかかっているかのデータを知りたい場面はそこそこにあります。
他の予選通過チームは自前のmacroを用意してOpenTelemetryでプロファイリングをとっているようだった。

著名なライブラリであるtracingでもSQLの文字列とかはキャプチャできないが、最低限関数呼び出しのトレースは取れるはずなので次回は活用したい。

他チームで参考になりそうな戦略としてはSQL文を一旦すべてスプレッドシートに洗い出すという戦略はよさそうと思いました。

狙いとしてはアクセスパターンを一覧にすることで、不要なDB操作を洗い出したり、今回の場合はSQLite移行の難易度を見積もったりとslow query分析だけでは見えてこないものを発見しやすくするというもので、これはシンプルながら強力そうな戦略に感じました。
やはり冷静になってアプリを分析する時間は必要。

来年はちゃんと3人メンバーを集めて予選通過を狙いたいです。

2021年の振り返り

年末恒例、今年の自分の振り返りをやっていく。
普段は避けているやや政治的な意見にも踏み込むので苦手な方はブラウザバックを推奨。
またお約束ではあるが、個人の見解であり、所属する団体を代表するものではない。

今年の成果

自作OSに関する成果については以前の記事にまとめた

ブログ記事などのアウトプット

今年はこのブログにこの記事を除いて4本、Zennのほうに3本の記事を投稿した。
これは去年よりも少ないことになるのだが、代わりに技術書展において去年執筆した自作OSドキュメントを改訂して同人誌として販売した

また、技術評論社刊「ソフトウェアデザイン 2021年9月号」にて、Rustのメモリ管理についての記事を寄稿した。
こちらの記事は一部改訂して前回のブログ記事とした。

その他、Kernel/VM探検隊などのオンラインLT会での発表もいくつか行った。

と、総合すれば去年と同じくらいアウトプットを達成したのではなかろうか。
ありがたいことにTwitterでのフォロワー数も900人代まで増えているので、自分の発信している情報が有益だと思ってくださっている方が増えてきている(と信じたい)。

また、これは他の場所では何回か言っているが、自作OS本を商業誌として出すように現在ブラッシュアップ中である。やや作業停滞気味なので年明けからは本気出す。

Rust雑談会(仮)

Twitter Spacesを主に利用してRust雑談会という企画をdorayakiさんと一緒にやり始めた。
Rustに関する話題をゆるく雑談するというポッドキャストもどきのような企画で、大体視聴者は10人前後いる程度には反響がある。
内容についてはまだまだ手探り状態で、大きなテーマを用意してそれにそって話すこともあれば、小さい最近の話題を細かく話すこともある。
アーカイブもYouTubeに上げているので興味のある方はどうぞ

正直、どういうスタイルの方が需要があるのかよくわかっていないので、フィードバックをもらえると助かります。
あと、アーカイブを聞き返すと自分のトークスキルがまだまだだなあと痛感させられてつらい

来年以降の方針

アウトプットの機会は保てたと思うが、まだまだ質・量を増やしたいと思っている。
また、アウトプットの方向性もちょっと変えていきたいと思っている。
今までは内容重視でどう発信するのかについてはあまり真剣にやってこなかった。
が、これからはもうちょっと注目されやすい方法にも手を出してみようと思っている。
具体的には、技術ブログをこちらのブログで主に出していたが、これからはZennのほうに移していこうと思っている。
ZennはGithub連携により手元に記事のソースを残しつつホストできるので運営の一存で記事が抹消されたりといったリスクも少なく、いざというとき他のプラットフォームに移行しやすい。
自前のブログでホストし続けるよりかは、Zennというプラットフォームにのっかるほうがアクセスされやすくなるので、こちらに移行していく。

Rust雑談会のようなTwitter Spaces等を利用したライブコンテンツも積極的に取り組みたい。
Twitterで単発で情報を発信するよりかは、誰かとしゃべりながらやったほうがいろいろと知見を深めやすいし、アクセスする側も手軽でいいといいと思っている。

こういう情報発信にもっと力を入れたいというのはいくつか理由がある。1つは単に自分が楽しいから。
もう1つは自分の能力をきちんと示すためである。残念ながら僕はコンテストの受賞歴のようなかっこいい経歴はない。
とりあえず今の会社には評価してもらって働けているが、他の会社・組織ではどうだろうか。社内での成果はアピールしづらく、さらに自分は自己アピールが苦手である。
こう考えると、目に見える形で何かしらの成果を残しておかないと、評価を得るのは難しい。もちろん、きちんと能力を身に着けるのも大事ではあるが、世の中誰もが純粋に能力を見抜いて評価してくれるほど甘くない。
若手という肩書もそろそろ厳しくなってくる中、アウトプットの質・量だけでなく方法も変えて、評価を得ることに欲を出し始めても良いころでは、という考えがある。
もう1つは情報科学全体への貢献という観点である。自分はプライベートでも仕事でもOSSなどの広く開かれたリソースに助けられながら情報科学を身に着けてきた。
そういう開かれたリソースへの恩返しとして、自分も開かれたリソースというものを可能な限り出して情報科学への敷居を下げていきたいという思いがあったりする。

Github Sponsorsもやっているので、もし活動に賛同していただける方がいらしたら支援をお願いします。

コンテスト

ISUCON、ICFPC、SECCONに参加した。ISUCONは予選敗退、ICFPCは去年よりは順位のいい11位、SECCONは久々の参加でありwarm up問題すら苦戦してふるわなかった。

AtCoder系の競技プログラミングは過去問を週に1,2本解く程度になってしまった。水diff程度は解けるが青diffになるとかなり解くのがつらくなってしまう。
最近、コーディングの瞬発力の衰えを感じているので、定期的に解いて行きたい。

自作PC

実は今まで性能の微妙なIntel NUCをメインの開発機としてプライベートでは開発していた。
最近、ファンの音がうるさくなってきたり、ディスクの空き容量がカツカツになったりと怪しくなってきたので思い切って今まで手を出してこなかった自作PCに手を出してみた。

初めての自作PCでノウハウが全くなかったので秋葉原のTSUKUMOに行き、店員さんの言われるがままにパーツを購入し組み立てた。
Ryzen 7+Geforce RTX 2060+メモリ32GB+PCIe 4.0 SSD 1TBでおおよそ20万円ほど。Linuxのビルドとかも視野に入れてSSDの入出力速度は重視し、コアもそこそこ強めのを選んだ。
GPUはNVIDIA Broadcastに興味があったのでそれが動く最低限のものを選んだ。

Windows+Ubuntuのデュアルブートにして、ある程度運用できているがトラブルもいくつかある。まず、Ubuntu側特有の問題として、動画や音声を再生するときノイズが入ったり音がズレたりする。
Pulse Audioを再起動すれば治ることが多いのだが、対処方法がよくわかっていなくてそこそこ困る。

致命的なのはSSDがすでに2回も謎の故障をしていることである。一回目は突然SSDとして認識されなくなり、初期不良として交換品をもらったのだが、交換品も全く同じように壊れてしまった。
一応、故障前に起動時に中に入っているOSを読み取れず起動失敗になる現象がたまに起きるという前兆があるにはあったが、2回ともそこから突然機能停止になってしまった。
店員さんにも相談したのだが、別に使っているSSDの型番やマザーボードでの故障報告が頻発しているというわけではなく、原因が未だに不明のままである。
交換品の到着にしばらく時間がかかるとのことだったので、別のメーカーのSSDを新たに購入し現在運用中で今のところ故障する気配はないが果たして…

自作PCはやはりトラブルが発生することは多く、コストパフォーマンスでいっても特別安くなったわけではない。
とはいっても、部品を選んでカスタマイズして組み立てるのは僕は楽しいと思うので、そういうのが好きな人にはおススメではある。

考えたこととか

競技プログラミング

今年の4月ごろ、競技プログラミングに関する批判の記事があり、これに関して議論が盛り上がった。

これに対して自分の知り合いの知り合いくらいの観測範囲でものすごくこの記事をほめたたえている人も少なくなかった。
しかし、自分は当時Twitterでも軽く言及した通り全く賛同できず、なぜここまで持ち上げられるのか理解に苦しんだ。

この記事は要するに競技プログラミングではコンピューター科学の一部分しか学ぶことができなくて、Googleなどのトップ企業に入る能力を身に着けるには悪影響すらありえるということを主張している。
競技プログラミングが万能でない点については同意するが、このような議論は僕の周りの競技プログラミングをやっている人からすると「月刊競技プログラミングは役に立たない」と揶揄されるくらい度々指摘されてきた話で、
なぜ今更になってここまで盛り上がるのかが理解できない話であった。

確かに、最近はAtCoder社を中心に競技プログラミングを利用した採用だったりエンジニア教育が注目されている。そのため、競技プログラミングがこんなにも役に立った、という話題がクローズアップされやすくなってきたのかなと感じる。
が、僕らが就職活動などを控えていた2010年代前半くらいの空気感だと、そもそも競技プログラミングの知名度が低く、レッドコーダーと言われてもピンとこない、採用でも特に有利にならないということが珍しくなかった。
そのため、僕の周りのレッドコーダークラスの人間も自分の能力は業務で役に立つのだろうかと疑問を持つ人間の方が多いように感じた。
そういう疑念の反証として、こうした競技プログラミングは役に立つ、といった話が持ち上げられたという空気を僕は感じていた。

ただ、それは僕の観測範囲内だけの話であった可能性はある。
実際、AtCoder社の社長であるchokudai氏が、かなり競技プログラミングを持ち上げるような発言を連発しているイメージがあり、そういう発言に感化されて競技プログラミングに対する幻想を抱く人間が一定数いた可能性はあり、
記事の著者がそういう人たちを観測してああいう記事を書いた可能性はある。が、記事を書くきっかけになった人物として名指しされている@drken1215さんは僕も競技プログラミング関連で何度か名前を見た人だし、多分僕と同じような空気を感じていた人ではないだろうか…

議論が古いというのは観測範囲の違いとかでしかたなかったとしても、現在の競技プログラミングのあり方についての記述もかなり疑問な部分が多い。
しかも、そういう思い込みに基づいて「ホラー」だとか「カーゴ・カルト」などという形容をしているのはいかがなものであろうか。

これに対して、恐らく批判の矛先になっていたであろうAtCoder社の社長のchokudai氏が反論記事を書いている。
chokudai氏自身も競技プログラミングの限界など承知であり、そのうえで競技プログラミングにどういう価値があるかという考えをまとめているという記事になっている。
やや反論としてはズレているのではと思う箇所もあるのだが、競技プログラミングの意義・限界については納得できる内容になっていると思う。

個人的には競技プログラミングをきっかけにコンピューター科学に興味を持ち始めて、情報科学を大学で学び、Googleやそれに並ぶような企業で働く知人を何人も観測しているので、競技プログラミングという文化そのものは非常に有意義なものであると思っている。
競技プログラミングは確かにコンピューター科学の一部分でしかないものの、その一分野の敷居を下げたAtCoderの取り組みは素晴らしいものだと思っているし、現に自分もトレーニングとしてAtCoderで出題された問題を解くことがある。
確かに競技プログラミングをやりこんだ人たちは知識が偏っていたり独特の思考に染まっていて驚くことは少なくはないのだが、それは単に入口が違うからでしかたないことだと思うし、「終わらせる」などという強い言葉でコミュニティを否定するのは違うでしょう。

正直、あの記事はchokudai氏やその周辺コミュニティへの個人的な嫉妬・嫌悪感にそれっぽい理由をつけただけのものではないだろうかとすら感じた(本人は別の記事で否定しているが、矛盾だらけの弁明に感じた)。
絶賛している人も競技プログラミングからは遠い人がメインだったし、そういうネガティブな感情から来るものという側面もあったのではなかろうか。

プログラマになるまでのルート

あの記事は主に競技プログラミングを中心にプログラマ、それもGoogleクラスのトップ企業で通用する、になるにはというものを論じていたが、それ以外にもプログラマに必要な能力は何か・どうやったら身につくのかという話題は定期的にTwitterで観測した。

自分は具体的な社名は伏せるが、一応それなりの規模をもつサービスを展開する外資系のウェブ企業に所属してウェブサービスの開発を本業としている。主にフロントエンド寄りの仕事が多い。
大学では情報科学科に入りコンピューター科学の基礎は一通り学び、競技プログラミングも嗜む程度には取り組んだ。
そこで感じるのは、必ずしも競技プログラミングのような高度なアルゴリズムを構築するような能力が必要とされる場面は限らていて、さらに言えばコンピューター科学の知識が直接役に立つ場面というのもあんまりない。
フロントエンド開発はユーザーのアクションに対してAPIリクエストをバックエンドに投げ、そのJSONのレスポンスに対応してUIをデザイナーの指示通りに表示させるようなロジックを組むのが主な仕事になる。
これを「JSONに色を付ける作業」とかジョークで言われたりするように、そこに数学的な知識とか高度なコンピューター科学の知識はそこまで役に立たない。
バックエンドのほうも、フロントエンド開発に近い領域だとデータベースや他のマイクロサービスなどからの応答をいい感じにJSONに加工して返すだけの場面が多く、こちらは「gRPCの詰め替え作業」とかジョークで言われたりする。こちらもコンピューター科学の知識は直接は役に立たない。
問題となるのは、これらの実装をいかに効率的に実現するかであるが、計算量的な効率化ではなく、機能の拡張性であったり設計の見通しの良さといった効率性がポイントとなる。
こういうところで必要になるのは、プログラミング言語やそのドメインに特化したライブラリやツールの使い方のような知識だったり、設計のベストプラクティスのようなものがメインになる。
こういうものは実際に業務での開発経験や、そのドメインの最新情報をブログや勉強会などでキャッチアップすることが多い。コンピューター科学の基礎的な勉強とはかなり距離がある。

じゃあ、競技プログラミングやコンピューター科学の勉強は無意味であったかというとそうではない。機会は少ないといったが、変なところ(ライブラリ内のバグ・インフラの故障)でトラブルが起きた場合に役に立つのはコンピューター科学の知識だったりする。
また、ドメイン知識の習得を下支えするのは基礎能力やコンピューター科学の知識であるとは感じる。
競技プログラミングが一定以上できる人間は、一度知識さえ身に着ければそこそこのスピードでこれらの業務をこなしてくれる。
こういうのを考えると、ある程度のプログラマであれば必ずしもコンピューター科学の知識を身に着ける必要はないかもしれないが、Googleなどのトップクラスを狙うのであれば無関心でいるというのは厳しいのではないか、と考えている。

他にも数学能力の必要性も議論されたりする。確かに自分のウェブフロントエンドの領域に限って言えば直接役に立たない場面が多い。
が、やはり下支えしているのは数学的な能力ではないのかなあと思っている。
前述の記事では東大理Ⅲ出身の医者をGoogleに入れたとあったが、東大理Ⅲ出身ともなればかなり数学の訓練は積んでいるはずである。
また、知り合いでGoogleなどの企業に入るなど高いプログラミング能力の人たちは、プログラミングを始めたのは大学からという人も結構いる。
昨今、年齢の若い内のプログラミング教育がよく話題になるが、個人的には本人のある領域があればどんどんプログラミングの世界に踏み入れるのは大賛成であるが、
焦ってプログラミングを始めてドメイン知識の習得に努めなくてもいいのではないかなあと思うし、数学教育のような基礎の重要性は変わらないと思う。
また、もともと数学(高校レベルあたりを想定)が苦手でさっぱりという人間が社会人になってからプログラミングで頭角を表す、みたいなのは厳しいのではないかなあとか思う。

数学力やコンピューター科学の知識は下支えになるとは言ったが、やっぱり最後は実践経験を積んで強くなるしかないとは思う。
その実践経験を積む方法として、会社に入る以外の方法だと、競技プログラミングは一つの選択肢になるとは思う。
AtCoder系のショートコンテストだと書くコードは非常に短く、問われるのはアルゴリズム寄りの知識がメインとなり偏ってしまうが、他にもプログラミングコンテストはいくつもある。
ISUCONだとウェブ系のドメイン知識が必要になり、競技時間も長くデバッグ能力・問題分析能力なども実践を通して問われる。
ICFPCの場合、多くのチームが問題専用の各種ツールを用意することが多く、そういうツールの開発を通じて得るものも多いだろう。
SECCONなどのCTFではセキュリティや低レイヤーの知識を問われることが多く、それらを手を動かして試す絶好の機会になるだろう。
その他、最近は自作OSや自作CPUなどの自作〇〇が流行っている(気がする)。これらの書籍・ドキュメントを入門として実際に自作〇〇をすると、今まで本で読んだだけの部分の理解がグンと深まる。
自分の例で言うと、自作ハイパーバイザをつくったときにメモリ管理ユニットの仕様書を読みながらコードを書いたのだかが、ページングのCPUの提供している機能を生で触ることができて、授業で習ったこととリンクして非常によい経験になった。
OSSへの貢献、ウェブサービスを自分で開発してみるなど他にも実践の機会はそこら中にあり、無料でアクセスできるリソースも多い。
僕はコンピューター科学は実践してなんぼ、と信じているのでやはり手を動かすというのはなんだかんだ大事だと思うし、その過程でコンピューター科学への理解も大事にしてほしいなと思う。

コインハイブ裁判

地裁で無罪判決が出てから高裁で逆転有罪となっていた所謂コインハイブ裁判が最高裁での審理が始まった

以前も何回か主張してはいるが、このコインハイブ利用による検挙と高裁での有罪判決にはかなり疑問を持っている。
とはいうものの、自分は法律関係の知識は高校とかでの一般知識程度に留まっていて、法律の観点からどこが問題かを正しく解釈できているかに100%の自信はない。
また、コインハイブ裁判に関わらず、この手のプログラマを標的(?)とした検挙というのはよく話題になったり、またプログラミングに関わらないが日常のひょっとしたことから警察のお世話になってしまう話なども流れてくる。

そういったトラブルへの備えと、そのような法律に関する知識をもとに自分の考えをまとめたいという思いから、最近法律系の本を読んだりしている。

現在読み進めているのは「法解釈講義」という法学部生の教科書としても使われている本だ。内容が濃く理解するのはやや大変だが、これといった専門知識もなく読むことができおもしろい

また、「Wizard Bible事件から考えるサイバーセキュリティ」のほうも購入した。
こちらは近年の疑問視されたセキュリティ関連の裁判の総まとめとなっていて、とてもおもしろいのだが、警察・検察の理不尽な取り調べの部分は読んでいて不快感が強い。将来警察・検察とは縁がないことを願いたい。

法律と技術の関係はたびたび問題になるにも関わらず、なかなか改善の兆しはない。司法側の問題もいろいろあると思うが、近年だとウェブ開発側の倫理を問われることも多く、欧米では各種規制が進んでいるが日本ではなかなか進んでいない。
自分もウェブ開発でお金を稼ぐ身としてはきちんと自分の意見を持って、邪悪な開発に手を染めないようにはしたい。

最後に

新型コロナの流行以来制限の多い生活が続き、今年もなかなかに息苦しい一年であった。
確かにワクチン接種が進み、流行も一時期落ち着いたりして制限がゆるくなってきた分野もあった。
自分は商業誌への寄稿といった新しいチャンスを得ることもあり、恵まれているほうなのであろう。
とはいっても、つらいものはつらく、在宅勤務でのパフォーマンス低下は以前はそこまででもなかったが最近はかなり集中するのが難しくなってきたように感じられる。
YouTubeやらTwitterやらをなんとなく眺めて時間をだらだらと過ごすことも多くなってきてしまった。
生活リズムもかなり乱れ始めていたりして、これらはなんとか改善したい。

年齢的にも若手を名乗るのには厳しくなってきているので、もっと貪欲に新しいことに手を出したり成果を形にすることに注力したいと思う。

と、雑にいろんなことを書き殴ったが、来年もいろいろとやっていきたいので、何卒宜しくお願い致します。

Rustのメモリ管理機能とその特徴

初出:技術評論社刊「ソフトウェアデザイン 2021年9月号」

先日、技術評論社よりRustのメモリ管理機能についての特集に寄稿させて頂きました。
この記事は自分が寄稿させていただいた記事をブログ用に一部推敲・加筆を加えたものです。
なお、ソフトウェアデザインでの特集ではより実践的な例でのメモリ管理についての解説もあるので、興味のある方は本誌のほうも手にとっていただければと思います。

プログラム言語におけるメモリ管理の課題

プログラミングにおける課題の一つとしてどうやってメモリ領域(ヒープ領域)を管理するかというものがあります。

C言語ではmalloc/free関数などを用いて手動でメモリを管理しています。
これらの関数はメモリアドレスを示すポインタを介してメモリ管理を行います。
malloc関数は必要なメモリ領域を確保してその先頭番地のポインタを返し、プログラム内ではその番地のメモリを読み書きし、使い終わったらfree関数にその番地を教えて解放する、という流れです。
この方式では、どのタイミングでメモリが操作されるかが明確なので実行時間やメモリ効率がいいのですが、人の手で毎回操作しないといけないので、プログラミングに複雑になりやすくミスを起こしやすいことが知られています。
例えば、ポインタの指し示す先のメモリがもう解放されていたり、他の用途で使われているのにもかかわらず値を読み書きしてしまうことがあります。
C++ではもう少し抽象化された形を用いていますが、基本的には自分で必要なタイミングでメモリの確保・解放を行う必要があり同様の課題を抱えています。

一方、Python・Rubyなどのスクリプト言語を中心に用いられているのがガベージコレクション(GC)という仕組みです。
この方式では実行環境が適当なタイミングで現在使用中のメモリの内容をチェックして、もう必要のないメモリを自動で開放するというものです。
この方式では、プログラマがメモリ解放のタイミングを指定する必要がなく、メモリ解放に関わるミスを防ぐことができます。
その一方で、GCを実行する際にプログラムを一時的に止める必要があったり、GCを走らせるまでは必要のないメモリを確保し続けたままになってしまうなど、実行時の効率が問題になるケースがあります。

Rustは所有権とライフタイムを利用することで、GCのような実行時のメモリ管理コストをなくし、かつ手動でのメモリ管理で起きがちなバグをコンパイラレベルで検知することができます。
この所有権とライフタイムがRustを学ぶ上での大きな壁の一つで完全に使いこなすのはかなり大変です。
かくいう筆者も、数年間Rustを書いていますが頻繁に頭を悩ませる問題であり、コンパイラに怒られながらなんとか直すということしながら書いています。
しかしながら、使いこなすことで安全性が高くかつ実行時コストが非常に少ないコードを書ける仕組みで、Rustが支持される理由の一つでもあります。

今回は所有権・ライフタイムの大まかな概念を見たあと、簡単なリスト構造を題材にこのメモリ管理機能を見ていきます。

Rustは何を保証するか

C言語などにおけるポインタをつかったメモリ管理は以下のような問題を引き起こすことが知られています。

  • これからアクセスしようとしている領域がすでに解放済みだったり他のことに使われているなどの原因により不正なものになっている(ダングリングポインタ)
  • すでに解放されているメモリ領域を誤ってもう一度解放してしまい未定義動作を引き起こす
  • 確保した領域がもう使われていないにもかかわらず解放されない(メモリリーク)

Rustはガベージコレクションのような実行時でのメモリ管理ではない方法でこれらの問題を回避します。
Rustではポインタを使って値にアクセスすることは基本的にはありません(できないことはないのですがunsafeと呼ばれる特殊な操作になり、Rustが保証する安全性を損ないます)。
かわりに使うのが「参照」と「スマートポインタ」などの機能です。
これらがどのようにポインタで発生しうる問題を回避するかを見てみましょう

参照と借用

参照の概念を説明する前にまず「所有権(ownership)」の概念について簡単に見てみます。

Rustのにおける値は唯一の所有者(owner)が存在します。変数に値を代入すると、その変数が値の所有者になります。
同じ値に対して複数の所有者が存在することはできません。
所有者である変数のスコープが終了すると、その値は解放されます。
簡単な例を挙げましょう。

1
2
3
4
5
{
let a = String::from("hello");
}
// ブロックの外に出たことで、aにはアクセスできなくなりaの持っていた値は解放される
println!("{}", a); // この文はコンパイルエラーになる

関数呼び出しや他の変数に値を渡すために所有権を渡す(ムーブ)こともできます。その場合、もともとの変数は所有権を失うので使えなくなります。

1
2
3
4
5
6
let a = String::from("hello");
let b = a;

println!("{}", b);
// 下の文はもうaが使えないのでコンパイルエラーとなる
// println!("{}", a);

しかし、実際には関数呼び出しや構造体のメンバーとしてポインタのようなものを持ちたい場合など、所有権がまるまる渡ってしまうのは不都合な場面があります。
そこでRustでは一時的に値を「借用(borrow)」する、ということができます。このときできるのが「参照(reference)」です。
参照のイメージとしては制限付きのポインタのようなものです。
関数に参照を渡す例を下に示します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 呼び出し元から値を借用する関数
fn print_string(a: &String) {
println!("{}", a);
}

fn main() {
let a = String::from("hello world");

print_string(&a); // aの参照を渡している

let b = a; // 参照を渡すだけでは所有権は失われないので、bにムーブすることができる

print_string(&a); // aは所有権を失っているので参照はつくれず、コンパイルエラー
}

aを借用することで所有権を失わずに関数を呼び出していますが、所有権を失っている変数への参照をつくるといったことをするとコンパイルエラーとなります。
このように、参照は普通のポインタとは違い、不正な値への参照がないようコンパイラが保証してます。
そこで登場するのが「ライフタイム(lifetime)」という概念です。
ライフタイムとはその参照が有効となる期間のことで、これをコンパイラがチェックすることでダングリングポインタや解放済みの領域へのアクセスというものを防いでいます。
他の例を見てみましょう。C言語でのダングリングポインタの例として、関数内のローカル変数へのポインタを関数の返り値にしてしまう、というものがあります。
以下にC言語での例を示します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include<stdio.h>

typedef struct Point {
int x;
int y;
} point_t;

point_t* get_point(void) {
point_t p = { 0, 0 };
return &p;
}

void main(void) {
point_t* p = get_point();
printf("%d %d", p->x, p->y);
}

GCCを使いコンパイルすると、警告はでるもののコンパイルできてしまいます。
これを実行すると筆者の環境ではセグメンテーションフォルトになりました。ローカル変数はスタック上に確保されますが、関数が返るときに解放されてしまうので、呼び出し先(この例ではmain関数内)では使うことができません。

Rustで同じようなコードを参照で書こうとするとコンパイルエラーになります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct Point {
x: i32,
y: i32,
}

fn get_point<'a>() -> &'a Point {
let p = Point {
x: 0,
y: 0,
};
&p
}

fn main() {
let p = get_point();
println!("{} {}", p.x, p.y);
}

突然返り値に'aというのが出てきましたが、これがライフタイムを表しています。今まではライフタイムを明示しなくてもコンパイラが自動的に判断できたのでつけなかったのですが、このように返り値として参照をつける場合は明示する必要があります。
同じライフタイムを持つ参照は同じ期間生存する必要があります。
コンパイラのエラーメッセージはこのようになります。

1
2
3
4
5
error[E0515]: cannot return reference to local variable `p`
--> src/main.rs:12:5
|
12 | &p
| ^^ returns a reference to data owned by the current function

pはローカル変数なのでそこへの参照は返すことができない、と言われています。
つまりget_point関数の返り値の参照は関数外でも生存している必要がありますが、
ローカル変数はライフタイムが関数内で尽きてしまうため、関数外でも生きる参照にはなれない、ということです。

可変性

Rustの参照の他のおもしろい性質として、可変参照と普通の参照が区別されることでしょう。
実は今まで登場してきた普通の参照は参照先の値を変更することができません。
参照先の値を変更したい場合は&ではなく&mutとつけることで可変(mutable)参照を使う必要があります。
配列を使った例で見てみましょう。

1
2
3
4
5
6
7
8
9
10
11
12
// 可変参照をとることで、配列の中身を変更する
fn add_one(a: &mut Vec<i32>) {
for i in 0..a.len() {
a[i] += 1;
}
}

fn main() {
let mut a = vec![0, 1, 2];
add_one(&mut a);
println!("{} {} {}", a[0], a[1], a[2]);
}

vec!マクロを使ってVecというRust標準の動的配列をつくり、その可変参照を関数に渡しています。この&mut&にするとコンパイルが通りません。

可変参照と通常の参照を分けることによって例えば以下のようなケースでダングリングポインタを防ぐことができます。

1
2
3
4
5
6
7
fn main() {
let mut a = vec![0, 1, 2];
let x = &a[0];
a.clear(); // Vecのclear関数は可変参照をとる

println!("{}", x); // xの指す先はすでに解放されている
}

配列aの先頭要素への参照をxとして、その後clearを呼ぶことで配列の中身をすべて消します。
その後、xを使い解放されたはずの先頭要素を出力しようとしています。これはコンパイルされてしまえばダングリングポインタということになってしまいます。
実際にはこのコードは以下のようなエラーが出てコンパイルが失敗します。

1
2
3
4
5
6
7
8
9
10
error[E0502]: cannot borrow `a` as mutable because it is also borrowed as immutable
--> src/main.rs:5:5
|
4 | let x = &a[0];
| - immutable borrow occurs here
5 | a.clear(); // Vecのclear関数は可変参照をとる
| ^^^^^^^^^ mutable borrow occurs here
6 |
7 | println!("{}", x); // xの指す先はすでに解放されている
| - immutable borrow later used here

実は可変な借用をする場合、その値に対する他の参照は存在できないというルールがあります。
今回の場合、xprintln!で使われるあいだまでライフタイムがあるにもかかわらず、その間で可変参照をつくろとしています。
a.clear()は一見するとよくわからないかもしれませんが、Rustのメソッド呼び出しをする場合、その値を借用することで呼び出されます。
clear関数は以下のような定義になっています。

1
pub fn clear(&mut self)

selfというのがそのメソッド呼び出しをしているインスタンスで、その可変参照をとるということです。

リスト構造を書いてみる

もう少し実践的な例としてリスト構造をRustで書くことを考えます
今回は関数型言語でよく見かけるconsを使ったリストというのを書いていきます。consとは2つの値のペアからつくられるリストで、関数型言語だとこういうような書き方をします。

1
(2, (5, (3, nil)))

これは[2, 5, 3]という連結リストのようなものです。各ノードは左側をcar、右側をcdrと呼びます。
リストは空の場合nil、そうでない場合先頭の値をcarに持ち、先頭を除く残りのリストをcdrとして持つ再帰的な構造になっています。

これをRust風に書いていきたいと思います。なお、このようなリストをRustで使うことはあまりなく、Vecのような標準で備わっている型を通常は使います。
リストは空の場合はnilでそうでない場合はペアになっているという2つのパターンがあります。このような構造をあらわすにはenum(列挙型)を使うと便利です。

1
2
3
4
enum List {
Cons(i32, List),
Nil,
}

ただし、これはこのままだと定義できません。なぜならList型がそのままList内のメンバーに使われてしまっているからです。
こうしてしまうとList型が無限のサイズを持たなければなりません。C言語ではこういう場合はポインタを使うように、今回は参照を使って書き直してみます。
参照はポインタのようなもの、と先ほど説明しまいたが、コンパイルされると内部的には参照先の値へのアドレスとなります。
そのため、値の参照先の型によらずアドレス分のサイズしか必要ありません。

1
2
3
4
5
6
7
8
9
10
enum List<'a> {
Cons(i32, &'a List<'a>),
Nil,
}

use crate::List::{Cons, Nil};

fn main() {
let list = Cons(2, &Cons(5, &Cons(3, &Nil)));
}

参照に明示的にライフタイムを書かなければならないことに注意してください。
参照はポインタと同じように内部的にはその値の先のアドレスを持つだけなので、List型のサイズによらずアドレスの値の分で一定なので先ほどみたいに無限のサイズにならずにすみます。
また今回は簡単のためリストの中身を書き換えるということは考えないことにします。

とりあえずリストは定義できました。次にいろいろな操作を書いてみましょう。
まずはリストの内容を全て出力する関数を書いてみます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn print_list(list: &List) {
match list {
Cons(val, ls) => {
println!("val: {}", val);
print_list(ls);
}
Nil => {}
}
}

fn main() {
let list = Cons(2, &Cons(5, &Cons(3, &Nil)));
print_list(&list);
}

enumに対してはこのようにパターンマッチを使った操作をすることができます。
consの場合はその値を出力して、残りのリストに対して再帰的にprint_listを呼び出し、nilに到達したら終了します。

出力結果は以下のようになります。

1
2
3
val: 2
val: 5
val: 3

さて、このリストを新たにつくる関数を考えてみましょう。
例えばこのリストの末尾に新たな要素を付け加えたリストを新たにつくりたいと思います。

関数はリスト受け取って空リストならその新たな要素1つのみを含むリストを返し、consなら残りのリストに対して関数を再帰的に呼び出し、
その結果をcdrにした新しいconsを返すことで実現できそうです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn append<'a>(list: &'a List, val: i32) -> List<'a> {
match list {
Cons(x, ls) => {
Cons(*x, &append(ls, val))
},
Nil => {
Cons(val, &Nil)
}
}
}

fn main() {
let list = Cons(2, &Cons(5, &Cons(3, &Nil)));
let list2 = append(&list, 7);
print_list(&list2);
}

しかし、これは以下のようなコンパイルエラーになってしまいます。

1
2
3
4
5
6
7
8
error[E0515]: cannot return value referencing temporary value
--> src/main.rs:22:13
|
22 | Cons(*x, &append(ls, val))
| ^^^^^^^^^^---------------^
| | |
| | temporary value created here
| returns a value referencing data owned by the current function

再帰呼出ししてつくった値が関数内でしか生存しないので、ライフタイムが合わずエラーが出ています。
このように、参照のみをつかってデータ構造を定義しようとするのは非常に難しいです。どうしたらよいのでしょうか?

C言語の経験がある方は、このような場合メンバーはポインタで持つと思うのですが、その値をつくるときにmalloc関数などを使いヒープ領域から値をとってくると思います。
Rustでもヒープ領域を扱う方法はもちろんあります。例えば、String型は内部的にはヒープ領域を使っています。
C言語の場合、malloc関数などでヒープ領域に確保された領域をポインタで指し示しますが、Rustでは「スマートポインタ」と呼ばれるものを使い安全にヒープ領域を扱います。

スマートポインタを使ったヒープ領域の活用

スマートポインタという言葉はC++でも使われているので知っている方もいるかもしれません。
普通のポインタとは違い、参照のように不正な操作を防ぐための仕組みが備わっています。
スマートポインタといってもRustには何種類か備わっているのですが、まずは一番(?)スタンダートなBoxというのを見てみましょう。

1
2
3
4
5
6
7
8
fn main() {
let x = 5;
let mut boxed = Box::new(x);

println!("boxed = {}", boxed);
*boxed += 3;
println!("x = {}, boxed = {}", x, boxed)
}

xをつかってboxedという値をつくりました。この値はヒープ上に確保されて、参照のように使うことができます(Derefと型強制という仕組みでおこなわれているのですが詳細は割愛します)。
xの値はコピーされているので、boxedを変更してもxの値は変化しません。

これを用いて先程のListの例を書き換えてみましょう。Boxも内部的にはポインタと同じようにその先が指し示す構造によらず一定のサイズで済む型です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
enum List {
Cons(i32, Box<List>),
Nil,
}

use crate::List::{Cons, Nil};

fn print_list(list: &List) {
match list {
Cons(val, ls) => {
println!("val: {}", val);
print_list(ls);
}
Nil => {}
}
}


fn main() {
let list = Cons(2, Box::new(Cons(5, Box::new(Cons(3, Box::new(Nil))))));

print_list(&list);
}

これは正しくコンパイルされ、先ほどと同じ出力結果を得られます。
先程断念した末尾に新たな要素を付け足したリストを返す、という関数も簡単に実現できるようになります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
fn append(list: &List, val: i32) -> List {
match list {
Cons(x, ls) => {
Cons(*x, Box::new(append(ls, val)))
},
Nil => {
Cons(val, Box::new(Nil))
}
}
}



fn main() {
let list = Cons(2, Box::new(Cons(5, Box::new(Cons(3, Box::new(Nil))))));
let list2 = append(&list, 7);

print_list(&list2);
}

今度はコンパイルできて以下のような出力結果が得られます。

1
2
3
4
val: 2
val: 5
val: 3
val: 7

なぜ今回は問題がなかったかというと、Box::newでつくられた値を借用するのではなく、ムーブによって所有権ごと渡されているからです。
内部的にも値の中身はヒープ領域にあるので関数から出てしまっても保持されているので問題ありません。

メモリ解放について

関数内のローカル変数の場合、その関数が終わるときにスタックが破棄され値も解放されますが、Boxのようにヒープを使う場合どのように解放されるのでしょうか。
実はBoxにはDropというトレイト(C++のインターフェースのようなもの)が実装されていて、その値がスコープから出たときにメモリが解放されるような実装がされています。
所有権システムによりその値に紐付いている変数は複数存在することはできないので、Dropトレイトによって実装されたメモリ解放の動作が同じBoxのインスタンスに対して働くことはありません。
また、自分たちでわざわざどこでメモリを解放するかを明示する必要もありません。

Rustのメモリ管理の弱点

ここまでRustでのメモリの扱いについて一通り見てきました。
Rustがこれらの仕組みを使いメモリを安全にアクセスできるというのがわかったと思います。

一方で、いくつかの弱点もあります。まず、可変な参照を考慮すると問題が複雑になることです。
可変参照は複数つくれないことを説明しましたが、現実には複数の値に同じ値を操作するための参照を持ちたい場合というのは結構あります。
例えば可変な双方向連結リストがそれにあたります。リストの各ノードは前後の要素に対する参照をもつような実装になりますが、そうするとあるノードに対しての可変参照が前のノードと後ろのノードのそれぞれが持つことになってしまいます。
一応、そういうケースをなんとかする構造体もあるのですが、普通のポインタよりかはオーバーヘッドがつきます。

またRustは実はメモリリークが起きない保証はしていません。
確かにBoxなどは自動的にメモリを開放する仕組みが備わっていますが、意図的にそういうものを無効にする関数があります(std::mem::forget)。
普通、Rustの安全性の保証を無視する関数はunsafeとマークされるのですが、そのような関数はunsafeとされていません。

このように、Rustのメモリ管理も万能ではありません。しかしながら、生のポインタを使うよりかはずっと安全で、GCありの言語よりかは扱いにくいと思いますが、その分高いパフォーマンスを得ることができます。

参考文献

ISUCON11予選参加記(予選敗退)

ISUCON11にチーム「ビックバン・オーガニゼーション」で@cympfhさんと@dekokunさんと参加して、予選敗退しました。お母さんには内緒だぞ!
昨年は別のメンバーとですがRustを使い予選通過できましたが、今年は結果が出せませんでした。

事前準備

この御時世ということもあり、全員Discordでオンラインで連絡を取り合いながら去年の問題を解いていました。
ログ解析にはnginxのログを解析するalpを、MySQLはスロークエリをpt-query-digestで解析することにしました。
ウェブアプリのデプロイは手元でコンパイルしたものをrsyncでデプロイする形式をとりました。
サーバーの基本的な環境設定用のansibleスクリプトも用意しました。

また、デプロイやログ解析用のシェルスクリプトも用意しておきました。

予選本番

9:40からのyoutube liveには全員オンラインになってライブを視聴してました。

まずは、ansibleを走らせつつ、当日マニュアルを読み競技内容の確認を行いました。

Rustの初期実装でベンチを走らせた結果を元に、まずは適当にindexを貼りました。
また、nginxの設定をいじり、静的ファイルはアプリを介さずnginxで直接返すようにしました。

11:40頃、CPU使用率が張り付いていたのを見てDBは別サーバーで動かすことにしました。スコア7610

13:10頃、他のメンバーがiconのmax-ageを設定することでスコア18380

13:55頃、GET /api/isuの最適化のため最新のconditionをウェブアプリ内でキャッシュするようにする。スコア17992であまり変わらず

15:00頃、icon画像をウェブアプリ内でキャッシュするようにする。スコア18196で大きくは変わらず

15:50頃、POST /api/condition/{id}でリクエスト中の最新のコンディションのみをINSERTする変更を加える。スコア35050で大幅に伸びる。
これはGET /api/isu/{id}を叩いたときに新しいコンディションを確認したときに加点される仕組みを利用したハックでした。
本当は全件を一括INSERTしたかったのですが、Rust実装で使われていたsqlxはそのようなSQL文を出力するAPIを持っていなかったのでやや手間がかかるのでこういう実装にしました。
グラフ内のコンディション数は下がってしまうので、そこでの加点はおさえられてしまいますが、それ以上にPOSTの負荷を下げることのほうが効いたようです。

ここでウェブアプリのCPU負荷が高く張り付いていることに気がつき、ウェブアプリを2台立てようと試行錯誤したのですが、なぜかスコアが下がってしまう現象に見舞われ苦戦します。
他にもアプリケーショ内キャッシュを導入したり、DROP_PROBABILITYを変更したり、スコア計算の仕組みを利用したハックの検討などをしたのですが、全て裏目に出てしまいます。
そうこうしている内に競技時間が過ぎ、再起動試験をした後、ログ出力を切り最終的にはスコア39107ということで予選敗退しました。

敗因分析

まず、ウェブアプリの分散に失敗したことですが、ウェブアプリ内でキャッシュを持っていてそれを共有していなかったのが原因のようです。
アクセス数のみをみてPOST /api/condition/{id}だけを別サーバーで捌く、などをしていたのですが、その場合、最新のconditionのキャッシュがPOST時に更新されないことでスコアが下がっていたようです。
今回、POSTの内容を全て無視しても整合性チェックで怒られることがなく、競技中は何が原因かの特定に失敗してしまいました。
ただし、nginxのみを分離する、ということは簡単にできたはずで、これはベンチマーク公開後に試してみたのですが、それだけでスコアが伸びそうな気配がありました(実際のスコアはベンチマーカーサーバーが非力だったためわからず)。

また、nginxからエラーメッセージが出ていて、リクエストがでかすぎるせいで一時ファイルが大量に生成されていることに競技中に気がつきませんでした。
確かにToo many open fileのエラーはアプリ側のログで確認したのですが、ulimitの変更で誤魔化していました。
nginxからのエラーメッセージ見逃しは去年もあったのに、同じミスを繰り返してしまいました。

あとは、isuテーブルからオブジェクトを引っ張るときに全てのカラムを引っ張っていたのですが、これをやめるのもかなり効果的でした。
これは僕は競技中に提案したのですが、そこまで優先度高くないよね、ということで先送りされていました。
が、実際にはicon画像が載っているカラムを引っ張ってきたりするので、かなりの負荷があったようです。
また、無駄なtransactionを生成している部分を消すのもかなり効いたようでした。
このへんはDBでの負荷が比較的少ないように見えたので、きちんとログを確認しておらず気がつきませんでした。
sqlxにはバグがあり、リクエストが中断したときなどにトランザクションを正常に終了できずエラーを吐くというのがあったので、
トランザクションを減らすことはその手のエラー処理の際のCPU負荷を軽減することにもつながっているので、Rustの場合は特に重要だったようです。
ただし、これらのテストはvagrant環境で同一インスタンスにベンチを含めた全てのアプリケーションを入れた場合のテスト結果なので、もしかしたら効果の大小は見誤っているかもしれません。

GET /api/trendはVarnishでキャッシュする戦略はかなりありだったと思います。
実は、事前練習でVarnishの導入は練習していたのですが、使うという発想が出てきませんでした。

総じて、本質的な負荷軽減があまり出来ていないにもかかわらず、チーム全体の思考がスコア計算をハックする方向に傾いてしまっていたのは痛かったと思います。
また、コードの変更内容をチーム全体で共有できていなくて、間違った修正が入ることも多かったので、ある程度チーム内で変更をレビューするステップを加えてもよかったと思います。

感想

結果こそ出せませんでしたが、今年も十分に楽しめました。運営の方々には改めて感謝です。
今年のルールは去年に比べると変更すべき点が多く、スコア計算の仕組みが結構複雑で戦略を問われるものになっていたと感じました。
個人的には、スコア計算の仕組みはシンプルに整合性を保ってリクエストを捌けた数を競うようなルールが好みなのですが、現実のアプリケーションでも厳密にすべてのリクエストを捌いて常に整合性を保つということは要求されていないことも多いと思うので、これはこれでありなのかなあとも思います。俺はどっちでもいいけど。

論文紹介: Theseus: an Experiment in Operating System Structure and State Management

先日、Kernel/VM探検隊 onlineというイベントにて、RustでOSを設計するという論文をまとめて紹介するというLTを行いました。

しかし、10分間のプレゼンで3本の論文を紹介するとかいう、かなり無茶苦茶な発表だったので説明している箇所もかなり限定的で、解説全体として非常に雑なものになってしまいました。
そこでこの記事で、このLTで触れた論文の1つであるTheseusというOSについて説明したものをもう少し詳しくみていきたいと思います。

論文概要

  • タイトル: Theseus: an Experiment in Operating System Structure and State Management
  • 著者: Kevin Boos, Namitha Liyanage, Ramla Ijaz, Lin Zhong
  • 会議: 14th USENIX Symposium on Operating Systems Design and Implementation (OSDI ‘20)

USENIX系会議でOS関連のトップカンファレンスの一つであるOSDIの論文です。
先日のLTで紹介したうちのReadLeafに関する論文もこの年のこの会議で発表されたものです。

この論文では、OSのステートスピルというものを減らすことを目的としたOS設計を模索しています。
ステートスピルとは、あるコンポーネントの変更可能なステートが他のコンポーネントとのインタラクションでステートの一部が他のコンポーネント内に残ってしまい、その漏れたステートを持ったコンポーネントがクラッシュしてしまうことで、もともとのコンポーネントもその影響を受けてクラッシュするというものです。
このステートスピルを減らすOS設計のために、Rustのコンパイラレベルで保証される性質をOSの設計でもなるべく保つことで、ランタイムでおこなうべきことを減らせる、というのがこの論文のポイントです。

ちなみに「Theseus」の読み方なのですが、ギリシア神話が元ネタなのですが、日本では「テセウス」で知られていますが、著者らは英語読みで「シーシアス」、というように喋っていたので、自分もLTでは「シーシアス」と紹介しました。

Theseusのデザインの原則

Theseusはcellと呼ばれるソフトウェアコンポーネントの組み合わせにより成り立ちます(Rustのstd::cellとは関係ありません)。
デザインの原則として以下の3つを挙げています。

  1. すべてのcellはランタイムで永続的な(runtime-persistent)境界を持つ
  2. 言語とコンパイラの力を最大限活かす
  3. cell間のステートスピルをなくす

順にどういうことかをみていきます

cellはRustのクレートとして実装され、実行時にはcell間の依存関係についてのメタデータとともにロードされます。
ロードされたcellはCellNamespace上で管理されます。
各cellをクレートとして実装することで、そのクレートのオブジェクトファイルとcellが直接対応することになります。
cellの依存関係はクレートの依存関係と等しくなるので、循環した依存関係は発生しません。
このクレートして実装された境界により、後述するcellスワッピングやエラーからの復帰が簡略化されます。

2つ目の原則である言語とコンパイラのもたらす安全性の保証を活かすために、Theseusのランタイムモデルは言語のランタイムモデルと同じように、シングルアドレススペースで(同じメモリ領域に対して複数のアドレスをマッピングしたりしない)、単一の特権レベルで(途中で実行モードが変わったりしない)、単一のヒープ(グローバルアロケータしか使わない)で実行されるようになっています。
これらにより、コンパイル時に行われる型やライフタイムなどの不変条件のチェックをそのまま活用することができ、OS的に他の仕組みを導入する必要がありません。
Rustが保証してくれていない性質としてリソースのリーク(resource leakage)の問題があります。実はRustはメモリリークが起きないことは保証してくれません(例えばstd::rc::RCで循環参照をつくるとメモリリークになります)。
ただし、ここでのリソースリークは広義のメモリリークというより、タスクがなんらかのオブジェクトを抱えたままエラーで異常終了する、といったケースを考えているようです。
これを防ぐために、TheseusではクリーンアップのロジックはすべてDropハンドラで実装することにし、独自のスタックアンワインディングを実装することで異常終了した場合でもリソースが解放されるようにしています。
このスタックアンワインディングで使う情報はすべてコンパイル時に生成されるので、実行時にコストがかかることはありません。

3つ目の原則であるステートスピルをなくすために、Theseusではopaque exportationという仕組みでクライアント・サーバー間で通信します。従来の場合は、サーバー側でステートを持ちその進捗とかを管理しますが、Theseusではクライアント側にそのようなステートは輸出し、所有権がクライアント側に移ります(exportation)。
一方でサーバー側のプライベートなステートは型安全性により守られます(opaque)。
こうすることで、クライアント側が所有権を持つため、クリーンアップのロジックも当然クライアント側で自動で行われるので、あえて実装する必要がなくなります。
複数クライアントでステートを共有する場合、Arcなどのリファレンスカウントを持つオブジェクトを使いますが、このような場合、ステートはヒープ上におかれることになります。
これはヒープへのステートスピルのように見えますが、このようなヒープアロケーションそのものはStringのようなオブジェクトをつくるときにも起こるのでそもそも避け得ないものであり、ヒープアロケーションを表すArcなどのオブジェクトの所有権はクライアントが持つことになり、そのオブジェクトを辿っていけばヒープにアロケートされたオブジェクトによるステートスピルも検出できるので、ヒープそのものの内部状態を考える必要はない、としています。

具体例

原則2、3をLTのスライドには入れたが、発表時間の都合上吹っ飛ばしたメモリ管理の例で見てみましょう。

TheseusではMappedPage型で仮想メモリを管理しています。シングルアドレススペースを実現するために、MappedPage型の所有権を保有することが対応するページとフレームの所有権を保有することを意味します。
よって、複数からマップされることはページとフレームの所有権を複数箇所で保有することを意味してしまうため、コンパイル時に禁止されます。
また、このアロケートされた領域はDropハンドラでしか解放されません。これにより、ライフタイムが尽きたときのみページとフレームが解放されるので、use-after-freeのようなバグを防ぐことができます。
また、writableやexecutableかも、それぞれ専用の型を用意することで、型レベルで制御しています。
このMappedPageオブジェクトをopaque exportationでは所有権ごとクライアントが受け取ることになります。サーバー側は使われている仮想メモリエリア(VMA)のリストを管理するということは必要ありません。

CellスワッピングによるLive Evolution

Theseusはnano_coreと呼ばれる他のcellが動的にロードされていき、cellスワッピングという仕組みで機能を拡張していきます。
6章で詳しく説明されているのですが、文章を書くのが疲れたので、このブログではスキップします。

実験・評価

CellスワッピングによるLive Evolutionにより、複数のスケジューラを切り替えたり、ネットワーク越しのアップデート機能などを実際に実装しています。

フォルトリカバリーの仕組みがちゃんと動くかの評価のためにQEMUのエミュレーターで実行時にランダムにフォルトを挿入するという大胆な方法で実験し、MINIX3と比べクラッシュすることが少ないことを確かめています。

マイクロベンチマークなどを用い、オーバーヘッドが十分に少ないことを確かめていますが、Linux並みの機能を実現しているわけではないことには留意する必要があります。

限界

低レイヤーの世界ではどうしてもunsafeを使うことが必要になります。unsafeコードは独立性を損ねる場合もあるので問題です。
また、Rustのコンパイラとcore/allocライブラリを信用しているのでそれらのsoundness holesの影響を受けてしまいます。
Rust言語そのものの性質に大きく依存しているので、他の言語のコンポーネントは残念ながら使えません。

まとめ

いかがでしたか?ちゃんと理解できましたか?僕も正直理解できているか自信がない箇所が結構あります。
が、Rustを活用することでのOS設計のメリットというのが垣間見えたと思います。

Theseusのコードはオープンソースになっていて、実装についてのドキュメントもあるので、そちらも目を通してみるとより理解できるのではないでしょうか。自分はまだ手が回っていませんが…

自作OSに関する新しい書籍が発売されるなど、自作OSに対する関心が高まりつつある(?)今日この頃ですが、従来のOSとは違ったアプローチのOSを探求するというのもまたおもしろいと思います。

RustのSTM32向けイーサネットドライバを解説する(送信編)

STM32ボードのイーサネットドライバのRust実装であるstm32-ethクレートの送信部分のロジックの解説をしていきます。

前回の記事では受信部分を解説しました。
そのときは、自分のOS用のドライバでの送信が成功していなかったため、受信のみの解説になってしまったのですが、
今回晴れて送信部分のバグがとれたので、安心して記事を書けるようになりました。

イーサネットモジュールの初期化部分のロジックは受信とかぶっているので省略していきます。
仕様書も前回記事で用いたものを使います。

送信用ディスクリプタとバッファの用意

受信はDMAを用いてメモリにデータが書き込まれ、操作のためにはリングバッファになったディスクリプタと呼ばれる領域とそれに対応するバッファを確保する必要がありました。
送信もDMAを用いてメモリ上のデータを転送し、リングバッファになっているディスクリプタを用いて操作していきます。
リファレンスマニュアルでは33.6.7で解説されています。今回はNormal Tx DMA descriptorsを使います。

stm32-ethではsrc/tx.rsTxRingという構造体がこのリングバッファを抽象化したものです。TxRingEntryが各ディスクリプタとそれに対応するバッファを持っています。
このTxRingEntryと受信のとき使ったRxRingEntryは共にRingEntry<T>というジェネリック型を用いて実装されていることからわかるように、共通点は多いです。ただし、フィールドの位置が微妙に違ったりするので注意しましょう。

この送信用ディスクリプタの初期化には以下のような処理が必要です。

  • TDES0のOWNビットをクリアしておく。このビットがセットされているとDMA側が所持していることになるが、まだ送信するべきものがないのでCPU側で所持する
  • TDES0のTCHビットをセットすることで、セカンドアドレス連鎖を有効にする
  • リングバッファの末尾のエントリ以外の場合、TDES3に次のディスクリプタのアドレスを書き込む。最後のエントリの場合は、アドレスは設定せずにTDES0のTERビットをセットする。

また、ディスクリプタは8バイトにアラインされている必要があります(ワードアライン)。

あと、これは前回書き忘れたのですが、DMAで書き換えられるメモリにアクセスしてポーリングなどの処理を書く場合はcore::ptr::read_volatileを使うなどしないと最適化されてちゃんと動作しない場合があることにも注意しましょう(1敗)。

DMATDLARレジスタに先頭のディスクリプタのアドレスをいれ、DMAOMRのSTビットを立てれば、送信用のDMAの設定は完了です。

DMAからデータを送信する

Eth::sendから呼び出されているTxRing::sendが送信でディスクリプタやバッファの処理をするコードです。実際に見てみましょう。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pub fn send<F: FnOnce(&mut [u8]) -> R, R>(&mut self, length: usize, f: F) -> Result<R, TxError> {
let entries_len = self.entries.len();

match self.entries[self.next_entry].prepare_packet(length) {
Some(mut pkt) => {
let r = f(pkt.deref_mut());
pkt.send();

self.next_entry += 1;
if self.next_entry >= entries_len {
self.next_entry = 0;
}
Ok(r)
}
None =>
Err(TxError::WouldBlock)
}
}

関数としては送りたいデータの長さと、ドライバ内のバッファにデータを書き込むための関数を受け取り、Resultを返すという型になっています。
やっていることはまず、利用可能なディスクリプタがあるか探していて、ある場合は、エントリ内のディスクリプタの下準備とバッファ領域を準備します。
ディスクリプタの下準備とは

  • TDES1のTBS1にバッファサイズを書き込む
  • TDES2にバッファのアドレスを書き込む

の2つです。バッファはTxEntry内のバッファのうち、必要な長さのみのスライスへのミュータブルな参照として渡されます。
引数として渡される関数はこのスライスにデータを書き込むこととなります。

その後、準備したバッファ領域に対して受け取った関数fを実行します。
pkt.send()はTDES0のOWNビットを立てる処理でこれでこのエントリがDMA側で処理される準備ができたことになります。
リングバッファのカウンターを更新したら、Okを返しておしまいです。

最後に呼び出し元のEth::sendTxRing::demand_pollを呼び出していますが、これはDMATPDRレジスタに1を書き込むことによってDMAが送信ディスクリプタをポーリングするように要求するものです。

受信とは違い、最後にDrop等は特に実装する必要はないです。

送信用バッファについて

このstm32-ethでは送信用バッファとディスクリプタを1つの構造体にまとめ上げていますが、実はこうする必要はあんまりなかったりします。
そもそもディスクリプタできちんと指定してあげれば、バッファのアドレスに特に縛りはありません。

この実装だと非行率的な例をいくつかあげます。
まず、送信用バッファとは別のメモリ上にすでに出来上がったパケットが存在している場合(例えば定数になっている場合)、引数として与えられる関数fはメモリ間のデータコピーをするだけとなってしまいます。
また、現状の実装ではTxEntryを確保した後、送信用バッファにデータを用意するという流れになっていますが、実際は送信用のデータの用意はTxEntryの確保前でもよいはずです。
このデータの用意がそこそこ時間のかかるものであれば、無駄にTxEntryを確保する時間が長くなってしまいます。

send関数を任意のアドレスとその長さを渡すような関数にしてしまう、という実装にすればこのような問題は解決されそうです。
が、ひとつ注意しなければならないのはライフタイムの問題です。
DMAでアクセスされるバッファ領域が送信中に解放されてしまい別のデータが入るなどとなれば送信が失敗してしまいます。
なので、バッファ領域はライフタイム制約を入れた参照として受け取るとよいでしょう。この場合の制約はドライバと同じ区間生存する、というものならば大丈夫でしょう。

感想

最後のバグの原因はGPIOの設定がひとつだけ間違っていた、というものだったのですが、気がつくまで相当しんどかったです。
とりあえずちゃんと実装できて一安心です。
送信は受信とは違った実装ポイントがあるので、もっと作り込めばおもしろそうだなと思いました。

2020年の振り返り

今年の成果

まずはブログ記事だが、今年はこの記事を含めずに9本で去年より少なくなっている。
一応、zenn.devにも記事を書いたが、それを含めても少ない。
また、自作OSプロジェクトだが、今年は新しいモジュールはイーサネットドライバの受信部分のみであまり進捗は出ていない。

と去年やっていた分野では停滞気味になってしまったが、他の形での成果はいくつか上がった。
1つはRustでの自作OS入門のドキュメントで、これは予想以上に反響をもらった。
こういうまとまったドキュメントを書くのはあまり経験がなかったのだが、はてなブックマークでのトレンド入りするなどしていたらしい(普段そういうサービスを使っていないので気がつかなかった)。

各種プログラミングコンテストにもぼちぼち参加した。AtCoderではABCに何回か出るほか、AtCoder Problmesを利用して毎月一程度の問題数をこなすようにした。
短時間で集中してコーディングするという能力がまだまだだと感じているので、ABCにちゃんとオンタイムで参加して緊張感のある状態で問題を解くという回数は増やした(時間帯が変わってくれるともうちょっと楽なのだが)。
ISUCONでは初めて予選通過できた。が、本選では散々な結果だったしまだまだ知識量も足りていないという部分も感じられた。
予選はトラブルも多く実力を発揮しきれなかったチームも一定数あることを考えると、運に助けられた部分もあったと思う。
しかしながら、ノウハウは自分の中で蓄積できつつあるのは感じているし、予選通過自体は素直に前進であったとポジティブに捉えてはいる。
ICFPCにも出ていた。チームはベストメンバーがそろっていたが、14位で去年と同じくらい。チームの中ではGalaxy Padの仕様をエスパーするのが一番得意という何とも言えない強味でサポートできた。できればもうちょっと他の面でもサポートできれば良かったのだが、なかなか難しい。

オープンソースへのコントリビューションはいくつかのプロジェクトに小さな貢献が何件かあったが、あまり大きな貢献はできなかった。
一応、winitというRust製のウィンドウシステムのライブラリにIME関連のイベントをサポートするというのに貢献を続けている。
が、MacとWindowsの環境への実装ができてないためまだマージはされていない。
これがマージされると他のデスクトップアプリケーションでIMEイベントに対応した機能がいろいろ実装できるのでぜひマージされてほしいが、手持ちにまともなMacとWindowsの開発環境がないので難しい。

他にはArm入門勉強会というイベントで過去に作った自作ハイパーバイザの話をするとかした。
オンラインのこういう勉強会にはちょくちょく今後も顔を出していきたい。

トータルで見れば去年の成果からは思ったよりは伸びなかったが、決して進歩がないわけでもないといったところか。
今年は新型コロナの影響やらなんやらでモチベーションを保つのが難しかったり、生活スタイルを変えなければならなかったりといろいろ厳しいなか、それなりにうまくやれた気もする。

政治のこととか

普段、Twitterとかで政治とか社会問題に関する言及はあんまりしないようにしている。
それらに関する専門知識をさほど持ち合わせているわけでもなく、この手の議論をSNS上で繰り広げてもろくなことにならないケースが多すぎるからである。
とはいえ、自分に直接関係するようなことに対して全くもって言及しない、というのはそれはそれで違うだろうとも思うので、いくつかの問題については言及をあえてした。
年末なので、自分の考えを整理する目的でここに書くが、別にこれに関して反論とかをぶつけてきても必ずしも反応するわけではない、ということは予め断っておく。
またお約束ではあるが、あくまで個人の見解であり、所属する団体を代表するものでもない。

COVID-19

新型コロナウイルスの流行は、無関係な人間は全くいないといっても過言ではないくらい社会にインパクトを与えてしまった。
3月の頭あたりから会社でも全員原則在宅勤務となり、正直その当時はオーバーリアクションなのでは、とも感じていたのだが、全くもって甘い考えであったことはその後の状況を見てのとおり。

一部では流行は自然収束するとか、日本人は免疫を持っているとか楽観説を唱える言論も少なからずあったわけだけど、現状の国内外のデータと照らし合わせれば説得力はほぼ皆無になりつつあると思う。
自分には医学的な知識はほとんどないので、論文を読んで本当に正しそうみたいな議論は残念ながらできないが、日本の分科会に属する医学の専門家たちの知見はおおむね信頼していいのだろうと思っている。
もっとも、その知見に基づいてきちんとしたアクションがとられているかどうか、メッセージの発信の仕方などには議論はあるとは思うけど。

大手メディアの報道もかなりいい加減で、かなり信憑性が低い言説であったり、専門家のメッセージもかなり歪に切り取られることも少なくなかった。
このあたりはそこまで驚きではなかったが、 自分の観測範囲のそこそこ知識もきちんとあると思っている人たちですら、怪しい言説をシェアしたり、流行初期にあった日本は意図的に感染者数を隠しているのような陰謀説に加担するひとすらいたのは正直驚いた。
なので、自分は分科会が上げる資料を時々目を通したり、感染者数の動向の生データをみたり、こういう専門家と矛盾していない解説をしてくれるTwitterアカウントを非公開リストにいれて(信憑性はだいぶ落ちるであろうことに留意しつつ)ウォッチしている。

自分は幸いにも大きく収入が落ち込むということはなかったが、人との接触が歓迎されないという状況が長く続くというのは気分がよくない。在宅勤務も正直そこまで好きじゃない。
おそらく来年中にコロナ問題が完全解決とはならず、一定の制限が続くのではないかなと理解しているが、一日も早く事態が好転するのを願わずにはいられない。

Coinhive裁判

Coinhiveを設置したウェブアプリケーション製作者が逮捕され裁判になるという事件があり、地裁では無罪になったが今年2月の高裁判決では逆転有罪となってしまった。
自分はこのケースを無罪になるべきだと思っている。
詳しい説明は日本ハッカー協会の寄稿などがわかりやすいと思うので、ここでは詳しく解説しない。

Coinhiveに関しては知り合いでも有罪になるべきではないか、という意見の人が少なからずいたりする。
そういう主張の背景にはCoinhiveを設置するのは金儲けのためで悪意に満ちたものという思い込みであったり、あるいは倫理観にかけた行為であるという点でそういう意見になっていると思っている。
しかしながら、ウェブアプリケーションを運営するにあたってなんらかの収益化は必要不可欠だし、倫理観にかける=違法であるわけではないしそこは議論をきちんと分けるべきと思う。
もうちょっと踏み込むと、そもそも倫理観にかけた行為であったのか、という点も議論の残る点ではある。
例えば有名なセキュリティソフトの開発元のNortonは、自社製品でこのようなマイニングスクリプトをブロックするようにしてはいるものの、100%悪として断罪はできないだろうとしている。

そもそもウェブ関連の倫理観というのはまだまだ成熟が甘く、どこからがアウトでどこまでがセーフかという線引きが難しい事案は多くあると思う。
他の産業の倫理観を引き合いに非難するというのも、ブラウザというサンドボックス内で実行されるアプリケーションという性質を考えると適当ではないだろう。
もっとも今ではブラウザ標準でこのようなスクリプトは弾かれるようになっているし、こういう手法は今なら倫理的ではないと言えるかもしれない。

たしかに、ウェブ関連のアプリケーションはかなりの大手ですら倫理的にどうなの、と思うようなことを平気でやっていたりするので、健全な状態とは言い難いとは思う。
しかしながら、このようにぽっと出の技術を使った個人開発者を叩いても、このような現状が好転するようなながれにはならないとは思う。

来年について

もうそろそろ若手とは言い難い年齢になってきたので、できるだけ多くのことに今のうちに学んでいけたらと思っている。
具体的には

  • 実際に広く使われている組込みRTOSの仕様や実装を学ぶ(Rustに限らない)
  • ARMv8-Aでのベアメタルプログラミング
  • ネットワークスタックについての理解
  • ウェブアプリケーションのセキュリティ施策
  • OSSに関するより粒度の大きい貢献

あたりが面白いテーマかなと思っている。おそらく全部をカバーするのは難しいと思うけど。

あとはコロナの影響で難しくなってきてはいるが、人との交流は増やしたいとぼんやり思っている。
もともと、人と積極的に親しくするというのは苦手で、所属しているグループで孤立するということはないのだが、例えばプライベートで会って遊ぶみたいな機会はほとんどなかったりする。
人との距離を見誤り怒られたり、立ち入ってはいけない領域に踏み入れてしまうということも、ちょっと前だと少なくなかったように思う。
最近だとないがそれは単に人との距離を詰めるのをさぼっているだけ、という気もする。
人との交流が少なくなると自分の精神的にもよくないというのは感じているので、なんらかの方法で増やせないかなあと思うのだが、はてさてどうすればいいのやら。

今年もあと数日残っているし、この年末に大きなニュースが入ってくるというのも珍しくないのだが、暇なので振り返ってみた。
来年も引き続き技術的なことを発信できていけたらなあと思う。

RustのSTM32向けイーサネットドライバを解説する(受信編)

この記事は自作OS Advent Calendar 2020の20日目の記事です。

RustでSTM32ボード用の自作OSをしていて、アプリケーションの幅を増やしたくて、イーサネットドライバを組むことにしました。
使用しているのはNucleo-429ZIボードで、イーサネットモジュールが付属しています。
去年から取り組みはじめてはいたものの、今までのペリフェラルとは違い仕様が複雑で何度も挫折して中断しまくったのですが、ようやく受信部分だけ完成したので解説していきたいと思います。

参照するコードは自分が組んだコードでもよかったのですが、送信部分がバグっているのと、参考にしてきたstm32-ethクレートのほうがずっと出来が良いのでそちらを使います。
なお、バージョンはv0.1.2とちょっと古いバージョンです。なお、stm32-ethのこのバージョンではいくつか立てる必要のないと思われるビットを立てていたりするので、実際に参考にする場合はちゃんと仕様書で確認しながら見ることをおすすめします。

仕様書を入手する

仕様書なくしてドライバはつくれません。今回はCPUのリファレンスマニュアルとデータシート、ボードのユーザーマニュアルがまず必要です。
更に、イーサネット通信は外部物理層(PHY)に対してIEEE 802.3で定義されているインターフェスを介してCPUのイーサーネットモジュールと通信することで実現されています。
このPHYに関するマニュアルも必要です。ユーザーマニュアルによるとこのボードではLAN8742A-CZ-TRを使っているとのことなので、これの仕様書も入手しましょう。

セットアップ

イーサネットモジュールを使うにはまずGPIOやクロックの供給などの初期設定をする必要があります。
GPIOのピンのうちいくつかを適切なAlternateモード設定するのですが、リファレンスマニュアルの方の33.3章のTable 185にピンの対応関係が書いてあります。
しかし、これをよく見ると複数のピンに同じ機能が割り当てられているのがわかると思います。実はボードのユーザマニュアル6.11章にこのボードで利用可能なマッピングがちゃんと書かれています。
なので、ピンの設定はボードのマニュアルを参照しましょう。
Alternateモードの11がイーサネット用のモードです。設定方法は8章のGPIOの章を参照しましょう。

PHYとの通信方法はReduced Media-independent Interface(RMII)と呼ばれる方法を使います。これはMIIより少ないピン数で通信できる方法です。
リファレンスマニュアル33.4.4で書かれているように、RMIIを使うにはSYSCFG_PMCレジスタで23ビット目を立てる必要があります。

クロックの供給はSYSCFGとイーサネットモジュール、使うGPIOに対して行う必要があります。GPIOはA,B,C,Gを使います。

stm32-ethでこれらのことをやっているのが、src/setup.rssetup関数とsetup_pins関数となっています。

イーサネットモジュールの初期化

続いてイーサネットモジュールの初期化を行います。stm32-ethではsrc/lib.rsEth::initに相当する部分です。
この初期化には送信に関係する設定もおこなっている場合があります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
fn init(&mut self) -> &Self {
self.reset_dma_and_wait();

// set clock range in MAC MII address register
let clock_range = ETH_MACMIIAR_CR_HCLK_DIV_16;
self.eth_mac.macmiiar.modify(|_, w| unsafe { w.cr().bits(clock_range) });

self.get_phy()
.reset()
.set_autoneg();

// Configuration Register
self.eth_mac.maccr.modify(|_, w| {
// CRC stripping for Type frames
w.cstf().set_bit()
// Fast Ethernet speed
.fes().set_bit()
// Duplex mode
.dm().set_bit()
// Automatic pad/CRC stripping
.apcs().set_bit()
// Retry disable in half-duplex mode
.rd().set_bit()
// Receiver enable
.re().set_bit()
// Transmitter enable
.te().set_bit()
});
// frame filter register
self.eth_mac.macffr.modify(|_, w| {
// Receive All
w.ra().set_bit()
// Promiscuous mode
.pm().set_bit()
});
// Flow Control Register
self.eth_mac.macfcr.modify(|_, w| {
// Pause time
w.pt().bits(0x100)
});
// operation mode register
self.eth_dma.dmaomr.modify(|_, w| {
// Dropping of TCP/IP checksum error frames disable
w.dtcefd().set_bit()
// Receive store and forward
.rsf().set_bit()
// Disable flushing of received frames
.dfrf().set_bit()
// Transmit store and forward
.tsf().set_bit()
// Forward error frames
.fef().set_bit()
// Operate on second frame
.osf().set_bit()
});
// bus mode register
self.eth_dma.dmabmr.modify(|_, w| unsafe {
// Address-aligned beats
w.aab().set_bit()
// Fixed burst
.fb().set_bit()
// Rx DMA PBL
.rdp().bits(32)
// Programmable burst length
.pbl().bits(32)
// Rx Tx priority ratio 2:1
.pm().bits(0b01)
// Use separate PBL
.usp().set_bit()
});

self
}

まずは、DMAのソフトウェアリセットをかけています。DMABMRレジスタのSRビット(ビット0)をセットするとDMAコントローラのソフトウェアリセットになり、
リセットが終了すると自動的にクリアされるのでそれを待ちます。

次にPHYモジュールとアクセスするための設定をしていきます。まずは、MIIでPHYのレジスタにアクセスするための下準備として、MACMIIARレジスタのビット4:2でクロックの範囲を指定します。
データシートの3.31章によると、25MHzで動作するようなので、0b010にセットすればよさそうです。
PHYモジュールのレジスタアクセスは、MACMIIARに読み書きしたいレジスタ番号と読み書きのモードを指定してMACMIIDRに書き込みなら自分でデータを書き込み、読み込みならばこのレジスタに値がPHYから書き込まれます。
動作の完了はMACMIIARのMBビットがクリアされることによりわかります。

具体的なPHYモジュールのレジスタの説明はLANの仕様書の4.2章からたどることができます。
Basic Control Registerの15ビットをセットすることでソフトリセットをしたのち、12ビットと9ビットを立てることでAuto-Negotiationを有効にすることで、
ハードウェア側で勝手にパラメータ調整を任せることができます。
そのあと、PHY special control/status register(31)の12ビットが立っていれば、auto-negotiationが完了したことがわかります(が、stm32-ethでは確認していないようです。いいのかな?)。

あとは、ペリフェラルのMAC側とDMA側の設定をおこなっていくことになります。
MACCRのCSTF・FES・DM・APCS・RD・RE・TEビットを立て(RDビットはDMビットを立てて全二重モードにしているのでおそらく無視されている)、MACFFRのRA・PMビットを立て、MACFCRのPTフィールドでポーズ時間を設定し、DMAOMRでDTCEFD・RSF・DFRF・TSF・FEF・OSFを立て、DMABMRのAAB・FB・RDP・PBL・PM・USPフィールドの値を設定しています。
これらのフィールドすべてを解説するのはしんどいので、マニュアルを参照してください。

受信用ディスクリプタとバッファの用意

イーサネットのデータはDMAを介してメモリに読み書きされます。そのためのディスクリプタと呼ばれるメモリ領域を確保しないといけません。また、送信されてきたデータが書き込まれるバッファも必要です。これらをリングバッファとしてDMAは利用します。
リファレンスマニュアルでは33.6.8で解説されています。今回はNormal Rx DMA descriptorsを使います。

stm32-ethではsrc/rx.rsRxRingという構造体がこのリングバッファを抽象化したものです。RxRingEntryが各ディスクリプタとそれに対応するバッファを持っています。
src/lib.rsEth::newでこれらの初期化をしたのち、DMAレジスタに値を書き込んでこのリングバッファを使うようにしています。

初期化処理で必要なのは

  • RDES0のOWNビットを立てることで、DMA側にディスクリプタの所有権を譲る
  • RDES1のRBS1でバッファのサイズを指定し、RCHビットを立ててセカンドアドレス連鎖を有効化しておく
  • RDES2にバッファのアドレスを登録
  • RDES3に次のディスクリプタのアドレスを登録する。リングバッファの最後のエントリの場合、RDES3は設定せず、RDES1のRERビットを立てる

です。またディスクリプタは8バイトにアラインされている必要があります(ワードアライン)。

アラインメントを実現するために、stm32-ethではalignedというクレートを使ってアラインメントを保証しています。
また、RxRingEntryにはバッファとディスクリプタが対となって格納されていますが、必ずしも対になっている必要はなく、RDES2にきちんとアドレスを格納しておけば基本的にRAMのどこでも構いません。

DMARDLARレジスタに先頭のディスクリプタのアドレスをいれ、DMAOMRのSRビットを立てれば、受信用のDMAの設定は完了です。

DMAからデータを受信する

DMAからデータを受信してみましょう。本来は正しく設定して、データが来る毎に割り込みを発生させて処理させるのがいいのでしょうが、今回はポーリングで行きます。
RxRing::recv_nextを見てみましょう。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pub fn recv_next(&mut self, eth_dma: &ETHERNET_DMA) -> Result<RxPacket, RxError>
{
if ! self.running_state(eth_dma).is_running() {
self.demand_poll(eth_dma);
}

let entries_len = self.entries.len();
let result = self.entries[self.next_entry].take_received();
match result {
Err(RxError::WouldBlock) => {}
_ => {
self.next_entry += 1;
if self.next_entry >= entries_len {
self.next_entry = 0;
}
}
}

result
}

まずはDMASRのRPSフィールドをみて受信処理状態を見ています。
もし、実行中になっていない場合は、DMARPDRに1をセットして受信ポールを要求します。

take_receivedは受信が完了しているエントリを取り出すメソッドになっています。
受信完了すると、RDES0のOWNビットがクリアされCPU側に渡されたことが示されています。
また、FSビットとLSビットをみてこのディスクリプタに対応するバッファにすべてデータが入っているかを確認しています。
バッファの長さより長いデータが来た場合は通常は次のエントリに続きのデータが格納されています。
しかし今回、バッファのサイズはデータシートにかかれているVLANフレームの最大長の1522バイトなので、2つのディスクリプタにデータがまたがることを想定しないつくりになっているようです。

take_receivedRxPacketという構造体が返されていて、Derefによって、データが格納されたバッファへのスライスへの参照に型強制させることにより、読み込みが可能になります。
RxPacketからはディスクリプタやデータが格納されていないバッファへの操作はライブラリ外からはできないようになっている、というわけです。

エントリを使い終わったら本来であればOWNビットを立て直すことでリングバッファに復帰させる必要がありますが、recv_next内ではこれからバッファのデータを読み込むわけなのでそれができていません。
ではどうするかというと、DropとしてRxPacketがライフタイムを終えるとOWNビットが立てられるようになっています。

1
2
3
4
5
impl<'a> Drop for RxPacket<'a> {
fn drop(&mut self) {
self.entry.desc_mut().set_owned();
}
}

これぞRustの力、という感じでこのあたりの設計は非常に参考になりました。

テスト

受け取ったパケットをシリアルで垂れ流す、という方法でテストしました。
適当にLANにつなげば何かしらのパケットは流れてくるし、そうでない場合は、pkttoolsなどを使いわかりやすいパケットを流せばいいと思います。

以下はstm32-ethを使ったサンプルコードです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#![no_std]
#![no_main]

use stm32f4xx_hal::{
gpio::GpioExt,
stm32::Peripherals,
serial::config::Config,
serial::Serial,
stm32::RCC,
rcc::RccExt,
time::{Bps, U32Ext},
};
use stm32_eth::{Eth, RingEntry};
use cortex_m_rt::entry;
use core::fmt::{self, Write as FmtWrite};
extern crate panic_halt;


#[entry]
unsafe fn main() -> ! {
let p = Peripherals::take().unwrap();

// Setup pins and initialize clocks.
let gpiod = p.GPIOD.split();

stm32_eth::setup(&p.RCC, &p.SYSCFG);
let gpioa = p.GPIOA.split();
let gpiob = p.GPIOB.split();
let gpioc = p.GPIOC.split();
let gpiog = p.GPIOG.split();
stm32_eth::setup_pins(
gpioa.pa1, gpioa.pa2, gpioa.pa7, gpiob.pb13, gpioc.pc1,
gpioc.pc4, gpioc.pc5, gpiog.pg11, gpiog.pg13
);
// Allocate the ring buffers
let mut rx_ring: [RingEntry<_>; 8] = Default::default();
let mut tx_ring: [RingEntry<_>; 2] = Default::default();
// Instantiate driver
let mut eth = Eth::new(
p.ETHERNET_MAC, p.ETHERNET_DMA,
&mut rx_ring[..], &mut tx_ring[..]
);

let rcc = p.RCC.constrain();
let clocks = rcc.cfgr.freeze();
let pd8 = gpiod.pd8.into_alternate_af7();
let pd9 = gpiod.pd9.into_alternate_af7();
let config = Config::default().baudrate(115_200u32.bps());
let mut serial = Serial::usart3(p.USART3, (pd8, pd9), config, clocks).unwrap();
let (mut tx, _) = serial.split();

loop {
if let Ok(pkt) = eth.recv_next() {
for p in pkt.iter() {
tx.write_char(*p as char).unwrap();
}
}
}
}

感想

仕様書だけでは何をすればいいのかを読み解くのが大変で、他の実装を参考にしながら手探りで実装していったのでそれなりに大変だった。
送信部分もこみで解説するつもりだったが、受信部分だけでもそれなりのボリュームになったので、まあ、これはこれでいいかな、と思っている。
近日中に送信部分も解説記事を書きたい。

ISUCON10決勝に参加しました

ISUCON10予選でなんと勝ち抜くことができたので、決勝に参加しました。
チーム名は「勉強不足の分は有り余る才能でカバーしようかなと思っております」で、チームメイトは@kenkooooさんと@GolDDranksさんでした。
今回もフルリモートでの参加になりました。
決勝に残ったチームとしては唯一のRustを使うチームになりました。

まだ、公式には点数が公表されていませんが、おそらく再起動試験で落ちて失格扱いだと思います。
再起動試験に通っていても競技中の最高スコアは11411点程度だったので入賞には程遠かったと思います。

今回は学生の一人チームが優勝したということでかなり驚いています。去年の予選は一人で参加して本当に何もできなく、
今回の決勝も対処すべき問題がいろいろと散りばめられているので、一人で全部なんとかしてしまうのは本当にすごいと思います。

当日やったこと

自分の視点からやったことを振り返ります。

8時半頃:起床。今回は前日にちゃんと睡眠がとれてコンディションはよいように感じた。

10時の競技開始前まで:朝食、朝のリングフィット、出前や飲み物などの準備

10時:今回は予定通りに競技がスタート。運営の皆様、お疲れ様です。

いつもどおりansibleスクリプトを走らせたり、マニュアルをチェックしたり、サーバーの構成を確認したり。
Ubuntu20.04になっていたことでうまく動かないところもあったが、なんとか修正してセットアップ。

今回はMySQLのバージョンも8にデフォルトでなっている他、サーバーはいつものnginxではなくenvoyになっているのでアクセスログのセットアップにかなり手間取ってしまった。
また、サーバーは3台与えられたが、スペックがそれぞれ異なり1台目は1GBのメモリに2コアのCPUだったが、2台目は2GBのメモリが、3台目は4コアにと増強されていた。
メモリが少なくてansibleスクリプトを走らせながらだとベンチマークが通らなかったりした。
とりあえず、メモリが多い2台目をメインのサーバーとして使うことにした。
Rust実装に切り替えてスコアは7000点程度という感じだった。

12時頃:アクセスログが取れるようになり、ボトルネック分析ができるようになる。
その間、@GolDDranksさんがダッシュボードAPIのキャッシュを導入したり、@kenkooooさんがチーム上限をいじったりしていた。
チーム上限をいじって異常に負荷をかけるとenvoyが途中で落ちてしまい、ベンチマークが動かないことがわかったので、下手にいじらないことにした。

12時半頃:SQLのログを見て適当にindexを張ってみる。今回は予選での反省を活かし、pt-query-digestを活用して改善が見込まれそうなクエリを探していった

13時15分頃:サーバー複数台の準備。コア数の多い3台目をアプリケーションに、2台目をSQLサーバーにという構成にしてみる。

実は初期実装に微妙なバグがあって、SQLサーバーのホスト名の参照すべき環境変数が間違っておりちょっと手間取る。
ベンチを回してみたところ、2台目をアプリケーションに、3台目をSQLサーバーにしたほうがよさそうだったので、とりあえずそういう構成に。
最終的にスコアが9928まで上がる。

14時50分頃:ダッシュボードAPIのキャッシュが導入されるもベンチは通らず。キャッシュの生存判定が緩すぎたっぽい

15時10分頃:Rust側でgzipを有効化。スレッド数の変更も試したが、かえってスコアが落ちるのでやめておく。
SQLのログを見ていると、コンテスト情報をとってくるためのSQLが頻繁に呼ばれているので、それをアプリケーション側でなんとかすることを思いついたので実装開始。

16時半頃:ダッシュボードAPIのキャッシュの生存判定をいじって1万を突破

16時50分頃:自分のコンテスト情報をアプリケーション側でキャッシュする実装が導入される、が、あんまりスコアが伸びない。
ダッシュボードAPIのキャッシュが効いて、そもそもこのSQLがそんなに呼ばれなくなったことが原因か

17時頃:3台目が余裕がありそうなのに対し、2台目がキツキツだったので、構成を再度逆にしようとする。
が、ベンチを回してみたところ、サーバーがフリーズ。メモリが枯渇したらしい。運営にサーバーを強制的に再起動してもらって、その後スワップメモリを導入しておく。

17時20分頃:@GolDDranksさんが一部のSQLのロックの除去を試みるも、ベンチ通らず。
サーバー構成変更も原因不明のエラーが出てしまってうまくいかなかったので、諦めて再起動試験やログを切るなどの最終調整に移行する。

17時50分頃:ベンチマークガチャを、と思い何回か回すが、直後に今回は追試でのスコアが最終スコアということを思い出し、無意味ということに気がつく。
とはいうものの、もうやれることもないので、撤退。最高スコアは11411点でした。

20時頃:結果発表。特に賞はもらえず。

後にコンテストサーバーに再度ログイン可能ということで調べたのだったが、なぜかブラウザからの動作確認ができないことに気がつく。
原因を追跡すると、自分の導入したコンテスト情報のキャッシュロジックが間違っていることに気がつく。
このキャッシュロジックは/initializeをするときにキャッシュに値を挿入するのだが、アプリケーションを再起動するとこのキャッシュの値は当然消える。
ベンチマークを実行している間はアプリケーションは再起動しないし、絶対/initializeが最初に呼ばれるのでエラーは起きないのだが、
今回の再起動試験は、サーバーを再起動させた直後ブラウザからの動作を確認する、というものが含まれていて、ブラウザから動作確認する場合は/initializeは当然呼ばれない。
そのため、サーバーがコンテスト情報をDBから取得できずにエラーを返してしまうため、おそらく失格になったと思われる。

感想・反省

再起動試験に落ちなかったとしても、1位が4万点台のスコアを叩き出していることを考えると、優勝までは程遠かった、ということがわかる。
サーバーは2台しか活用できなかったし、それもスペックをフルに活かせていない構成だったので、このへんの構成を変えただけでももっとスコアは伸びたと思う。
DBの詳細なチューニングも十分にする時間はなく、かなりやることが多いな、という印象でした。

envoyに振り回されたところもあるので、nginxなどある程度経験のあるものへの切り替えも視野に入れるべきだったかもしれません。が、切り替えられるほど習熟しているかどうかも怪しく、踏み切れませんでした。

戦略もあんまり正しくなかったかもしれません。もうちょっと俯瞰的にどこがボトルネックになっているかを手を動かす前に詳細に分析してあげることも必要だったと思います。
例えば、今回のアプリはAPIアプリとベンチマーカーの2つの構成となっていたのですが、このうち片方を別サーバーにしてあげるとかでも負荷分散ができたはずです。
どうやったらサーバー3台を活かすことができたのかがおそらく勝負の鍵だったと思うので、DBを分ける、程度しかできなかったのは敗因として大きかったと思います。

あとは、マニュアルをもっと読み込んで、再起動試験を真面目にやるべきでした。再起動後のベンチマーク試験のパスは確認していたのですが、ブラウザからの追試は完全に抜けていました。

優勝したチームは一人で全部やったらしいですが、そういう超人でない以上、チーム内での役割分担とかはもうちょっとなんとかできたかもしれません。
ただ、即席チームで決勝までこれたのはかなりよかったのかなあと思います。
来年も機会があれば、なんらかの形で参加したいです。

最後に、毎年このコンテストを開催してくれる運営の皆様、ありがとうございました。今回はRustという自分が好きな言語での実装が提供されてとても楽しかったです。

ISUCON10 予選参加記(予選通過しました)

ISUCON10にチーム「勉強不足の分は有り余る才能でカバーでカバーしようかなと思っております」で@kenkooooさんと@GolDDranksさんと参加して、予選通過しました。
一昨年は、違うメンバーとですが出場したものの予選敗退、昨年は一人でNode.jsで参加するもののGoの参考実装のスコアすら超えられない悲惨な結果でした。
今回はRustでの参考実装が提供されそうだったというのと、Go言語を今更勉強するのもなあという気持ちもあったところに、
@kenkooooさんがRustを使うチームメイトを募集していたので誘いにのり、Rustにしました。

事前準備

事前練習としてISUCON9の予選問題をGoからRustに切り替えつつ最適化する、という練習をしていました。
ここでactixやsqlxの使い方を勉強しつつ、ISUCONで必要になる計測やチューニング方法の確認をしました

計測ツールとしてはnginxのログをalpで解析する方法と、NewRelicの使い方を確認しました。
また、CPU使用率やメモリ使用率などをリアルタイムで監視できるnetdataも用意しておきました。

デプロイの手順も確認しておいて、チームメンバー間でのコードを共有はgithubのレポジトリを介してやりましたが、サーバーへのデプロイは手元のビルド生成物をrsyncでデプロイする形式をとりました。
理由としてはコンパイルがそれなりにサーバーに負荷をかけること、サーバーに入ってgit pullしたりするのはいろいろと面倒な場面があることが上げられます。

/etc以下のファイルはetckeeperで管理しておきます。サーバーごとに変えたいとか権限の問題もあるので、特にリモートレポジトリでの管理はせず、ダイレクトに編集します。

事前にサーバーの環境はUbuntuであるとわかっていたので、例年通り自前のansibleスクリプトで各種ツールの導入や基本的なbashrcやvimrcの導入もできるようにしておきました。

当日の流れ

自分の視点から、当日の流れはこんな感じになった。

8時位:起床。入眠失敗してかなりつらい

9時位:PCを起動して支度をしていると、開始が2時間延期になったことを知る

12時位まで:リングフィットしたり、AtCoderの問題といたり、トイレの便座カバーを洗濯したり、お昼の出前をとったり

12時20分:競技開始。しかし、ポータルの不具合によりすぐにはベンチマーカーは走らせられない状態とのこと。

事前に用意しておいたansibleスクリプトを走らせて基本的な設定は済ませつつ、ゆっくりとコードを読んだりマニュアル読んだり、開発環境を整えたり。
各サーバーにsshできること、データベースにも接続できることなどを確認。
@kenkooooさんがgithubにウェブアプリのコードを共有し、各エンドポイント用の関数を別ファイルに切り出すことで編集がしやすいように手配

13時15分頃:ベンチマークが走らせられるようになる。

初回実行時のスコアは537
ベンチマークを走らせた結果をnginxのログからalpでどのエンドポイントがボトルネックになるかを観察。
/api/estate/searchなどは回数が多いが、一回の実行あたりの時間が長そうなのは/api/estate/nazotteだな、などとあたりをつける。

自分は/api/estate/searchのコードを見るものの、例年みたいにN+1問題のような感じでもなく、純粋にSQLクエリが重いだけっぽい。
ということで、インデックスを適当に張ってみたり、MySQLの設定を見直すということに注目する。

14時35分頃:@kenkooooさんの手によって、server2をMySQL専用にして、server1をウェブアプリ専用にする設定が導入される。server3はこの段階では使ってない。

ポータルが不安定だったり、nginxのログ設定がうまくいってなかったり、アプリケーションの挙動の確認などで、ここらへんはわりとあたふたしている。
@GolDDranksさんがボットフィルター入れたりなどの変更はあるものの、大きなスコアの変化はなし。

15時45分頃:nginxの設定を見直してみる。

静的ファイル配信周りに最適化の余地がありそうだったが、よくよくレギュレーションを見ると、/api以下のパフォーマンスしか見られないらしいのでスルーする。
gzipとかが有効になってなさそうなので、Rustアプリ側で圧縮を有効化してみたり、ワーカーの数を4にしてみたり。
ワーカー数はデフォルトだとCPUの数に等しくなり、今回は1コアだったのでワーカーが1つのみになるので、これだとIO待ちとかが有効活用されないのでは、と思って4にしてみた。
が、この辺の変更でもスコアは大きくは変わらず。500点台が続く

16時10分頃:@kenkooooさんのnazotteの変更が入る。

この辺からスコアが伸び始める。インデックスを張ったりすることで700点台に入る。
この段階でベンチマークを回すときにnetdataのダッシュボードを見るとMySQLサーバーのCPU使用率が非常に高いことに気がつく。一方でアプリ側は余裕がある。
メモリの使用率も全体としてはまだ余裕がある。この辺に注目すればスコアが伸びるのでは、と予想をつける。

16時50分頃:DB2台体制の準備に取り掛かる

MySQLサーバーのCPU使用率がボトルネックになってそう、ということは、サーバー数を増やせばスコアが伸びるのでは、と考えた。
今回のクエリはテーブル間でJOINすることでスコアが伸びることはなさそうだったのと、テーブル数はたったの2つのみ。
ということは、各テーブルごとに専用のサーバーを立てれば、比較的容易に負荷分散ができそうだ、ということに気がついて実装に取り掛かる。

その間に他のチームメイトはアプリケーション側でデータをキャッシュすることなどで高速化を測り、スコアが1000点台に乗り始める。

18時半頃:使ってなかったserver3を追加してDB2台体制が整う。

この時点でのスコアは1775。一気に伸び始める。

18時50分頃:MySQLの設定を見直していたら、実はクエリキャッシュがきいていないことに気がつく

query_cache_sizeがデフォルトで正の値が設定されていたので、てっきり有効になっていると思ってましたが、
query_cache_typeがデフォルトだと0に設定されて無効になっていました。1に変えて有効化すると、なんとスコアが2465に一気に伸びる!
キャッシュサイズが大きすぎるとCPUの負荷も上がるかもなあと思って、大きくは変更しませんでした。

19時頃:時々、ベンチマークが落ちるようになる

アプリケーション側のキャッシュロジックがかなり怪しかったので、ここを他のメンバーが見直しつつ、自分はSQLの設定をいじったり、インデックスの貼り方を見直したり。
MySQLTunerというのを使ったんですが、どうもサーバーが貧弱すぎるせいか、まずRAMを増設せよ、みたいなアドバイスが出てきたりして、あまり活用できませんでした。
インデックスの仕組みも自分があまり勉強したことがなかったため、適当な複合インデックスを貼ったりはしていたのですが、果たして効果があったかはよく検証できませんでした。

20時頃:@kenkooooさんが離脱

まだ、アプリが不安定な状態が続いていたので、アプリケーション側のキャッシュロジックを全部取り外すことに。
一時的に3000点台も記録しましたが、キャッシュロジックを取り除くと2500点付近まで落ちてしまうことに…
とはいうものの、残り時間もわずかで、凍結前のスコアボードを見るに、この点数でも十分に決勝に残れそうだったのでこのままいくことに。
本当は再起動試験を真面目にやりたかったのですが、再起動の手順を確認しておらず、万が一再起不能になると運営からの救済も厳しい時間帯だと思ったので、行わないことに。
ただ、設定はすべてファイル経由でおこなっていたし、systemdのRust側のサービスのみが有効になっていることだけを確認はしておいたので、まあ大丈夫だろうと。

netdataなどを落としたり、ログのレベルを落としたりして、スコアガチャの時間に。2684点が終了10分前あたりに出て、ここで作業ストップ。
不必要なsshコネクションを落としたりして、天命を待つことに。

24時頃:結果発表。予選通過!

正直、今までの結果もそこまで良くなかったので、まさか通過できるとは思ってませんでした。
チームも即席で、チーム内のコミュニケーションもすべてテキストチャットで不安な面もありましたが、意外となんとかなりました。
この手のプログラミングコンテストでここまでいい成績を残せたのははじめてな気がします。
本戦も引き続きがんばっていきたいです。

反省

良かった点

  • テキストチャットのみでも比較的コミュニケーションはとれる
    • ボイスチャットだと不必要なインターセプトも入る可能性があるので、テキストのみというのは意外と悪くないかも
    • 個人の好みや場面に依存はすると思う
  • エンドポイント毎にファイルを切り出すのはよかった。見通しがよくなる
  • netdata等の監視ツールは大事。ansibleなどで自動で入れられるとすごく楽
    • NewRelicも用意はしていたが、今回は活用できませんでした
  • お昼ご飯はとても大事。今回はスポンサーである出前館を利用として、出前を注文しておきました
    • 参加費と思って、ちゃんといいものを食べましょう

悪かった点

  • 再起動試験の手順はもっと早くに確認しよう
  • MySQLのインデックスについてなど、ちゃんと勉強しよう
    • 5.7では降順インデックスは存在しないらしい。8では存在する
    • Generated Columnをソート用に使うというテクニックもあるらしい
      − 他にも不要なSELECT FOR UPDATEを取り除ける、というのも見逃していた
      − 前日の睡眠はきちんととりましょう…

9/14 追記

チームメイトの@kenkooooさん視点の参加記です。

他のチームの参戦記もいろいろ読ませてもらって、自分たちのできてなかった改善点はだいたいこんなところだと思います

  • 実行結果をアプリ側でキャッシュする
    • low_pricedが主か。自分たちもやろうとしたが、バグらせてしまい断念
  • MySQL 8.xの使用
  • nginxのclient_body_buffer_sizeの調整
    − nginxのログをちゃんと見ると警告が出ていたらしい
  • MySQLの真面目な解析
    long_query_time=0として全部スロークエリとしてログに吐かせたあとpt-query-digestで解析するといいっぽい
    • EXPLAINを使うとインデックスがちゃんと効いているか確認できる
  • recommended_estateWHERE句の最適化
    • 椅子の高さ・幅・奥行きのうち下位2つのみを取り出せば、ORによる連結が減らせる。GENERATED COLUMNを活用すれば、さらに減らせる

その他、他のチームの参戦記は以下の公式のブログにまとめられるそうです。

今回、はじめて初期実装としてRust言語が追加されたことに関しては運営の皆様とそれに協力してくださったボランティアの方に深く感謝します。
初期実装がない状態だと敗戦濃厚でした。また、実装自体もちゃんとしていて不利になることがありませんでした。

一方で、今回の大会ではポータルの不調が例年に比べると目立ち、多くのチームが混乱しているように見えました。
自分たちもキャッシュロジックの追加時、ポータルの不調に出会い、アプリが悪いかベンチマークが悪いかでかなり混乱してしまいました。
アクセスができない時間帯もありましたが、僕個人としては、そういう時間は冷静に次に何ができるかを見直せる時間になったりしたので、大きくは影響しなかったのかなと思ってます。
特に今年は予選が一日に集中している関係でかかる負荷が想定よりも重かったなどの事情もあったのでしょうか。
この点は少々、残念ではありましたが、毎年多大な労力をかけて開催していただけることは非常にありがたいと思っています。
運営の皆様には改めて感謝の気持ちを伝えたいと思います。ほんとうにありがとうございます。