エンジニアが給料を12倍にする方法

はてブの人気エントリーに日本のエンジニア達は海外に出なければいけないという記事があった。 カナダ在住で経験年数4年のソフトウェアエンジニアで年収1600万円の方らしく、 日本より海外の方がソフトウェアエンジニアの給料が一般に高いので海外に行くべきという話が書かれている。

実際僕も居住地域による給与差を利用すべく渡米し、先月の記事 では新卒から数えて8年で年収が12倍になっていた話も紹介した。 一方、年収1600万円であれば海外に出なくても稼げると思っているので、 国内にいてもできそうなものも含め、ソフトウェアエンジニアとして給料を上げる上で過去に活用したハックを紹介していきたい。

昇給履歴

新卒入社

僕が新卒で入社した会社の当時の初年度給与は450万円だった (公開情報)。 大学の4年間はずっとアルバイトとしてソフトウェアエンジニアをやっていて、 3社を渡り歩いて時給は800〜1350円という感じだったが、それに比べると正社員というのはすごい額の給料がもらえる。 経験年数というのはビザの取得とかにも影響してきたりするので、正社員にはさっさとなってしまうのが良い。

外資転職

新卒社員として1年11か月働いた後、外資の会社に転職した。 東京のエンジニアポジションだと、この会社の最低年俸は800万円とかである (公開情報)。 新卒2年目の間に年収800万円に達していたら昇給RTAとしてはまあまあという感じがする。

転職の前に転職ドラフトに参加していたのだが、 ありがたいことに外資ではなくともこれくらいの額の指名が結構いただけ、 1000万円で指名していただけた会社もあり、そのあたりを根拠にこの転職での給与を交渉した。

外資なら、日本にいて年収2000万円の人もざらにいるので、 年収1600万円は外資であれば日本でも割と有り得る話だと思う。

買収

僕はこの会社にシリーズCの資金調達直後に入社したのだが、その1年後に会社が買収された。 この時社員からミリオネア (つまり1億円もらった人) が50人以上生まれたらしい (公開情報) のだが、多分僕もそれにカウントされてそうな程度にはお金をいただいた。 この額がどう支払われたかは公開情報ではないが、買収後は4年間在籍してるわけで、 4年で1億円以上もらえてる場合の年収は…まあそういうことだ。

有望そうなスタートアップを見つけたらなるべく早いうちに入っておくと、 日本でも一発で大金を得られるチャンスになるかもしれない。

渡米

買収で得られる収入はボーナスのようなもので、基本給にあたる部分は東京のエンジニアらしい推移を続けていたが、 渡米した段階で年収が増えて $151,491 になった (公開情報)。 当時の為替で1600万円。アメリカの就労ビザであるH-1Bビザを申請する時、 給与は最低このくらいないといけないというガイドラインがあって、 僕の地域のSenior Software Engineerの当時のPrevailing Wageがこれであったと記憶している。 なので、シニアエンジニアがH-1BでSFベイエリアに渡米すると、最低でもこの年収はもらえることになる。 今の為替だと2200万円になる。

僕の経験上、どこの会社でも従業員の現在の住所に従って給与には傾斜がかかる。 例えアメリカの会社に勤めていても、 例の記事の人のようにカナダ在住であればアメリカのNYやSFから勤めるのに比べたら給与は劣るはずだし、 日本からだとそれより更に低くなる。 逆に僕はカナダの会社にSFベイエリアから勤務しているが、多分カナダの本社周辺の人たちより良い傾斜がかかっている。

つまりニューヨークかサンフランシスコに住みましょうという話になるのだが、 どっちも治安は最悪である。その点、サンフランシスコから少し南のシリコンバレーのあたりは、 田舎なので治安が少しマシで、給料はサンフランシスコより若干劣る程度で、日本食も豊富なのもあり、 いいバランスだなと思ってそこに住んでいる。

昇進

外資だと、マネージャーにならなくてもIndividual Contributorとしてそこそこ昇進し続けられるのが普通である。 Senior, Staff, Principal, Distinguished あたりが割とメジャーなタイトルで、 タイトルがインフレすると間にSenior StaffとかSenior Principalが挟まってくる。 目の前のタスクに集中するのではなく、多くのチームをまたいだデザインやリードをやるようにしていると、 タイトルが上がる。

これらのタイトルはそれぞれ役割が異なるので、単に別の仕事として位置づけて給与に連動させない会社もあるが、 まあ普通はジョブタイトルごとに給与のレンジがあり、それはlevels.fyiを眺めればすぐにわかる。 なので、なるべくビジネスインパクトの大きいタイトルにポジションチェンジを続けると、ついでに給与も上がる。

この会社では僕はStaffまで昇進した。 その際の給与交渉の額の参考にするために他の会社のリクルーターと話したりしていたが、 このタイトルでのSFベイエリアでのスタートアップの基本給の相場は $180,000 みたいな感じだった。 今の為替で2700万円。

大企業転職

この会社で2度目のEXITチャンスが見えてきて、 僕は2億円欲しいと公言していた。 これは在籍し続ければ実現する可能性は十分にあったが、 コンパイラが書きたくなったので転職した。 それができる会社がたまたま社員1万人だっただけなのだが、 スタートアップから一転、上場済みの大企業に移ることとなった。

スタートアップだと一発当てない限りは前述の $180,000 からそれほど年収が増えないイメージだが、 大企業だと2億円みたいな夢はないかわりに、安定して高い年収が得られる。 例えばAmazonのSeniorにあたるポジションは年収 $360,300 くらいらしい (ソース: levels.fyi) ので、大体倍くらいになるということ。 ちなみにこのポジションはリクルーターに話しかけられて受けたのだが、もらったオファーも実際そのくらいの額だった。 実際にはこのオファーは蹴ったわけだが、今の為替だと5400万円で、新卒の450万円の12倍ということになる。 円安じゃなかったとしても8倍はある。

こういった相場感を適切に調べておき、複数の企業からオファーをもらっておくと、 このくらいの額がもらえるような交渉ができる。

この辺は日本にいても当てはまる話で、例えばGoogleでSeniorまで昇進できた場合、 日本オフィスでも $265,266 (4000万円) とかもらえるようだ (ソース: levels.fyi)。

Q&A

お金に執着しすぎでは?

そう思う人は多分お金に困ったことがないのだろう。 学生時代に貯金を全て親の借金の返済に使われ、 大学院進学を経済的理由により諦めたといった経験から、 貧乏に対して常に強い不安を感じ続けている。 実家は家のローンの返済に苦労しているが、 自分の家族は家も教育も不自由なく得られるようにしたい。

起業した方が稼げるのでは?

それがそんな簡単に上手くいくかはおいておいて、 僕はやっぱりコンパイラが書きたいというのが最初にあって、 経営ではなくコードを書くのに集中できるロールのままお金もいっぱいもらえる状況を望んでいる。

海外だと物価も高いのでは?

これはよくあるエアプコメントだと思う。例えば給料と出費が両方4倍になる時、手元に残るお金も4倍になることになる。 実際には、給料が12倍になった期間、例えば家賃はせいぜい4~5倍にしかなってないので、もっと残る。 僕の場合は老後は日本に帰るつもりなので、その残ったお金は低い物価の国で消費することになる。 もっと話を単純にすると、これくらい収入があると1年で数千万資産が増えるのだが、 日本にいたらそもそも額面で数千万受け取るのが大変だと思う。

海外だとレイオフされるのでは?

ビザの状況によっては割と深刻な問題だと思う。 僕の場合はもうグリーンカードを持っているのでそこは安心。これを書いた次の日にレイオフされるみたいなリスクはあるが、 普通は数ヶ月分給料がもらえるし、その間にまともな転職ができそうな程度にはリクルーティングメールは来続けてるので、 少なくとも金銭的な心配はない。個人的にレイオフされたら困るのは、自分がやりたい仕事ができなくなることくらいである。

まとめ

海外に住むと、日本語は使えないし、趣味や食事や医療などの選択肢や治安などが変わってくる。 家庭がある場合は家族にも影響がある。

「日本のエンジニア達は海外に出なければいけない」と結論づける前に、 海外に行くことで得られる給料が本当にその変化に見合うものなのか、 またそれは日本では達成できないものなのか考えておくと今後のためになる。

Re: OSSで世界と戦うために

yusukebe さんの OSSで世界と戦うために を読んで感銘を受けた。 hono の快進撃もさることながら、OSSで日本のコミュニティの外にリーチしたり、 GitHubスター数を伸ばしたりみたいな話は、 自分も10年くらい挑戦し続けているけどあんまり表に出てこない気がするネタなので興奮した。

僕はいくつかの点で上記の記事とは違う方法でOSSで世界と戦っているのだが、 その中でうまく行っているものや、良くないと思っているものなどについて紹介したい。

GitHubのスター数

OSSを始めたばかりの学生時代、GitHubのスターへの執着がもはや煩悩の域であり、 集めたスターの数を合計するCLIツールを作ったり、 同じ計算方法でランキングを作るWebサイトを作ったりした。

このサイトによると、僕の今のスター数は9000を超えている。

自作したOSSの中では、スター数が1600くらいのものが2つ、970くらいものが2つ、700-800くらいのものが3つ、 300くらいのものが2つある。GitHubのアチーブメントでStarstruck x3 (☆512) を達成するプロジェクトは量産しているが、 Starstruck x4 (☆4096) を達成するものは1つもない、という感じになっている。

この10年を振り返ってみて、正直これはあんまり上手くなかったと思っている。 というのも、☆512 のリポジトリを10個持つより、 ☆4096 のリポジトリを1つ持つ方が代表作として人々の記憶に残りやすいし、 メンテの効率も良くなるし、世界に与えるインパクトも大きくなる可能性が高いからだ。

一方で必ずしも多作が悪いということではなく、FluentdMessagePackといった Starstruck x4クラスのプロジェクトを何度も世に送り出した @frsyuki さんという完全上位互換みたいな存在もいるので、 そういうパスもあることは書いておきたい。 これどうやってたのかというのが気になりすぎて、Rubyist Hotlinksという連載では僕の回の次に古橋さんを指名して、 インタビューは収録済みで公開待ちというステータスなのだけど、とても良い話が聞けたので乞うご期待。

なぜ戦うのか

楽しいからだ。 OSSに限らず一般に、以下のような条件を満たす問題解決に取り組むのは楽しい。 *1

  1. 自分が最も興味がある分野で
  2. まだ他の人に十分解決されておらず
  3. 自分の能力や知識が活きやすい問題

そういった問題を解決するソフトウェアを書く時に、興味が一致するかわからない現在の所属企業で予算や人を集めて始めるよりは、 業務外で個人で作り始める方がよっぽど実現ハードルが低いので、そういう意味で個人開発はありがちな選択肢になる。

僕の場合はコードを秘密にして他者を出し抜きたいみたいな気持ちがなく、 むしろお互いのコードをオープンにして議論を深める環境に身を置く方が知的好奇心が満たされるため、 個人で書いたソフトウェアは基本的にオープンソースにしている。

より多くの人に使ってもらった方がより難しい問題に挑戦する機会が増えるし、当然承認欲求も満たされるので、 せっかくなら可能な限りバズらせてスターを集めて楽しくやりたいと思っている。 あと、純粋に数字を伸ばすことにこだわっていたとしても、 持続性や自分のパフォーマンスの維持のため、どっちにしても楽しさの追求は重要になると思われる。

OSSとインフルエンサー

yusukebe さんの記事にこういう話があった。

声を上げることは昨今のOSSでは強力な戦闘力になる。 我々は、なかなかこの文脈に我々は入れない。 それは英語ができないからではない。日本語で話している環境の中で英語で発信しても力にならないからだと思う。

大筋同意なのだけど、「日本語で話している環境の中で英語で発信しても力になる」ケースは普通にあると思っている。この戦闘力は本質的には「目的の環境でインフルエンサーに気にかけてもらえる力」*2 であると僕は捉えていて、英語を喋っている人の方がよくリーチするのでインフルエンサーになりやすく、そこに一方的に英語で発信するだけでは不十分で、何らかの方法でその人たちの気を引く必要があるがこれが難しい、という構造だと思っている。

やり方はいろいろあるが、海外のカンファレンスに何度も参加して仲の良い関係になるとか、そういう人たちが多く所属する会社やチームに入ってしまうとか、影響力のあるポッドキャストに出るなどして知ってもらうとか、そういう比較的難易度の高い話。*3 一度そういうコネができると、日本のコミュニティに囲まれている場所で(英語で)発信していても、 英語圏のインフルエンサーの会話の輪に入れてもらったり、自然と自分の発信を取り上げてもらえたりする。 その先は発信するネタの質の問題だと思われる。

英語のXアカウント

どちらにしても、英語で発信するのであれば投稿する内容は英語オンリーにした方がフォロワー獲得の効率は当然良くなると思われる。 僕の場合は自分の英語の練習および英語話者のコミュニティや元/現同僚とのコミュニケーションのためにXの投稿をリプライ以外は英語に倒しているのだが、 それでもフォロー/フォロワーの日本人比率は依然として高く、正直まだ日本語圏で活動してるなという感覚が強い。

僕のように英語アカウントにコンバートするデメリットは、 日本語圏で(ブログではなく普通の)ポストをバズらせたりフォロワーを獲得したりみたいなのが明らかに難しくなっている点で、 英語発信用アカウントを例えば @honojs のような形で分離するのに成功した場合はどちらのコミュニティにも効率良く発信できそうでいいなという感じがする。とはいえ、僕の日本語の細かな発信に関しては、日本語のSlackやZulip *4 で割とベラベラ話してるので、まあそれでいいかという気もする。

子ネタとして極端な例を出しておくと、Matzは日本語の投稿が多いけど明らかに英語圏の多くの人が(おそらく翻訳機能を使って)読んでいるし、 Railsコミッター四皇の @kamipo さんはアイドルや食べ物などに関する日本語の投稿が多いけど、 英語圏でかなり影響力があると思われるDHHにフォローされている。 僕にも彼らのようなパワーがあればもっと自由に発信できたのに、と思う。

英語圏へのリーチ

yusukebe さんの記事では名前が登場しなかったが、Redditは比較的敷居が低く、かつターゲット層にリーチさせやすいので便利だと思う。 ある種英語圏のはてなブックマークのような存在だけど、人気投稿に加えて新規投稿もそこそこ露出する仕組みになってるので、 アカウントを作って突然GitHubリポジトリのリンクを張るだけでも割とスター伸ばしに貢献するような印象がある。 何かスターが伸びてるなと思ったら、誰かが自分のプロジェクトをRedditで褒めてくれていた、みたいなこともあった。

まあでもやっぱり本命はHacker Newsかなと思う。 Redditだと良くも悪くもプログラミング言語などでコミュニティが分断されたSubredditを使いがちだが、 Hacker Newsだと全テックコミュニティが混ざるので人の目が多くなるし、 こちらのトップに載る方がインパクトが大きい印象を受ける。 業務時間中にRuby関係のスレに同僚がよくコメントしまくっている様子を見るのだが、 それくらい皆気にかけているということ。

僕が書いたもののうち2つが今年Hacker Newsのトップページに載ることに成功した。

どちらも僕自身でHacker Newsに掲載したものではないが、 X上で(上述したような方法でできたRuby界のコネにより)インフルエンサーによってシェアやリポストされた結果、 日本人がはてブでコメントを書くようなモチベーションで、 Hacker News上でコメントしたい人がでてきて伸びたという感じだと思われる。

神対応

issueで機能要望が上がってきた時、即日実装してクローズするみたいな生活を繰り返していると、 それだけでGitHub Sponsorになったよ、という人が出てくる程度にはそういった「神対応」にはOSSを伸ばす力がある。

それはそうなんだけど、「神」という名前がふさわしい程度に希少であるのには理由があって、 例えば複数プロジェクトでアクティブに要望が来続けると、割と簡単に1人の人間のキャパシティを超える。 僕はフルタイムで仕事を続けながら0歳児が生まれてからの1年9か月でCS修士を取るという無茶をしていたことがあるが、3歳児となった今の方が子の睡眠のスケジュールが不安定で作業時間の確保が難しくなっており、 その上で家の事務処理やプリスクールとかで発生する雑務も全てこの作業時間に押し込む結果、 業務以外でOSSをやるのはissueを書いてきた人自身にパッチを書いてもらってマージするのが精一杯という感じになっている *5

「神対応」が可能になるのは早くても子が小学校に入ってからかな、という感じだが、 まあそもそも子供と時間を過ごす方が他人のOSSのissueを実装するより楽しいし、 一番興味があるOSSは業務時間中に触れるので、現状に何か不満があるわけではない。

コントリビューター

神対応不可能性に対する解としては、それをフルタイムのジョブにしてしまうというのが多分最も持続性がある。 僕は自分が一番興味があるRubyのYJIT開発にそれを充て、 業務外のプロジェクトの開発スピードはある程度諦めることによって(OSS)ワークライフバランスを保っている。

とはいえ、自分の仕事にならなかったとしても、使う人がいるものに関しては可能な限り速く開発された方がいいので、 業務外のOSSはなるべくissueを出した人に自分で実装する手助けをしたり、 力がある人にはメンテナになってもらうなどすることで、可能な限り最大のスループットを出そうとしている。 sqldefはありがたいことにそこそこメンテナがいるけど、 sqldefxremapあたりは引き続きメンテナを増やしていきたい感じなので、興味がある人は声をかけて欲しい。

英語

東京にいたころ、隣の机にいる上司が英語ネイティブでかつ彼と比較的高頻度で話す必要があった時期があるのだが、 その1年が僕の英会話力は一番伸びた感じがあり、そこで業務に必要なラインも超えた気がする。 そういう環境にどうにか自分を突っ込むというのが一番効率がいい。

プログラミングに関わる語彙が基本的に英語と日本語で共通してる関係上、 業務に必要な会話で語彙に困ることはほぼない。 これは裏を返すと、要求語彙力的には「業務に必要なライン」は「日常会話に必要なライン」 を遥かに下回ることを意味しており、正直仕事以外の雑談は未だに理解に失敗することが多い。 それでも「OSSで世界と戦う」という要件では困ることはない。

時差

北米東海外がメインのタイムゾーンの会社にいるんだけど、 このタイムゾーンは日本とはほぼ真逆なので日本の人がMTGを持つことは実質不可能で、 チームメンバーの構成次第では一人だけ日本にいられても困るみたいなことは普通に有り得る。

まあ…普通に移住するのが良い。 短期的には円安という話もあるが、そうでなくても東京とニューヨーク/サンフランシスコの間には凄まじい給与格差がどの企業でも存在しているため、 移住するだけで少なくとも金銭的には得をする可能性が高い。

OSSで戦うために

僕の中で一貫しているのは「楽しくやる」という点かなと思う。OSSで数字を伸ばしたりお金を稼いだりすることよりも、自分が取り組んでいることを真に楽しめていて、プライベートも楽しく暮らせるみたいなことの方が大事だと思っている。

*1:僕は汎用プログラミング言語の最適化に今最も興味があり、本番環境で満足な性能のものが他になく自分がコミッターである経験が活きてくるCRubyという言語処理系で、YJITというJITコンパイラを開発する仕事に楽しさを感じる、という話。

*2:自分自身がインフルエンサーになれている場合も当然含まれる

*3:なんか当人達に読まれると恥ずかしい気がするので具体名は出してないのだが、この3つの例は全て実践しているつもり。

*4:オープンな奴だと ruby-jp, vim-jp, rust-lang-jp, prog-lang-sys-ja

*5:ところで直近少し返事も滞ってるのがあるんだけど、これは出張の後に風邪を引くというコンボが発生したため。この記事を書きたい気持ちが強すぎて今はこれを書いてるけど、明日は(風邪の症状がマシになっていれば)そのあたりに対応する。

自作PC2023: Ryzenをやめた

Ryzenはゲーム用CPUとしては特に問題ないのだが、 ソフトウェア開発においてはIntelのCPUに比べて不便なポイントがいくつかある。 日々業務で使っていてあまりにもストレスが溜まるので、CPUをIntel Core i7に変更した。

このマシンは8年前に組んだ自作PC なのだが、使っていて不便を感じたパーツを差し替え続けた結果、 今回のアップデートで全てのパーツが当時とは違うものに変わったため、 それぞれ古い方のパーツで不便だったポイントなどを紹介したい。

仕事で使う自作PC

社内のサービスをいじる時は会社から貸与されているM1 MacBook Proを使うのだが、このマシンは不便である。 Rubyのビルドは自分のLinuxのマシンに比べ2倍以上遅いし、Reverse Debuggingができるデバッガが存在しないし、 慣れたツールであるLinux perfも使えないし、Podmanを使う会社なのだが当然Linux上でDockerを使う方が便利だし、 命令幅が32bitな関係で主に64bitの値を扱うRubyのJITコードは読みづらい。

僕はOSSの開発が主業務なのだが、OSS開発はMacから自作のLinuxマシンにsshしてそこで作業している。 ssh越しにtmuxを立ち上げ、マシン間でクリップボードを同期する仕組みも自作した結果、ローカル環境がLinuxかのような快適さで作業ができている。 会社のお金でLinuxマシンを立ち上げることもできるが、 rr-debuggerや安定したベンチマーク環境が必要な関係でベアメタルなマシンが必要になりがちで、 これは高いので使う時間は最小限にする必要があるが、それが不要な手元のマシンは楽である。

使っているパーツ

現在使っているパーツの一覧はこちら。

種類 名称 値段 購入日
CPU Intel Core i7-12700KF $219 2023/10/13
CPUクーラー ID-COOLING ZOOMFLOW 240X ARGB $64 2023/07/16
ケース NZXT H7 Flow $113 2023/10/15
マザーボード ASUS Prime B760-PLUS D4 $120 2023/10/13
メモリ TEAMGROUP Elite DDR4 16GB x 4 $67 x 4 *1 2021/01/11
GPU ZOTAC GeForce RTX 3060 $550 2021/05/28
SSD TEAMGROUP T-FORCE 1TB M.2 $93 2021/08/12
電源 ROSEWILL 80 Plus Gold 750W $70 2019/11/29

合計 $1,497 *2。 RTX 3060はDeep Learningの授業で必要になって買った、 当時入手可能な中では安かったRTXで、僕の普段の用途だと過去に持ってた $50 の適当なGPUで置き換えても問題ないし、 メモリの半分もその授業のために盛った本来不要なものなので、実質 $863 で僕の開発環境は再現できそう。

8年前に組んだPCは98,316円で、 当時はこれと家賃と持株会で1か月の給料を使い切ったようだが、 円安の関係で今は年収が当時の12倍になったため、特に無理なく買えるようになった。 生活が苦しくてiMacをヤフオクした時の苦労が嘘のようだ。 円安最高!

CPU

Ryzenをやめた理由

僕の仕事はJITコンパイラの開発なのだが、この業務でよく使うツールがrr-debugger *3 とLinux perfである。 Ryzenでもこれらは使うことが一応可能なのだが、Intel CPUで使う場合に比べると、どちらも少し不便。 rrのために毎日zen_workaround.pyを叩くのだが、 こんな名前のスクリプトを日々叩かされたらRyzenに嫌気が差して当然である。 perfはLBR*4 が動かないし、Event Modifierの:upや:pppが動かなかったりする。

普通は耐えられるレベルの不便さだと思うが、業務でよく使う二大ツールでストレスを溜め続けるのも嫌だし、 Prime Big Deal Daysでかなり安かった割に性能も結構改善しそうな見込みがあったので、乗り換えを決意した。

Ryzen 7 5800X vs Core i7-12700KF

そもそもi7-12700KFは第12世代で、最新の第13世代のCPUに比べると少し古い。 2020/11/05に出たRyzen 7 5800Xに比べて、2021/11/04に出たCore i7-12700KFはたった1年分しか新しくなってないのだが、 PassMark Single Threadを見ると、17%くらい性能が改善していることになっている。

RyzenでPCを組み直したら爆速で最高になった という記事を書いた時は、趣味で開発していたRuby 3.0 MJITをNESエミュレータでベンチマークしたが、 今回も同じベンチマークを使い、今は仕事で開発しているRuby 3.3 YJITで比較してみた。

Ryzen 7 5800X Core i7-12700KF
201.17fps 287.57fps

速い! そもそも前回の記事では142.09fpsだったわけなので、 Ruby自体の性能の進歩にも喜びたいところだけど、実装同じで今回のCPUの差し替えだけで43%速くなったのもすごいなと思う。 これは元々Ruby 2だと20fpsで動く想定のベンチマークで、 Ruby 3ではこれを60fpsで動かせたら3倍速くなってRuby 3x3だねというベンチマークなんだけど、 今はRuby 3x14くらいある。

CPUクーラー

水冷クーラーを使っている。前回の記事では空冷を使っていたのだが、 空冷は良い性能のものはヒートシンクがめちゃくちゃでかく、これがケースの容量をかなり圧迫して取り回しがしづらくなるし、 それで狭くなるのに加えてヒートシンクが鋭利なことで割と手を切ったりする。

これを買い換えたのは、ちょっと負荷の高い使い方をしたらPCがクラッシュするようになったのがきっかけで、 CPUクーラーを水冷に換えたら問題が発生しなくなった。 また、水冷は省スペースな上に元々使っていた空冷クーラーより静かだったので、今後は水冷しか使わないと思う。 元々水冷を避けていたのは機器の寿命や液漏れを心配していたからだが、 このクーラーのポンプの想定寿命は6年弱で、正しく扱えば液漏れの確率も低いようだし、NZXT H1 *5 みたいに発火するのに比べたらマシな気がする。

ケース

8年間唯一同じものを使い続けた、最も長持ちしたパーツがケースである。 本当はCPUとマザボだけ買い替えるつもりだったのだが、 マザボを取り付けるネジがガバガバになってしまい寿命ぽいなと思ったのでケースも買い替えた。

r7kamuraさんyoshioriさん が使っているのを見て前々からNZXTが気になっていたので、NZXTのケースにした。 YouTubeとかでレビューを結構眺めたんだけど、NZXT H7 Flowは評判がいい。 NZXT H7については、Eliteは穴が少ない分見た目がかっこいい反面Flowと違って冷却性能が悪く音もうるさいらしいので、 穴だらけのFlowが無難と思われる。

最近のケースはマザボ側をガラスにして中身が見えるようにするのが流行っている *6 ようで、これもそうなっている。 中身が見える影響か、配線が綺麗にできるような工夫が随所にあって、それがこのケースを買って一番嬉しかったポイント。 ガラスになっている前面がインスタ映えするのは、背面にケーブルを送りまくっているだけというのがオチのようだが、背面もケーブルをまとめるための仕掛けがいい感じになっていて、背面の方も以下のようにそこそこ綺麗 *7。あとSSDのスロット*8も省スペースで良い。

マザーボード

前のマザボではUSB-Cに対応してなかったのが気になっていたので、今回はUSB-Cをつけた。 ケースも前にUSB-Cのポートがある奴にした。サイズはいつも通り、大きくて取り回しがしやすいATX。 CPUを買い替える度にソケットの互換性の都合でマザボを買い替えるはめになっているのだが、これはどうにかならんかなと思う。 メモリは前回のマザボからDDR4にしているが、次にマザボ替える時はDDR5になってまたメモリ買い直しとかありそうだし、 CPUクーラーもソケットごとの対応なので互換性なくなるリスクがある。 とりあえず単に要件を満たす一番安い奴を買うようにしている。

メモリ

最初16GBで使い始めた。それで十分な気がするが、まあ最近は32GBが人権みたいな風潮があり、何かの拍子に32GBまで増やした。 Deep Learningの授業の時、PyTorchを使っている既存プロジェクトの中に、前処理でやたらメモリを使う奴があって、 それが64GBないと足りないという感じだったので、そこで買い足した。

容量はやたら大きいが、それ以外の性能のところは値段のためにあえて妥協したスペックのものを選んでいる。 まだDDR4だし、2666MHzだし、ECCでもない。 ところで、IntelのCPUはXeonとかにしない限りはECCをサポートしていないというのが長く続いていたらしいのだが、 12世代と13世代ではi5やi7でも普通にECCをサポートしているらしい。現にi7-12700KFにもついている。

GPU

GPU *9 はRTX 3060なのだが、このシリーズが出た直後に買っていて、当時半導体がめちゃくちゃ不足していたので在庫の確保が大変だったし、 値段もめちゃくちゃ高かった。記憶が正しければ、古いシリーズの中古のRTXの方が最新シリーズの新品を買うより高い状態だった。 従って、新品入荷情報にめちゃくちゃアンテナを張ってどうにか新品を掴むというのが、一番安く買う方法だった。 もう少し安い奴もあったが、在庫を確保するのが難しすぎるのと、ある程度急ぎだったのでこれになった。

GPUの性能だけでいうとNVIDIAよりAMDの方がコスパがいいらしいのだが、 Deep Learningという用途の都合CUDAを使いたかったため、実質NVIDIA縛りだった。 NVIDIAのGPUはWaylandの対応も微妙なのだが、僕はアンチWaylandなので関係ない。

ゲームも普通に動く性能だし、これは当分買い替えの必要がなさそう。 グラボを持ってると、CPUのオンボードグラフィックが不要になるので、CPUは少し安く買える。

SSD

今どきはSSDといったらM.2一択と思われる。 このパーツもDeep Learningの授業の時、モデルの保管に1TBという大容量が役に立った。 まあそうじゃなくても、128GBのディスクは結構普通に足りなくなる印象で、最低256GBは欲しいし、 Dockerとかをそこそこ使うような開発用途には512GBくらいがちょうどいいのではと思う。

電源

電源の品質の等級にいろいろあって、80 Plus Goldはまあそこそこいい奴くらいのつもりで買った気がするが、 これが購入日が一番古い奴なので正直よく覚えていない。 買い替えたのは、マシンが起動しなくなってしまった時で、まあ寿命だったのだと思うが、 起動しない理由は完全にエスパーだった中、期待した通りちゃんと直ってよかった。

750Wは割と多めに盛ったつもりだったので、他を買い替える時もいちいち容量を再計算してなかったのだが、 Power Supply Calculator で今見積ってみたら僕の構成は600-699Wだった。十分ぽいけど、めちゃ余裕があるわけでもなさそう。

感想

8年前の記事を見ると、 およそまともなエンジニアとは思えないめちゃくちゃなことを言っている。

刺さりそうなピンに勘で適当にケーブルを挿していき、動作するまでの回数を競うゲーム。 一発では動かなくて、よくわからんけど似たようなとこに適当に付け替えたら動いた。やはりエンジニアに必要なのは運命力。

そんなことはない。 仕組みを理解し、マニュアルの読む必要があるところだけ丁寧に読み、 その通りに配線していくことで、今回は効率良く一発で起動にこぎつけた。

PCの組み立てはそれ自体がパズルのようで楽しいが、このようにスキルの上達が感じられるところも面白いポイントだと思う。

*1:バラバラに買っているので、買ったタイミングごとに値段が異なり、$67というのは平均の値段。2021/01/11: $55, 2021/05/29: $77, 2021/07/17: $68 x 2

*2:なお、複数同時に購入した時の商品あたりの税金の計算が面倒だったため、値段は全て税抜である。

*3:Reverse Debugging機能がついたGDBのフォーク。GDBにはReverse Debuggingが元々ついているのだが、これはあんまりまともに動かないし、 サブコマンドの利便性的にもReverse DebuggingをするならGDBではなくrr-debugger一択である。 言語処理系のデバッグをする時に、とりあえずクラッシュさせてからリバースステップ実行で原因を調査するみたいなのは大変便利で、 これは必須のツールと言える。M1 MacではそもそもGDBすら動かないし、LLDBにはReverse Debuggingはないので、まあMacは全然使う気にならない。

*4:Last Branch Record。僕が開発しているYJITではperf上でフレームのunwindingが動かなかった (それを可能にする変更を最近マージした) のだが、 LBRはハードウェア側でアドレスの履歴を記録することで動くため、YJIT使用時もスタックトレースが問題なく取れるという利点がある。

*5:CPUクーラーじゃないけど、NZXT H1は発火することが原因でリコールされたPCケース

*6:ガラスは割れるらしいので、実用的には普通にマイナスな気もする。 他に不便になった点としては、DVDドライブをつける場所がモダンなケースには基本ないというものがあるが、 まあ必要になったらUSB接続で外付けの奴を使う、で十分な気がする

*7:とかいいつつ大分スパゲッティな絡み方をしているが、全くリファクタリングをしてない状態がこれなので、改善はできるかもしれない。でも当分パーツ差し替える予定ないしやらないかも…

*8:ところでこれはSSDのところで解説しているM.2 SSDとは別のSSD 2つで、まああんま使ってないけどとりあえずつけてるだけなので、パーツ一覧には含めなかった (追記: なお、これにはWindowsとArch Linuxが入っていて、それぞれの環境のサポートの動作確認用に維持しており、使用頻度は大分低いものの、一応開発目的でつけているものではある。)

*9:買っているもの自体はグラフィックボードとかビデオカードとか言うのが正しいのだが、 性能的にGPU以外の部分はどうでも良いことが多いのでGPUとカテゴライズしている。

*10:はてなブログのAmazon商品埋め込み機能を使っているのだが、このパーツだけは日本のAmazonだとヒットしなかった。そのためリンクだけになっている。

YJITの性能を最大限引き出す方法

RubyのJITコンパイラYJITを開発している弊社Shopifyでは、社内で最もトラフィックが多いストアフロントのアプリにRuby 3.3 (master) をデプロイして平均レスポンスタイムが16%高速化、社内で最も大きなアプリであるモノリスにRuby 3.2をデプロイして平均レスポンスタイムが9%高速化している。他の会社でも、YJITを本番で有効にしたら高速化したという事例をちらほら目にした。

一方で必ずしも良い報告ばかりではなく、YJITを有効化したらメモリを使い切ってしまったりだとか、遅くなったみたいな報告も目に入ることがある。こういった問題は我々も多かれ少なかれ経験しており、それぞれ適切に対処することで解決できたため、その知見を共有する。*1

メモリを使い切ってしまった時

zenn.dev

YJITを有効化すると、YJITが生成する機械語に加えて、それに関するメタデータもメモリを消費する。機械語の最大サイズは --yjit-exec-mem-size (デフォルト 64MiB) で制限されるが、メタデータは特にリミットがない。ただし、メタデータサイズは生成コードのサイズに比例する傾向にあり、かつRuby 3.2の時点ではメタデータは生成コードの3~4倍程度メモリを使うと見積っておくと良い*2。従って、デフォルトではメモリは最大で256~320MiB使われることになる*3

ここで注意しなければならないのは、この値はあくまで各プロセスあたりのメモリ消費量であること。UnicornやPumaで複数プロセスを走らせる場合、ワーカーがforkする時点で存在しているメモリのページのうち、その後更新がされないものは複数プロセス間で共有される*4が、YJITのコードやメタデータに関しては基本的にワーカーのfork後に生成されるため、メモリの共有は期待できない。そのため、例えばUnicornのプロセスが16ある場合は、最悪の場合 16 x 256~320MiB = 4096~5120MiB 使うことを覚悟しなければならない。

--yjit-call-threshold を大きくする

一番簡単に試せるのはこれ。デフォルトでは --yjit-call-threshold は30で、つまり30回呼ばれたメソッドからコンパイルを開始するようになっているのだが、アプリの初期化のロジック次第では、この閾値では初期化時にしか使われないコードがコンパイルされてしまうということが有り得る。それらのメモリ消費は後で無駄になるので、この値を大きくするとメモリ使用量が大幅に改善することもある。

Shopifyのストアフロントではこれを30よりどれだけ大きくしてもそれほどメモリ使用量は変わらなかったが、20から30に上げた時はメモリ使用量が大幅に減った。あなたのアプリでは30よりもう少し大きくしないとその変化は訪れないかもしれない。なお、これを大きくすればするほどウォームアップが遅くなってしまうため、各ワーカープロセスが処理したリクエスト数合計などのメトリクスと見比べながら、程々の大きさに留める必要がある。

--yjit-exec-mem-size を小さくする

Ruby 3.2のYJITにはCode GCという機構が入った*5。これは生成した機械語のサイズが --yjit-exec-mem-size に達したら全てのコードを開放し、その後必要になったコードだけコンパイルして省サイズ化を目指すものだが、ついでにメタデータの方も開放されるため、これを小さくすればするほどメモリ消費量は抑えられることになる。

これを小さくしすぎるとCode GCが頻繁に行なわれるようになってしまうため、Code GCがどれくらい頻繁に行なわれているかモニタリングする必要がある。 RubyVM::YJIT.runtime_stats[:code_gc_count] の現在の値をRackミドルウェアなどで定期的に記録しておくと良い。Datadogとかだと DataDog/dd-trace-rb#2711 が使えるかもしれない。目安としては、これが0の場合は多少サイズを小さくする余地があり、1で安定する場合や1時間おきに1回走る程度が理想、それより頻繁に値が増える場合はサイズを大きくした方が良い。

肥大化したプロセスをkillする

ワーカー1つあたりに許容するメモリ使用量をあらかじめ決めておき、それを越えたプロセスを止めるようにするという手も有効である。YJITを使っているかに関わらず、本番でメモリリークが発生してしまった時の供えとしても役に立つ。我々は独自の実装を使っているが、OSSのものではunicorn-worker-killerpuma_worker_killerなど使えばよさそう。一方で、killの頻度が高いと速度的には悪影響であるため、killの回数もモニタリングしておくのが望ましい。

ワーカーをreforkする (上級者向け)

同僚の@byrootがUnicornのフォークであるPitchforkというのを開発した。これはUnicornと比べてレガシーな依存が一つ外れているモダンなUnicornとしても使うことができるが、それに加えて "Reforking" と呼ばれている機能が追加されている。通常、UnicornやPumaのfork時にはアプリのコードがコンパイル済みでないワーカープロセスが作られるが、Reforkingというのはアプリのコードが既にコンパイルされているプロセスを後から定期的にforkし直すことでそのメモリを複数プロセス間で共有することを目指すというもの。YJITの運用でメモリの使用量を最適化しようとしたら、これが一番効果があると思われる。

デメリットとしては、スレッドを扱うgemなどがfork-safeでないことがあり、アプリが使っているコードが全てfork-safeであることをどうにか保証しておく必要がある。具体的にはgrpc.gemがこの問題を抱えており、byrootたちがGoogleとコミュニケーションを取ってこれに対応している。それから、PitchforkはUnicornのフォークであるため、Pumaのように各プロセスでマルチスレッドワーカーを立てることはできない。

一部ワーカーのみ有効化する (Ruby 3.3以降)

Ruby 3.3では新たに --yjit-pause というフラグと RubyVM::YJIT.resume というメソッドが追加される。この2つを使うと、Ruby起動時にはJITコンパイルを無効にしておき、アプリの初期化が終わった後に手動でコンパイルを開始するということができる。それが主な想定用途なのだが、byrootの発案で、モノリスではUnicorn (Pitchfork) のワーカープロセスのうち、ワーカー番号の若い一部のみ有効化することによって、メモリ使用量を抑えている。これは、Unicornがリクエストを捌く際、全てのワーカーが均等にリクエストを処理するわけではなく、キャパシティに余裕がある場合はワーカー番号が若いものにリクエストが偏るという性質を利用している。そのため、ワークロード次第ではYJITを有効化しているプロセスの割合以上のリクエストがYJIT有効のワーカーに処理されうることになる。

Ruby 3.2にはこの機能はないのでRuby 3.3を待っていただく必要があるが、我々は独自のRuby 3.2フォークを持っており、これにはこの機能がバックポートされている。

遅くなってしまった時

残念ながら、YJITを本番で有効化したら遅くなったという話もちらほら聞く。

--yjit-exec-mem-size を大きくする

YJITを有効化したら遅くなった、と聞いたときに僕が真っ先に疑うのは --yjit-exec-mem-size が小さすぎるというもの。これが小さいとCode GCが頻繁に走りすぎて遅いというリスクが高くなる。その症状になっているかを確認するには、RubyVM::YJIT.runtime_stats[:code_gc_count] をモニタリングし、この値が頻繁に増加していないかを確認すると良い。これが0か1で安定するところまで上げれば、速度的には影響がない状態にできる。

弊社ストアフロントでは --yjit-exec-mem-size=64 を使っていて、これは十分すぎるサイズなのだが、一方弊社モノリスでは --yjit-exec-mem-size=256 を使っていて、これを大きくしてきたときに大幅に速度が改善された。これに関連して、Ruby 3.3 (master)ではこのオプションのデフォルトが128に変更されている。

ワーカープロセスをなるべく長く走らせる

僕が次に疑うのは、ワーカープロセスが定期的にkillされているような環境で、そのkillがあまりにも頻繁すぎるというもの。プロセスが長く走ればコンパイル済みのコードを再利用する機会が増え、速度的には良い影響が期待できる*6が、頻繁にkillされるとコンパイルのオーバーヘッドがかかり続けるということが有り得る。各ワーカープロセスが過去に処理したリクエスト数合計などをモニタリングしておき、その値が大きくなる前にプロセスがkillされてしまっている場合は、killの閾値の見直したり、OOMの影響を確認して割り当てるメモリを増やしたりする必要がある。

--yjit-call-threshold を調整する

関連した問題として、--yjit-call-thresholdはワーカーのリクエスト処理数に応じて調整する必要がある。--yjit-call-threshold が小さすぎると、起動時に多くのコードがコンパイルされ、ウォームアップのオーバーヘッドが一気にかかりがちになる。その一方で --yjit-call-threshold を例えば1000まで上げた時、ワーカーが頻繁に再起動されていてプロセスあたり1000リクエスト足らずでkillされてしまっている場合、1000回目のリクエストで初めてコンパイルされるパスが有り得ることになる。その場合は閾値を100くらいまで下げると、より早く、一方で早すぎずウォームアップが行なわれ、速度が改善したりする。

ratio_in_yjit を確認する

これはRuby 3.2では少し手間がかかるのだが、Rubyのconfigure時に --enable-yjit=stats をつけてビルドしておき、Rubyの起動時に --yjit-stats をつけ、RubyVM::YJIT.runtime_stats[:ratio_in_yjit] を確認すると、実行されているVM命令のうち何%がYJITで実行されているか確認することができる。ワーカープロセスがピーク性能に達した状態でこれが90%くらいあれば性能が改善していることが期待できるが、これが例えば80%を下回るなどしている場合、アプリのコードかYJITの実装のどちらかに問題があることになる。例えばTracePointがどこかで有効になっていると、ものによってはそれがVM命令を全てtrace命令に書き換えることがあり、その場合YJITはコンパイルを諦めてしまう。いずれにせよ、例えばワーカープロセスが10000リクエスト処理した後もこの値が小さい場合は、--yjit-stats を使用した状態での RubyVM::YJIT.runtime_stats の中身を丸ごとYJITチームに共有*7していただけると、よしなに対応できると思う。

Ruby 3.3ではデフォルトのビルドで ratio_in_yjit が確認できるようになっている。YJITが使える全てのRubyのビルドで、起動時に --yjit-stats をつけておけば、RubyVM::YJIT.runtime_stats[:ratio_in_yjit] が利用できる。一方で、Ruby 3.3には ratio_in_yjit を改善する様々な実装が入っており、以前は92%だったのが現在では97%まで改善していたりするので、そもそもこれを確認しなくても速くなるようになっているかもしれない。

まとめ

簡単にできるアクションのまとめとしては、RubyVM::YJIT.runtime_stats[:code_gc_count] と各ワーカープロセスが処理したリクエスト数をモニタリングし、それらや速度、メモリ使用量に応じて --yjit-call-threshold--yjit-exec-mem-size を調整したり、割り当てられているメモリのサイズやワーカーのkillの設定などを見直していただくのが良いと思われる。

これを読んで「YJITを使うのは面倒くさい」と思った人もいるかもしれないが、これらの知見を可能な限りデフォルトのパラメータにフィードバックしており、基本的にはデフォルトの設定で有効化しただけで特に苦労なく速くなる状態を目指して開発されている。また、より多くのVM命令の対応が日々コミットされ続けているので、Ruby 3.2で遅くても、Ruby 3.3にしたら速くなった、ということも有り得ると思う。

参考資料

1月に書いた(のを6月にやっと公開した)記事なので若干情報は古いが、会社のブログに書いた以下の記事も参考になるかもしれない。

*1:余談だが、こういった話のプロポーザルをRubyKaigi 2023に出していたのだが、RJITの方の話が勝ったため話しそびれていた。

*2:なお、Ruby 3.3ではメタデータのサイズがより小さくなっており、生成コードの2~3倍程度になる。

*3:メモリは仮想ページごとに必要に応じて割り当てが行なわれるため、アプリのサイズ次第ではメモリ消費量はもっと小さくなる。

*4:Copy on Writeの話をしている

*5:僕が書いた

*6:一方で長く走らせすぎるとメモリが断片化していき若干性能が減衰する問題もあるが、ここで話している問題とは別なので置いておく

*7:https://bugs.ruby-lang.org/ にチケットを起票するか、https://github.com/Shopify/yjit にissueを書くか、gistとかに貼ってX (Twitter) で僕にリプライなど

RubyKaigiでJITコンパイラの書き方について発表した

RubyKaigi 2023でRuby JIT Hacking Guideというタイトルで発表してきた。

speakerdeck.com

JITコンパイラを書くチュートリアル

今回の発表ではJITコンパイラが書ける人間を増やすことをゴールにしていたが、 30分という短い発表枠内では雰囲気を知ってもらうことにフォーカスし、 実際に手を動かしたい人たちにはそれ用のチュートリアルを触ってもらう形を取った。

github.com

JITコンパイラは実は初心者向き

独学でコンパイラの作り方を学ぶ人は、Cコンパイラなどを実装することが多いように思う。 C言語は実装対象として一見シンプルそうに見えて実は結構機能が多いので、C11をゴールに始めてもC89の範囲すら実装しきらないままエターなる人も多いのではないか。

そんな僕みたいな堕落した人間にお勧めなのがJITコンパイラインタプリタと併走する特性上、実装が面倒な入力は全てインタプリタに移譲することで、限られた労力で最適化と互換性を両立できる。 このリポジトリで作ったJITコンパイラをベースに、Railsアプリを動かしても壊れないような修正をいれることは難しくない。

YJITより速いJITを書くという体験

JITコンパイラを書いていて一番楽しいのは、やはり自分が書いた実装が既存の実装を打ち負かした瞬間だと思う。 フィボナッチは個人JIT開発者界隈でよくベンチマークに使われるのだが、 今回のチュートリアルでもこれで他のJITと競う体験をしてもらうことにフォーカスした。 リポジトリ内のヒントを素直に辿るだけで、YJITより速いJITを完成させる喜びが体験ができるようになっている。

フィボナッチ"だけ"をめちゃくちゃ速くするコンパイラは書こうと思えば比較的短期間で書けるのだが、 それなりに複雑になる反面、Railsアプリのような大きなベンチマークではその成果が労力に見合わなくなることが多い。 YJITはマイクロベンチを速くすることにプライオリティを置いていないため、今でも簡単に負かすことができるが、 その一方でRailsをYJITより速くするJITを書くのは難しい。

参照実装を公開した

元々Shopify社内で内部公開していて、自分のアカウントに移すときに参照実装のブランチをpushし忘れてリンク切れになっていたのだが、今は直っている。 どうにもバグが直せなくてギブアップという人や、動くものをとりあえず触ってみたいという方はどうぞ。

github.com

opt_send_without_blockとbranchunless

@HKDnet さんがツイートしていたが、opt_send_without_blockとbranchunlessのところは他よりやや難易度が高くなっている。 前者はRubyのメソッド呼び出しをある種自力で実装しなければならず、後者はCFG (制御フローグラフ) を作ったり循環参照のあるコード生成をしたりということが必要になるからだ。

どちらも自分で頭をひねって実装してみるのは面白い題材なのでそのままにしてあるが、難易度の傾斜的には、この二つは一旦参照実装を理解してもらって、自分の書き方で実装し直してもらうくらいがちょうどいい気がする。

アセンブラを書きたい人へ

本当はRubyKaigiの発表内でModR/Mエンコーディングとかも解説したかったのだが、いくらコアな参加者が多いRubyKaigiといえど流石にやりすぎという気もしたのと、トークの尺やスライドの準備的にも面倒だったので、opv86だけ紹介してわかったつもりになってもらう形を取った。 そこもやってみたい! という人は、lib/jit/assembler.rb を削除して、このサイトとかを参考に一から実装してみるのが良いと思う。

困ったら

Twitter @k0kubun にリプライをいただくか ruby-jp Slackの #ruby-hacking とかで質問していただければ、解説やアドバイスなどできる予定。

Mojoは「C言語のように速いPython」なのか

LLVMやSwiftを作ったChris LattnerがCEOをやっている会社が、Pythonの使用感とC言語並の性能を併せ持つ言語としてMojoをアナウンスした。 まだ手元で試せる状態でリリースされてはいないが、最大35000倍Pythonより速いという。

僕は大学院のディープラーニングの授業や仕事などでPythonはある程度使った経験があり、Pythonに似た性能特性を持つRubyJITコンパイラを書く仕事をしているので、Mojoの何がC言語並に速いのか興味を持った。Mojoローンチ動画ドキュメントを見てわかったことをまとめておく。

どんな言語なのか

Pythonとの互換性

MojoにはPythonにはない機能がいろいろとあるが、ローンチ動画では全く同じコードがCPythonとMojoの両方で動くことが何度か紹介された。これはつまり、単にMojoPythonの資産を使えるだけでなく、MojoPythonのスーパーセット言語であることをアピールしたいように見える。Pythonに対するMojoの関係は、Javaに対するKotlinではなく、JavaScriptに対するTypeScriptのようなものということになる。

一方、Why Mojoのあたりを見ていると、現状の互換性は散々で、クラスもサポートしてないみたいな状態らしい。彼らはスタートアップ企業であるから、お金や人を継続的に惹きつけるために多少の誇大広告が必要なのは理解できるが、現時点でリリースに至ってないのはまだ実際にはPythonのスーパーセットにはほど遠い状態であるからと推察できる。Python3との互換性のためにCPythonを活用しているというよくわからない説明があるが、Pythonモジュールのインポートが普通のimport文ではなく Python.import_module なのはこれがCPythonそのものを呼び出して相互に動作させているからかなと思った。

とはいえ、Python2からのPython3移行の例を引き合いに出して、Pythonのサブセットを実装することでPythonの弱点を解消するのは既存のPython資産という強みを生かせないのでダメなんだと力説しているので、少なくともPython3のライブラリが使えて、運が良ければ本当にPythonのスーパーセット言語として出てくるのではないかという雰囲気はある。

Rust風の言語機能

Rustのようにモダンな言語仕様かつ速い動的言語が欲しい、みたいな言説をTwitterで時おり見かけるのだが、Mojoはこの領域を攻めているように感じる。MojoはキャッチフレーズがAI開発向けの言語であるのと同時に、FAQではMojoは汎用プログラミング言語であると説明されている。

Rustは所有権といったセマンティクスが常に強制されるので、書いてコンパイルを通すのが面倒なことがまあまああるのだが、それらの手間をかけてスレッドセーフティや性能を追求しなくとも、アプリの特性上それが問題にならない場面はよくある。そういう場面では何の型アノテーションもない普通のPythonを書き、局所的に性能が必要な部分だけMojoの高速な書き方を使いたいみたいなユースケースは存在するように思う。

let でイミュータブルな変数を宣言したり、所有権や参照の概念があったりする。def で関数を宣言するといままで通りのPythonのコードが動くのに対し、fn で関数を宣言すると型宣言が強制され、全てのローカル変数や例外の明示的宣言が必須になる。書くのは面倒になると思うが、おそらくこれを使うとコンパイラは高速化がしやすくなるので、書きやすさと保守性や性能のバランスをユーザーが選択できることになる。

デプロイ容易性

素のPythonではダメだったモチベーションの一つに、モバイルやサーバー環境でのデプロイが困難であったことが挙げられている。サイズは何十MBにもなってしまうが、ランタイムを内包したバイナリを生成できるらしい。最近Node.jsがそれをやっている話が流れてきたが、これに関してはCPythonもやればできるのではという気もしないでもない。僕はpercolよりはpecoxkeysnailよりはxremapの方が便利だと思っているが、それはPythonを独立してインストールして維持しないといけないのが面倒くさいからで、シングルバイナリのデプロイが生活を少し楽にしてくれることはよくわかる。

なぜ速いのか

巷ではMojoがまるでC言語並みに速いPython処理系であるかのような受け取り方をしている人が見受けられるが、ローンチ動画を見た限りでは、単にPython処理系として使うとCPythonの数倍速い程度に留まり、C言語の性能には程遠い印象を受ける。しかし、何も書き変えずに数倍速くなるのはそもそも偉いし、Python風の高級言語にちょっと書き直すと35000倍速くなるのも普通にすごい話なので、それに貢献していると思われるポイントをまとめてみた。

MLIRとSIMD、並列化

端的にいうと、Mojoというのは高速なPythonなのではなく、MLIRという並列計算に長けたコンパイラ基盤を言語機能として表出させ、Python風の高級言語で簡単に扱えるようにすることでCやC++より速いコードが書ける言語、という感じに見える。マンデルブロットが35000倍速くなるのはMojoSIMDサポートを使っているからだとローンチ動画で説明されており、このSIMDMojoがMLIRを直接使えることで実現されているとドキュメントに書いてある。

CGOというコード最適化に関するカンファレンスの論文を眺めている時に、Chris Lattnerが書いたMLIRの論文を見かけたことがある。LLVMの作者である彼は、最近はLLVMよりMLIRの方に注力していると伺える。GoogleでMLIRを作ったが、MLIRを使いやすい言語がないから起業して言語作っちゃうぞ、ということだったらカッコイイなと思う*1

MLIRのMLはMachine LearningではなくMulti-Levelの略なのだが、MLIRには特定のアーキテクチャや環境に特化した最適化を可能にするdialectという概念があり、それを使ってSIMDやスレッドで並列計算することができ、LLVMと違ってMachine Learningといったドメイン固有の最適化も可能にしていると論文に書いてある。

動的な機能が制限されたstruct

次に重要そうだなと思ったのがstruct。言語機能的にはdataclassのようなものだし特段驚くようなものではないが、このstructを使うと動的なディスパッチやモンキーパッチが不可能になると書いてある。このstructを使った変数を宣言して、その変数に対してstructが実装していない処理をしようとするとコンパイル時エラーになる例が示されており、メソッドの呼び出し先が静的に解決できるのは最適化目線では非常に素晴らしいことだなと思う。

AOTとJITをサポート

僕はJIT屋さんなので、Mojoの話を聞いた時にPython部分はJITしてるのかなというところが最初に気になったが、FAQを見るとAOTとJITを両方サポートしていると書いてある。PythonのコードをそのままMojoで実行して8.59倍速になってるところは、MLIRの基盤の寄与が大きいのか言語特有のJITサポートががんばってるのかどっちなのかはわからないが、structではない(動的ディスパッチが可能な)既存のPythonライブラリのクラスをこねくり回すところでまともな性能を出したかったら、実行時情報に特化して最適化を行なうJITは必須であろうと思われる。CPythonもそろそろ本気を出して欲しい。

所感

MojoがCPythonの用途を全て置き換えていくかというとそうはならないと思うが、Pythonの既存資産が使えて、AI開発のために高速なコードが書けるというのはいいものだと思うし、何よりLLVMやSwiftを作ったChris Lattnerがやっているというのがアツいところなので、正式リリースに期待している。

言語の使用感としてはRubyの方が好きなので僕はRubyを速くして使っていくが、最適化のところで真似できるアイデアがあったら参考にしていきたい。

*1:実際にはどういうモチベーションで起業したのかは調べてない

RJIT: RubyでRubyのJITコンパイラを書いた

僕はRustでRubyJITを書く仕事をしているのだが、去年の12月くらいから、趣味ではRubyRubyJITを書いている。 それまではC言語でコード生成を行なうMJITを5年くらいメンテしていたのだが、先月、Ruby機械語を直接アセンブルするRJITに差し替えた。

github.com

なので、今Rubyのmasterブランチには、会社で業務として開発しているRust製のYJITと、僕が趣味で開発しているRuby製のRJITの2つのJITコンパイラが存在している。余談だが、JITの開発をしすぎてRubyの作者であるまつもとさんのコミット数を最近抜いた。

なぜMJITをやめたのか

MJITも結構がんばっていて、去年開発していたRuby 3.2ではMJITのコンパイラの実装をCからRubyフルスクラッチした上、バックグラウンド処理をpthreadからfork + SIGCHLDで行なうように変えたり、早い段階からコンパイルを可能な限りまとめてバッチ処理してwarmupを高速化するなど、結構手を加えていた。

しかし、MJITの改善について考えれば考えるほど、生成コードがC言語に縛られているせいでコードの動的書き換えを前提とした最適化が難しいことが気になってしまった。そこで、12/10にMJITのコンパイルのバッチ化をマージした次の日から、その問題を抱えていない設計を持つRJITに着手し始め、3ヶ月ほどかけて公開に至った。

RJITでやりたいこと

MJITのコンパイラRubyになったことで、Ruby 3.2からモンキーパッチを使ったJITコンパイラの差し替えが可能になった。これを使ってJITを開発している人が2人いて、このバックドアを残すことがRJITの主目的の一つである。僕もその2人もYJITの開発に参加している*1ので、オリジナルのJIT開発の経験をYJITにフィードバックするのが最終的な狙い。YJITを越えるのは特にゴールではないため、本番環境では引き続きYJITを使うことが推奨される。

YJITの開発ではコンパイル速度やメモリ消費量に細心の注意が払われており、ベンチマークの改善に即座に貢献しない複雑な実装はマージしないで塩漬けにする傾向にある。僕は既にYJITのシンプルなメソッドインライン化やSplittingなどのPRを用意したが、これら単体では大きな速度改善に繋らなかったため、マージされなかった。RJITはそういった試験的な実装を継続的に試せる環境にしたいと思っている。実際に成果に繋ったものはYJITにポートするようにしていて、最近もそのようなPRをマージした。

RJITのアーキテクチャ

MJITはメソッドJITだったが、RJITではYJITの実験場という目的から、YJITと同じくLazy Basic Block Versioningというデザインで作られている。それだけでなく、僕自身がYJITの仕組みを学びたいという目的もあり、codegenもほぼYJITの実装をなぞって作ってあるため、Yet Another YJITみたいな状態になっている。

一方で、アセンブラに関しては誰の実装も見ずに完全に自力で書き上げた。これは何というか、これまでC言語に依存してコード生成をしていたので "本物の" JITコンパイラを書いているという感覚がなかったので、Intel SDMを読み込んでここをちゃんと書いたことでそのコンプレックスが今回解消された。

アセンブラAPIはなるべく生成コードに近くなるようにしてあって、例えば以下のアセンブリにあたるコードは、

lea rax, [rbx + 8]
mov rdi, 0

以下のようなRuby DSLで生成できるようになっている。

asm.lea(:rax, [:rbx, 8])
asm.mov(:rdi, 0)

それから、Ruby 3.2でMJITをRubyに書き換えるにあたり、CのstructにRubyからシームレスにアクセスする仕組みを結構時間をかけて作ったのだが、これはRJITにそのまま引き継いできて便利に使っている。RustのbindgenではCとの連携機能が非常に限られており、例えばstatic inlineの関数は呼び出せないのでYJIT用に手動でラップしないといけなかったり、Cのstructのfield一つごとにそれにアクセスするための関数をYJIT用に用意して使うみたいな感じなのだが、RJITではそもそもそこを自前で作り全自動化することで快適な開発ライフを送っている。

例えば、YJITだとこのようなコードがあるが、

let stack_max = unsafe { rb_get_iseq_body_stack_max(iseq) };

RJITだとこうなる。

stack_max = iseq.body.stack_max

RJITのパフォーマンス

1日1回Rubyベンチマークを走らせて表示する rubybench.github.io というサイトをメンテしていて、 このサイトでの最新の結果は以下のようになっている。

JITなしと比較して何倍速くなったかのグラフ (棒が長い方が速い)

MJITの性能はRJITをマージした時のPRを見て欲しいが、MJITはこれらのベンチマークでは多くの場合インタプリタより遅いという状態だったので、RJITでは (ruby-lsp以外は) これだけ高速化できているというのは大分進歩した感じがする。MJITのrailsbenchでの性能は、かなりがんばってチューニングして5%速くなるみたいな感じだったが、RJITでは安定して30%くらい高速化している。

セルフホストJIT構想

RJITでRJITをJITすると再起呼び出しになったり、ありとあらゆる理由で壊れるので現在はRJITそのものはインタプリタ実行になっている。 インタプリタの性能でRust実装のwarmup速度に追いつくのは無理な話だが、RJITのコンパイラが十分に発展したら、 別プロセスでも再利用できるようなコードを生成するモードを用意して、RJIT自体をRJITでプリコンパイルして添付する、 みたいなことを妄想したりしていた。

それはそれで面白い取り組みになると思うが、RJITがrailsbenchで生成するコードがYJITとほぼ同等になった今railsbenchでの性能を比べてみると、warmup速度以前にピークタイムもYJITにそこそこ差が開いているという気付きがあった。何か実装漏れがあるとかならわかるが、僕の知る限りrailsbenchに影響しそうなところは全てポートしてきたという理解なので、やや不思議な状態になっている。そこで GC.disable をしてみたところ、性能差がかなり縮まった。そのため、RJITが生成するオブジェクトがGCに与えるオーバーヘッドがまあまあ大きいということになるが、これにアプローチする方法は今のところ思いついていない。

まとめ

僕はRubyJITを書ける仲間を増やしたいという思いがあり、RubyKaigi 2023ではそういう人を増やすためのトークをする予定。松本で僕と握手!

*1:ちなみにその2人はRubyRailsの両方のコミッタでもある