OSS活動記 #3 - reactionview - レンダリング時間を出力するPRがめちゃめちゃ良いのでさらに魔改造してクエリ発行数も追加してみた

対象リポジトリ

github.com

レンダリング時間を出力するPR

github.com

PRの内容と状況を見てみた

reactionviewのデバッグモードを使うとviewテンプレートとかpartialテンプレートで出力されたHTML要素に枠線追加して可視化してくれる。PRはこの情報にレンダリング時間を追加してくれるもの。どうやって実現しているのかが気になったので実装を覗いてみた。

herbとreactionviewを使えばHTMLのerbテンプレートに対してASTで処理ができる。レンダリング時間を計測するrubyコードを含んだerbタグを動的に追加していた。ざっくり以下のようなタグがerbテンプレートの先頭と末尾に追加されるイメージ。

<% __reactionview_timing_start = Process.clock_gettime(Process::CLOCK_MONOTONIC) %>
<% __reactionview_timing_end = Process.clock_gettime(Process::CLOCK_MONOTONIC) %>
<% __reactionview_timing_ms = ((__reactionview_timing_end - __reactionview_timing_start) * 1000).round(2) %>

さて、この計測した値をどうやってブラウザで表示される画面まで持ってくるかというところで、PRの実装では以下の流れになっていた。

  1. 計測値をスレッド固有データとして格納 e.g. Thread.current[:reactionview_timings][__reactionview_render_id] = { ... }
  2. middlewareを使ってスレッド固有データに格納したHashをJSONに変換してscriptタグに出力
  3. 画面上で動くスクリプトJSONを含むscriptタグからJSON値を回収

うーん、スレッド固有データとかmiddleware経由するとか、グローバルに近いスコープで値を移しているのが少し気になる。

実装は気になるところあるけど実現している機能はめちゃめちゃ良いのではよマージされんかなと思っているのだが、2ヶ月前にDraft PRとしてオープンされており放置されているように見受けられる。とりあえず機能を実現してみたPoC的なPRだったのかもしれない。

よりシンプルに実現できないか自分で実装してみる

middlewareを経由しなくても解決できそうな気がしたので実装してみた。PRの魔改造

以下のコミットを作ってみた: https://github.com/kozy4324/reactionview/commit/f0b72e2d161c8663632539c97dc55f75735c53cb

こういったscriptタグで解決できるのではないかという。scriptの即時実行 + rubyの値はdata属性に出力して document.currentScript.dataset から取得すればええやろ派。あと画面表示は data-herb-debug-outline-typeview or partial なもの(これがデバッグモードで枠線表示される要素になる)を探してその data-herb-debug-file-name に追記してみた。これは手抜き実装。

<script data-herb-timing-duration="3.14"
        data-herb-queries-count="0"
        data-herb-cached-queries-count="0"
        data-herb-target-debug-file-full-path="/Users/kozy4324/work/blog-app/app/views/layouts/application.html.erb">
(() => {
  const fullPath = document.currentScript.dataset.herbTargetDebugFileFullPath;
  const targetElm = document.querySelector(`[data-herb-debug-outline-type~="view"][data-herb-debug-file-full-path="${fullPath}"]`) ||
                    document.querySelector(`[data-herb-debug-outline-type~="partial"][data-herb-debug-file-full-path="${fullPath}"]`);
  if (targetElm) {
    const timingDuration = document.currentScript.dataset.herbTimingDuration;
    const queriesCount = document.currentScript.dataset.herbQueriesCount;
    const cachedQueriesCount = document.currentScript.dataset.herbCachedQueriesCount;
    targetElm.dataset.herbDebugFileName += ` (${timingDuration} ms, ${queriesCount} queries, ${cachedQueriesCount} cached)`;
  }
})();
</script>

追加でクエリ発行数も追加してみた

Rails 7.2 からログ出力にクエリ発行数が追加されている。

Add queries count to template rendering instrumentation by fatkodima · Pull Request #51457 · rails/rails · GitHub

このPR見ると、グローバルな ActiveRecord::RuntimeRegistry から取得できる。これも追加したら良いのでは?というアイデアが降ってきたので実装した。

これ良くない?めちゃくちゃ良くないですか...?

というわけで色々まとめて作者にコメントしておいた。

https://github.com/marcoroth/reactionview/pull/55#issuecomment-3673672277

まとめ

この機能がリリースされると嬉しいな、

もしこのレンダリング時間表示+クエリ発行数表示を試してみたいという場合はGemfileに以下のように記述して私のブランチから直接インストールすれば動かせます。フィードバックなどあればよろしくお願いします。

gem "reactionview", "~> 0.2.0", github: "kozy4324/reactionview", branch: "render-times-and-queries-count-with-build"

OSS活動記 #2 - herb - head以下に出力するcontent_forへのdebug spanをなんとかする

対象リポジトリ

github.com

Issue

github.com

どういった問題が発生しているのか

個別のテンプレートとして models/index.html.erb がある。

<% content_for :title do %>
  <%= :title %>
<% end %>

レイアウトのテンプレート layouts/application.html.erb は以下の通り。

<!DOCTYPE html>
<html>
  <head>
    <title><%= content_for(:title) %></title>
  </head>
</html>

個別のテンプレートごとに任意の <title> が設定できるが、この <title> に debug span が展開されてしまう。debug span とは reactionview の debug mode において erb 出力を追跡するための情報を可視化するための <span> であって、herb ライブラリ内の処理で動的に追加されるタグ。

そもそも <title> には <span> はおろかテキストコンテントしか子要素として受け付けない。

https://html.spec.whatwg.org/multipage/semantics.html#the-title-element

よくよく調べると <head> 以下に <span> が存在することは仕様上ないので <head> 以下に debug span が展開されることは HTML の構造を破壊しうるので好ましくない。実際 herb ライブラリ内の処理では特定タグ以下では debug span は出力しないようにされている。

https://github.com/marcoroth/herb/blob/999337503477819a9018a149c49f6219ce2fbfed/lib/herb/engine/debug_visitor.rb#L301

ところが content_for で出力する場合、現状の処理では個別のテンプレートとレイアウトのテンプレートは別々に解析される。そのため <head> 以下に出力されうる erb 出力なのかを識別することが困難になっている。

解決策を提案してみる

問題の解像度が上がったところで、現時点でどう解決すべきかが一意ではなさそうなので質問がてら解決策をいくつか提案してみた。

https://github.com/marcoroth/herb/issues/605#issuecomment-3649595653

自分が提案した案は3つ。

  1. content_for 以下では debug span は出力しない
  2. debug span の出力を opt out できるようにする
  3. (1) + debug span の出力を opt in できるようにする

ここでいう ( opt out | opt in ) は、例えば <%# herb:disable-debug-span-in-content_for-block %> みたいな erb コメントを添えることで debug span の出力をコントロール可能にするという感じ。

それに対する作者の回答が来て、

  • 確かに現時点だと理想的な解決は難しいね
  • erb コメントを添えるのはフォーマッター的に難しい問題もあるので避けたい
  • やるなら (2) の案かもしれない

という感じに受け取った(意訳、正確には Issue のやりとりを読んで)

opt out する syntax も考える必要がある。いくつか作者からも提案してもらった(ジャストアイデアっぽいが)。その中で一番筋が良さそうなのが以下。

<% content_for :title do %>
  <%= :title # herb:debug disable %>
<% end %>

erb 出力単位で opt out できるのは良さそう。

erb 出力タグ内コメントの実用可能性を探る

そもそも erb としてそこにコメント置けるんだっけ?と思ったので試してみたところ Rails 上では 500 エラーになる。Rails デフォルトの ERB エンジンでも同様にエラー。

erb の出力というのは erb テンプレート → テンプレートの内容を文字列として出力する ruby コード → 文字列 という順番に処理される。ruby コードを確認してみる。

以下の erb テンプレートがあるとする。

<%= :title # herb:debug disable %>

この部分の ruby コードを確認するとこうなっていた。

@output_buffer.append=(:title # herb:debug disable);

閉じタグがコメントアウトされて ruby コードが壊れている。それはそうとして erb の実装が素朴すぎてスゴい。

いい感じに erb 出力とコメントを同居できないか探ってみる。

以下はいけた。

<%=
  # herb:debug disable
  :title
%>

以下だと Rails デフォルトの ERB エンジンは大丈夫だけど herb で ruby コードが壊れる結果となった。

<%=
  :title
  # herb:debug disable
%>

書き方をミスるとパースエラーではなく生成される ruby コードで syntax エラーになるのは微妙すぎる(ユーザーフレンドリーなエラーではない)

というわけで erb 出力タグ内コメントは「無し」っぽいなと思ったので作者にコメントを返した。

https://github.com/marcoroth/herb/issues/605#issuecomment-3650154958

唯一の妥協できそうな案をPRとして作成する

コメントを書く中で、これまでの会話を踏まえると妥協できそうな案がこれぐらいじゃないか?と思った。

<% content_for :title do # herb:debug disable %>
  <%= :title %>
<% end %>

これであれば erb ブロック単位で debug span を制御できる。元々の Issue もこれがあればワークアラウンドとして機能しそう。

というわけで PR を作ってみた。

github.com

作者がこの方針を気に入るかどうかは分からない。気に入らなかったら Reject でいいでしょう。

herb 本体側のパース精度が改善されればより適切な解析ができるだろうし、それまで塩漬けでいいかもしれないって思った。

この取り組みを通じて感じた課題

現時点の herb の課題という観点で以下を思った。

  • herb 本体側のパース精度はまだまだっぽい、この部分の改善が必要そう
  • HTML 仕様に準拠した HTML 構造のチェックがまだ甘そう

ここら辺をどう改善できるかというのは引き続き追いかけたい。

プラグインから始めるOSS開発

この記事は 東葛.dev Advent Calendar 2025 11日目の記事です。

昨日はponyoxaさんで「東葛.devのここが好き 3選」でした。

adventar.org

はじめに

こんにちは kozy4324 です。さっそくですが質問です。

みなさんOSS開発してますか?!

こう書くと「ソフトウェアエンジニア、余暇の時間もOSS活動に従事すべき!(MUST, SHOULD)」みたいに聞こえてしまいそうですが、本記事はそういう主張をするつもりはありません。どちらかというと「これからOSS開発チャレンジしたいけど何をすればいいか分からない」という人向けに、OSS開発を始める一つの「型」としての「プラグイン開発」を提案したいと思います。

筆者のOSS開発スタンス

自分に関して言うと、フルコミットOSS開発をしていたり、有名ライブラリのメンテナをしているというレベルでは全くありません。

あくまで趣味の範囲で余暇に以下のようなことに取り組んでいるという感じです。

  • 他の人も使いそうでライブラリとして切り出せそうな処理や関数があればOSSライブラリとして公開してみる
  • 仕事で使っていたり気になるライブラリを見つければソースコードを読んだり、Issueを眺めたり、PRを送ったりする

OSS開発を始めたいけど敷居が高そうで何やればいいか分からん問題

そう感じている人は少なからずいるのではないのでしょうか?私も昔はそうでしたし、今もそうかもしれません(有名&長年続いている&規模の大きなOSSにIssueを登録するのはいつだって緊張します)。

さて、OSSコントリビューション最初の1歩としてよくオススメされる「型」として「READMEやドキュメンテーション、コメントなどの記述ミスやtypoを見つけたら修正PRを送ってみましょう」というものがあると思います。OSSプロジェクトにおいてドキュメンテーションは大事ですし、そこまで手が回っていないプロジェクトも多くあるのが現状です。こういった貢献から入っていくのはすごく価値がありますし、何より修正の難易度も高くない、かつ自然言語で書かれた文章からスタートできることもポイントでしょう。

だがしかし、やはりコードが書きたい!コードを書いて貢献したい!!と思うのがエンジニアの心情ではないでしょうか。少なからず私はそうです。その場合にドキュメンテーションの次のステップとして何か「型」があるといいなと考えました。そこで「プラグイン開発」です。

プラグインプラグイン志向なOSSプロダクト

AIによる概要

プラグインとは、既存のソフトウェア(アプリやWebブラウザなど)に後から機能を追加・拡張するための小さなプログラムのことで、「拡張機能」や「アドオン」とも呼ばれ、本体にはない便利な機能(PDF表示、フォーム設置、デザイン変更など)を、個別にインストールして使えるようにするものです。

AIくんの回答をそのまま引用させてもらいました。

さて、世の中にはプラグイン機構、もしくは明確にプラグインとは言っていないがほぼそういった思想をもって設計されているOSSプロダクトが数多く存在しています。また、ここが割と重要なポイントでもあるのですが、筋の良いプラグイン機構を持つOSSプロダクトでは プラグイン開発者が集まる → ユースケースが広がる → エンドユーザーの裾野が広がる → 大きなエコシステムを形成する という機序が発生するようにも感じます。

プラグイン開発から始めることのメリット

なぜプラグイン開発がオススメなのかというポイントをいくつか挙げてみます。

1つの課題に集中することができる

大きな課題領域やユースケースは本体のOSSプロダクトが担ってくれており、それを利用する中で「追加でこういったこともできればいいのにな」と思うシーンがプラグイン開発のスタート地点になるでしょう。「本体に足りない+α」の課題部分だけに集中すれば良いのは開発を始めやすいポイントになるのではないでしょうか。

また1つの課題にのみ集中すれば良いということは実装を小さくシンプルにできるということです。その結果として素早くリリースすることも可能になるでしょう。これも開発を始めやすいポイントになると思います。

エンドユーザーに届けやすい&使ってもらいやすい

すでにエコシステムとして発達しているOSSプロダクトであるということはそのエンドユーザーがすでに多く存在します。エンドユーザーにとっては全く別の新しくツールを導入するよりもプラグインを導入する方が導入コストは低くなります。つまり自分が開発したプラグインを利用してもらえるチャンスも高いと言えます。

またあるOSSプロダクトのプラグインであれば特定の命名規則に沿って公開されることが慣例的になっているケースをよく見かけます。例えば自分は Ruby をよく使うのですが、rubygems.orgにおいて rubocop- という接頭辞や rspec- という接頭辞を検索することでそのプラグインgemを検索することが容易になっています。

本体OSSプロダクトにも貢献するチャンスができる

プラグイン機構を設計するということは、そのOSSプロダクトのユースケースにおいてプラグインにはどこまで何ができるのかを考えることだと思います。またプラグイン開発の開発体験はプラグインに対してどういったAPIが公開されているのかということにも直結します。つまりプラグイン開発者もそのOSSプロダクトのユーザー形態の1種と考えることができます。

より良いプラグイン機構とプラグインユースケースを実現するためにはプラグイン開発者からのフィードバックも必須だと考えられます。プラグイン機構を持つOSSプロダクトにおいてはプラグイン開発者も重要なロールの一部なのではないでしょうか。

kozy4324のOSSプラグイン開発をふりかえる

プラグイン機構を持つOSSプロダクト事例の紹介を兼ねて、実際に自身が取り組んできたOSSプラグイン開発をふりかえってみます。

Gruntとgrunt-concat-sourcemap

gruntjs.com

github.com

GruntはJavaScript製のTask Runnerですね(なつかしい)

WebpackもTypeScriptもまだまだ無かった頃の話です。複数のJavaScriptを連結(concat)して1ファイルとして配信していた時に、今や当たり前にあるSourceMapも後発として生まれました。単純にファイルを連結する機能はGruntに最初からありましたがSourceMapは解決してくれません。無いなら作るかとなったのがこのgrunt-concat-sourcemapです。

initial commitを確認すると今からもう12年以上も前です。コード量は100行ぐらいのJavaScriptファイルが1つのみです。コンパクトですね。

https://github.com/kozy4324/grunt-concat-sourcemap/blob/master/tasks/concat_sourcemap.js

その時のニーズにマッチしたのか50スターほど付けてもらいました。なお余談ですがSourceMap解決ロジックがバグっており少し複雑なケースでSourceMapがぶっ壊れます。

1つの課題に集中できたので素早くリリースできたプラグインだったなぁと思いつつ、concat時のSourceMapは本体機能でちゃんと解決されるようになったのでお役御免ということでarchiveとなりました。

JasmineとJasmine-TAPReporter

jasmine.github.io

github.com

JavaScriptスティングラインブラリであるJasmineのReporterプラグインを作っていました。これのinitial commitはさらに古く14年も前だった。実装を見たら「おお!CoffeeScript CoffeeScriptじゃないか!久しぶりじゃないか 元気にしてたか」という気持ちになりました。これも100行程度のスクリプトファイルが1つでコードサイズもコンパクトなものになっています。

https://github.com/kozy4324/Jasmine-TAPReporter/blob/master/src/tapreporter.coffee

RuboCopとrubocop-oneoff_codemod

github.com

github.com

RuboCopのプラグインを作りたくて作ってみました。TSKaigi 2025の基調講演でAnthony FuさんがESLint Plugin Commandというやつを紹介していたのですが、

ESLint Plugin Command

「カッコよ!それRubyでもできんかな?」と思ってRuboCopのプラグインとして作ってみたというやつです。PoCレベルの実装だけして「似たようなことできるやん!」で満足して放置してしまっていますね(良くない)。アイデア思いついてからgemとして公開するまで1日ぐらいしか経っていません。そういったスピード感でリリースまでいけるのはプラグイン開発のオススメしたい点だと思います。

これも一つ一つのcop(RuboCopのpluginの単位となるもの)はコンパクトになっているのでソースコードリンクを掲載しておきます。

https://github.com/kozy4324/rubocop-oneoff_codemod/blob/b7e0d49315dea61cdf7fb38d50846c01402259b8/lib/rubocop/oneoff_codemod/cops/keep_unique.rb

RubyLSPとruby-lsp-rake

shopify.github.io

github.com

Shopifyが開発しているRubyLSPにもプラグイン機構があります(ドキュメント上は Add-on とありますが、この記事中は全て「プラグイン」という概念でまとめさせてください)

Add-ons | Ruby LSP

ドキュメントページがしっかりと整備されておりRubyLSPの仕組みを理解したいなと思ったのでプラグインを作ってみたという流れのものです。

本体のRubyLSPに比べると小さい実装にまとまっており、これぐらいなら自分でも作れそうと思ってもらえれば良いなという事例として紹介してみました。

https://github.com/kozy4324/ruby-lsp-rake/tree/f76f0467c1379d584072d6b509351270cd49c055/lib/ruby_lsp/ruby_lsp_rake

ruby-lsp-brakemanへのPR

RailsのセキュリティスキャンツールにBrakemanというものがあります。

Brakeman: Brakeman Security Scanner

このBrakemanのRubyLSP Add-onが作られていました。その名もruby-lsp-brakeman

GitHub - presidentbeef/ruby-lsp-brakeman: Ruby LSP Addon for Brakeman

このBrakemanのAdd-onですがソースコードのエラー箇所をLSPとして表示してくれます。ですがソース位置を表す情報として行情報しかなくカラム位置情報がありません。なんとかうまいことカラム位置を含められないものかと思い修正を検討しようと思いましたが、修正するにしてもまずはテストないと修正はキツいだろうなということでテストを追加するPRを送ってみたというのがこちらです。

github.com

ruby-lsp-rakeを自作して得た知見を展開してみるムーブをかましてみました。なお肝心のカラム位置情報の追加ですが、Brakemanが扱うASTにそもそも具象情報としてのカラム位置が欠落しており、Add-onじゃなくてBrakeman本体をなんとかしないと無理では...となって頓挫しています。現実はなかなか厳しい。

自分でプラグインを開発するだけでなく、他のOSSプラグインに対してコラボレーションするというOSS開発スタイルもあるよねということで事例紹介してみました。

まとめ

プラグイン開発のオススメポイントを説明させてもらいました。またその具体例として自分が過去に行ってきたOSSプラグイン開発の事例を紹介してみました。紹介した事例の本体OSSプロダクトは有名どころだったり長年続いていて規模の大きなコードベースのものばかりですが、そのプラグイン開発であれば本体に比べて敷居が低く見えたのではないでしょうか?またプラグイン機構(もしくは似たような設計)を持つOSSプロダクトの一部を紹介させてもらいましたが、そういったOSSプロダクトが世の中に多くあることも多少は伝わったのではないでしょうか。

少しでも「このやり方だったら自分でもOSS開発にチャレンジできそう」と思ってもらえたなら幸いです。この記事を最後までお読みいただきありがとうございました。

さいごに: 東葛.devと私

東葛.devのAdvent Calendarなのでさいごに東葛.devの紹介をしておきます。東葛.devは東葛地区中心のIT/Web技術コミュニティで定期的なオフラインイベントやDiscordなどでワイワイやっています。

toukatsu.dev

私個人としての参加モチベーションはこの記事で紹介させてもらったようなOSS開発にまつわる活動や事柄を会話したり相談したりしたいなぁというものがあります。この記事を読んで同様な興味・関心を持ってもらえたならば東葛.devの方もぜひのぞいてみてください。よろしくお願いします!

OSS活動記 #1 - herb - javascript_tag内のerb出力にdebug spanが付与されるのをなんとかしたい

対象リポジトリ

github.com

遭遇した事象

herbにはdebug modeがある。これをonにすることでerb出力が可視化されて便利。

debug modeがonになっている様子

とても便利なのだがどうやらjavascript_tagヘルパー内のerb出力にも作用して余計なspanタグを付与してしまうみたい。

<%= javascript_tag do %>
  alert('<%= 1 %>');
<% end %>

実装を確認したところ、通常のscriptタグのケアはなされていた。以下は問題ない。

<script>
  alert('<%= 1 %>');
</script>

scriptタグを使うべきか?でいうと、例えばCSPを有効にしてscriptタグにnonceを付与したい場合はjavascript_tagが使えないと問題である。

ActionView::Helpers::JavaScriptHelper

scriptタグがケアされているのだからjavascript_tagヘルパーも同様にケアされるべきだろうと判断した。

Issueを上げる

というわけでIssueはこんな感じ。基本的に英作文はLLMに手伝ってもらった。

github.com

ソースを読んで解決策を考えてみる

このdebug spanを出力するのは Herb::Engine::DebugVisitor クラスがやっている。

herb/lib/herb/engine/debug_visitor.rb at a43c7e6f138cdaa759468f11427b98ecd5e131f6 · marcoroth/herb · GitHub

scriptタグをどうやって識別しているのかを見る。

  1. スタック @element_stack = [] を持つ
  2. HTML要素ごとに処理される visit_html_element_node メソッド内でスタックを積む
  3. erb出力ごとに処理される visit_erb_content_node メソッド内で in_excluded_context? でチェック
  4. in_excluded_context? で現在のスタックを見てscriptタグが含まれているかを確認する

ベースはvisitorパターンで辿ったHTML要素をスタックに積んで、それをコンテキストとして利用していると理解した。

さて、HTML要素はスタックに積むが javascript_tag do といったERBBlockに対してはスタック操作はなされていない。同様の操作を行えば識別可能になるのではないか。

PRを作る

土日に入ったタイミングでIssueを上げたためかレスポンスはまだ来ていなかった。解決策のPoCにしてもコードを見せるのが手っ取り早いよなってことでPRを作成した。もちろん全ての英作文はLLMとやっている。

github.com

ソースを読んで分かったこと

herbはhtml/erbのパーサーであるのだけど、その抽象構文木のノードに関わる実装はtemplates以下にあるテンプレートから動的に生成されていた。

これRubyパーサーのPrismで見たアプローチだなってなった。

あとherb ASTからerb出力するためのRubyコード生成は Herb::Engine , Herb::Engine::Compiler あたりが主にその責務を持つ。

visitorを複数注入できるのでdebug modeであれば Herb::Engine::DebugVisitor が注入されるし、任意のvisitorを実行時に追加可能な設計にもなっていた。

おわりに

まだPRを作っただけなので動きがあったらまたブログ記事に書き残すか追記をしようかと思っています。個人的に障壁が高かったOSS活動に伴う英作文や、あとソースコードリーディングに関してもLLMの力を借りることで難なくできるようになっていい時代になったものだなぁという感想でした。

追記(2025-12-07 22:36)

PRは少し手直しされてマージされていた🙌

2025年11月のふりかえり

12月になりましたね。12月が終わるとどうなる?知らんのか。2026年が...。

今年のふりかえりもあと2回ですね。11月をふりかえります。

前回はこれ:

kozy4324.hatenablog.jp

OSS活動

herbとかreactionviewを触って見つけたバグを報告していた。

Parser error in v0.8.0 when using `if` inside `else` of a `case` block · Issue #860 · marcoroth/herb · GitHub

reactionviewで属性値にJSON埋め込むと2重にエスケープ処理が走ってJSONが壊れるというIssueがあって、そのPRがDraftのまま進捗ないのでコメントしてみたりしていた。

Fix double escaping in attributes, `<script>` and `<style>` tags by marcoroth · Pull Request #53 · marcoroth/reactionview · GitHub

herbを使いたいのだけどこのバグが直らないことにはプロダクトで使えんな〜ってなってて、見方によってはコントリビューションチャンスとも言えるわけで何かできること考えてみたい。

英語学習

なんとかスピークだけは続けている。TOEIC学習の進捗は、ありません...。そっちはいい加減やっていこう。

読書

「入門 データ構造とアルゴリズム」をゆるゆると読んでいる。

https://www.amazon.co.jp/dp/4873116341

書いてあること+周辺トピックを掘り下げてブログ記事とか書いてアウトプットしたいなぁとなっている。

ランニング

頻度が落ちてしまった。子が通う小学校でインフルエンザによる学級閉鎖が発生したりと緊張感のある週もあってなかなか厳しい月になってしまった。幸い体調面の調子は悪くないので免疫落とさない程度に追い込んでいきたい。

体重

ギリ保っている。ランニングの代わりに筋トレを意識的にやっていたりもする。

ブログ

いちおう2記事書いた。

2本目の記事を書こうと思ってしばらくずっと浮動小数点関連の論文を読んでいたら気づいたら月末になっていたという展開。間隔が空いてしまったのはちょっと反省。

勉強会

念願の東葛.devに初参加してきた。もちろん柏.rbも継続している。

東葛.devで発表したLTはこちら。好きなことを好きなように喋らせてもらった。

speakerdeck.com

さて、12月と1月は子の中学受験があるため勉強会関連の参加は控えることになっている。2ヶ月充電期間だけど、LTでもなんでも発表できる程度にネタを仕込んでおこうかと思っています。

KPT

  • Keep
    • OSS活動
    • 読書
    • 体重
    • 勉強会
  • Problem
    • 英語学習
    • ランニング
    • ブログ
  • Try
    • 英語学習
    • ランニング
    • ブログ
      • 勉強会とかの外部交流しない代わりにインプット&アウトプットやっていき

Rubyと深掘る浮動小数点数

はじめに

コンピューターサイエンスの基礎的なことをゆるく独学していきたいなぁということで「CSゆる独学」というカテゴリーで記事を書いていこうかとなりました。なおこのテーマを思い立って筆を取るまでにはや数週間経過しているぐらいのゆるさです。やっていきます。

最初のテーマは浮動小数点数、なぜ浮動小数点数

趣味でRailsアプリケーションの bundle outdated を毎日叩いて依存gemのchangelogを眺めていました。とある日のjson gemのchangelogで見つけたものがこれ。

令和のこの時代に浮動小数点数に関わる部分で7倍も高速化する余地があるんだ!ということに驚きました。と同時に「浮動小数点数のこと、なんとなくしか把握できてないな」となったのでこのタイミングで改めて浮動小数点数を学び直します。

おさらい、浮動小数点数

まずはWikipediaを確認です。

浮動小数点数 - Wikipedia

浮動小数点数(ふどうしょうすうてんすう、英: floating-point number)は、実数をコンピュータで処理(演算や記憶、通信)するために有限桁の小数で近似値として扱う方式であり[1]、人間が多く用いる10進数での数値の計算や表記に加えて、コンピュータの数値表現で多く用いる2進数の数値でも採用されている。多くの場合、符号部、固定長の指数部、固定長の仮数部、の3つの部分を組み合わせて、数値を表現する。

とりわけ、多くのシステムで採用されている IEEE 754 形式 を確認しましょう。ポイントとしては以下らへんで、

  • 符号部は、 0 を正、1 を負とする
  • 仮数部は、整数部分が 1 であるような2進小数の小数部分(ケチ表現)を表す
  • 指数部は、符号なし2進整数とし、半精度では 15、単精度では 127、倍精度では 1023、四倍精度では 16383 のゲタを履かせたゲタ履き表現で表す

単精度の場合で言うと、仮数部は23bitsありますが整数部分の 1. が隠れているので2進数の有効桁数は 23bits + 1bits = 24bits というケチ表現の部分でしょうか。あと指数部もゲタ履き表現というものがあります。これらを押さえておかないと後に出てくる実装で「あれ、この演算は何?」となるので注意しましょう。

さて、このCSゆる独学ではプログラミング部分はRubyを使っていこうかと考えています。もちろんRubyのFloatクラスも IEEE 754 形式 ということですね。

class Float (Ruby 3.4 リファレンスマニュアル)

10進数値と IEEE 754 形式 2進数表現を相互変換してくれるページもありました。こちらを触ってみるとより内部表現のイメージを掴みやすいかと思います。

Float Exposed

RubyでFloatを2進数表現に変換、またはその逆の操作

Array#packString#unpack1 を利用することで変換が可能です。

g で単精度、 G で倍精度です。

$ ruby -e'puts [0.75].pack("g").unpack1("B*")'
00111111010000000000000000000000

$ ruby -e'puts ["00111111010000000000000000000000"].pack("B*").unpack("g")'
0.75

$ ruby -e'puts [0.75].pack("G").unpack1("B*")'
0011111111101000000000000000000000000000000000000000000000000000

$ ruby -e'puts ["0011111111101000000000000000000000000000000000000000000000000000"].pack("B*").unpack("G")'
0.75

お手軽ですね。

10進数値から2進数表現を得るアルゴリズム

一般教養レベルの話ですね。なのでLLMに質問丸投げした回答を載せます。

  • 10進整数 → 2進数(整数部)の変換:2で割って余りを集める
    1. n を 2 で割る
    2. 商 ← 次の n として使う
    3. 余り(0/1)を記録する
    4. 商が 0 になるまで繰り返す
    5. 最後に余りを逆順に並べる
  • 10進小数 → 2進数(小数部)の変換:2を掛けて、出た “1 or 0” を集める
    1. x に 2 を掛ける
    2. 結果の整数部(0/1)をビットとして記録
    3. 小数部だけを次の x にする
    4. 必要な桁数に達するまで繰り返す

疑問:プログラムはどうやって10進数字文字列から浮動小数点数を得ているのか?

ここで1つ疑問が生じました。例えばソースコード上にある浮動小数点数リテラル(10進数字文字列)はどうやって計算機で処理可能な値(2進数表現である浮動小数点数)に変換されているのか?ということです。

イメージ
"0.75" → 00111111010000000000000000000000 → Float(0.75)

前述のアルゴリズムがそうとした場合、少数部の変換で浮動小数点数による演算が登場しています。つまり浮動小数点数を得るためには浮動小数点数が必要となり、ニワトリが先かタマゴが先か状態になっていそうです。

単純に少数部を整数として扱い2倍して繰り上がりしたら 1、繰り上がりしなかったら 0、みたいなことでもできそうだけど計算効率がとても悪くなりそうな予感がします。

浮動小数点数に関する論文をあたる

自分一人で考えるには行き詰まったところで、きっとこういった部分で学術的な研究の積み重ねがあるやろ!ということでLLMに質問してみました。

decimal文字列 → binary浮動小数点、またはその逆に変換するアルゴリズムに関して、時系列順でどういった論文がある?で出してもらったものが以下の通り。

  • How to Read Floating-Point Numbers Accurately (1990) — William D. Clinger
  • Correctly Rounded Binary-Decimal and Decimal-Binary Conversions (1990) — David M. Gay
  • Printing Floating-Point Numbers Quickly and Accurately (1996) — Robert G. Burger & R. Kent Dybvig
  • Printing Floating-Point Numbers Quickly and Accurately with Integers (2010) — Florian Loitsch
  • Ryū: Fast Float-to-String Conversion (2018) — Ulf Adams
  • Grisu‑Exact: A Fast and Exact Floating-Point Printing Algorithm (2020) — Junekey Jeon

json gemが高速化したところで言及されていた ryu algorithm は2018年の論文に関するものらしいですね。

How to Read Floating-Point Numbers Accurately にある変換アルゴリズムを実装する

1点目のWilliam D. Clingerによる論文を試しに読んでみます(もちろんLLMに翻訳させながら)

https://dl.acm.org/doi/pdf/10.1145/93542.93557

書いてある内容はLLMの翻訳によってなんとなく読めますが、サンプルコードがSchemeによるもので読み解きが非常に困難です。LLMに解説をさせていたところ、論文に言及のあるアルゴリズムと同等なものを解説している記事を見つけました。

https://www.exploringbinary.com/correct-decimal-to-floating-point-using-big-integers/

こちらの解説の方が理解できます。ポイントとしては以下というところでしょうか。

  • 浮動小数点数の有効桁数に収まるよう任意精度の整数を使って正しく丸められる浮動小数点値を得ること
  • そのために10進数字文字列 → 有理数表現に変換してから 252 ≤ q < 253 に収まる範囲にスケーリングしている
  • 整数演算のみで完結

これをベースにコードを書いてみました。もちろんRubyで。

gist.github.com

実行結果は以下の通り、うまく変換できていますね。

1. Express as an integer times a power of ten
3.14159 = 314159 * 10^-5

2. Express as a fraction
314159 / 100000

3. Scale into the range [2^52,2^53)
314159 * 2^51 / 100000 = 707423177667543826432 / 100000

4. Divide and round
707423177667543826432 / 100000 = 7074231776675438 remainder 26432
quotient rounds down to 7074231776675438

5. Express in normalized binary scientific notation
7074231776675438 converts to 11001001000011111100111110000000110111000011001101110
Unnormalized binary scientific notation  : 11001001000011111100111110000000110111000011001101110 * 2^-51
The unscaled and normalized value becomes: 1.1001001000011111100111110000000110111000011001101110 * 2^-51 * 2^52
Normalized binary scientific notation    : 1.1001001000011111100111110000000110111000011001101110 * 2^1

6. Encode as a double
The sign field    : 0
The exponent field: 1024 (1 + 1023)
The fraction field: 2570632149304942 (7074231776675438 - 2^52)

input : 3.14159 => 0100000000001001001000011111100111110000000110111000011001101110
result:            0100000000001001001000011111100111110000000110111000011001101110 => 3.14159
1. Express as an integer times a power of ten
1.2345678901234567e22 = 12345678901234567000000 * 10^6

2. Express as a fraction
12345678901234567000000 / 1

3. Scale into the range [2^52,2^53)
12345678901234567000000 / 2^21 = 12345678901234567000000 / 2097152

4. Divide and round
12345678901234567000000 / 2097152 = 5886878443352969 remainder 1355712
quotient rounds up to 5886878443352970

5. Express in normalized binary scientific notation
5886878443352970 converts to 10100111010100001010110110010011100111011001110001010
Unnormalized binary scientific notation  : 10100111010100001010110110010011100111011001110001010 * 2^21
The unscaled and normalized value becomes: 1.0100111010100001010110110010011100111011001110001010 * 2^21 * 2^52
Normalized binary scientific notation    : 1.0100111010100001010110110010011100111011001110001010 * 2^73

6. Encode as a double
The sign field    : 0
The exponent field: 1096 (73 + 1023)
The fraction field: 1383278815982474 (5886878443352970 - 2^52)

input : 1.2345678901234567e22 => 0100010010000100111010100001010110110010011100111011001110001010
result:                          0100010010000100111010100001010110110010011100111011001110001010 => 1.2345678901234568e+22

まとめ

浮動小数点数の変換(decimal文字列⇔binary浮動小数点)に関する論文の1つを読み、そこで説明されているアルゴリズムRubyで実装してみることで変換処理の理解を深めることができました。ただしこのWilliam D. Clingerによる論文は後続の論文に対する基盤となる考え方を提供しているに過ぎないらしいです。この論文の後によりいくつかのアルゴリズムが発表され高速化がなされているんだと理解しました。

最初に言及されたryu algorithmについてや、そもそも現在のここらへんのRuby実装がどうなっているのかという別の興味関心も湧いてきました。別の論文もゆるく読んで学んでいきたいなと思います。また次回!

素朴な再帰下降パーサをRubyで書いてみる その2

改めて書いてみて色々気付きがあったなという記事です。

kozy4324.hatenablog.jp

成果物リポジトリ

github.com

Lexerの改善

1トークン分を読み進めてトークンを取得する #advance を実装していましたが、自分で実装した割に呼び出した後にlexerがどういった状態になるかが曖昧になっていました。読み込み位置と、その読み込み位置に対してどのトークンが取得できるのかを改めて整理してみました。それに加えて #token で取得できるトークンに加えて、さらにその次のトークンを取得する #peek も定義しています。

ここら辺をコメントに記載した箇所: https://github.com/kozy4324/simple-json-parser/blob/0ee2c886047f04b2825db9474034fb8f5774d8de/lib/simple/json/lexer.rb#L10-L39

Parserの改善

実装時の注意点を以下のように整理して実装に取り組みました。

  • 基本は1文法ルールに1パースメソッドが対応する
  • 読み込み位置はパースメソッド開始時点でその文法の先頭にあり、終了時点で末尾まで進むものとする
  • トークン列で考えるとメソッド開始時点でその文法の直前トークン、終了時点でその文法の最終トークンが token から取得できる

ここら辺をコメントに記載した箇所: https://github.com/kozy4324/simple-json-parser/blob/0ee2c886047f04b2825db9474034fb8f5774d8de/lib/simple/json/parser.rb#L11-L26

これにより、例えば object の1つの key & value を処理する parse_member は以下のような実装にできました。ホワイトスペースを処理するタイミングも文法通りにしています。

# member ::= ws string ws ':' element
def parse_member
  parse_ws
  key = parse_string
  parse_ws
  @lexer.advance # ':'
  value = parse_element
  { key => value }
end

numberのパース処理

文法上は以下の通りです。

number ::= integer | fraction | exponent
integer ::= digit | onenine digits | '-' digit | '-' onenine digits
digits ::= digit | digit digits
digit ::= '0' | onenine
onenine ::= '1' . '9'
fraction ::= "" | '.' digits
exponent "" | 'E' sign digits | 'e' sign digits
sign ::= "" | '+' | '-'

これは1つのメソッドで対応してみました。 Lexer#number_value がその実態です。

INTEGER_REGEXP  = /-?(?:0(?![0-9])|[1-9][0-9]*)/
FRACTION_REGEXP = /\.[0-9]+/
EXPONENT_REGEXP = /[Ee][+-]?[0-9]+/
NUMBER_REGEXP   = /(#{INTEGER_REGEXP})(#{FRACTION_REGEXP})?(#{EXPONENT_REGEXP})?/

# 現在読み込み位置以降の数値を取得して読み込み位置を進める
def number_value = @scan.scan(NUMBER_REGEXP).then { |s| /[.Ee]/ =~ s ? s.to_f : s.to_i }

stringのパース処理

文法はこうです。

string ::= '"' characters '"'
characters ::= "" | character characters
character ::= '0020' . '10FFFF' - '"' - '\' | '\' escape
escape ::= '"' | '\' | '/' | 'b' | 'f' | 'n' | 'r' | 't' | 'u' hex hex hex hex
hex ::= digit | 'A' . 'F' | 'a' . 'f'

バックスラッシュのエスケープを正規表現で解決するには悩ましい。ということでこちらは1文字ずつ処理する形式で実装してみました。

# 現在読み込み位置以降の文字列値を取得して読み込み位置を進める
def string_value # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity
  string = +""
  @scan.getch # "
  until (c = @scan.getch) == '"'
    raise "unexpected end of input." if c.nil?
    raise "invalid ASCII control character in string." if c.ord < 20

    if c == "\\"
      c = @scan.getch # escape
      case c.ord
      when 34, 92, 47 # " \ /
        string << c
      when 98 # b
        string << 8.chr # BS
      when 102 # f
        string << 12.chr # FF
      when 110 # n
        string << 10.chr # LF
      when 114 # r
        string << 13.chr # CR
      when 116 # t
        string << 9.chr # HT
      when 117 # u
        hex4 = @scan.scan(/[0-9a-fA-F]{4}/)
        string << hex4.to_i(16).chr(Encoding::UTF_8)
      else # rubocop:disable Lint/DuplicateBranch
        # JSON仕様上は許可されないescape文字だがJSON.parseでは無視する振る舞いなのでそれに合わせる
        string << c
      end
    else
      string << c
    end
  end
  string
end

結局実装しなかったこと

  • 文法が崩れている時のエラー処理
    • 期待したものと異なるトークンがやってきた時のケアとか
    • object や array の閉じ忘れとか
  • 文法エラー時の適切なエラーメッセージ
    • 〇を期待したけど△がやってきたよとか
    • ソース文字列中のこの場所でエラーだったよとか

ただしパース処理中のここら辺でエラー処理を実装すれば良さそうだなという感触は掴みました。

発展系?

文法的に不完全な状態でも簡単なルールであれば欠落したトークンを保管してあげることなどができそうです。つまりそれがエラートレランスな機能を持つパーサーということか?と少し思いました。

文法ありきな話ではありますが、文法エラー時にどういった戦略をとるか、またはとれるかを考えてみると面白いかもしれません。

とはいえRubyの文法に対してPrismはどんなエラートレランスが実現できているんだろうか、JSONに対して複雑性が全然違いそうなので全く想像できていないなぁともなりました。そういった観点でPrismの実装も眺められるようになるといいかもしれませんね。

まとめ

  • 素朴な再帰下降パーサを実装してみて、それなりに実装できました
  • JSON文法ぐらいの大きさ・複雑さであれば学習用としてちょうど良かった気がします
  • 文法通りに実装するところから発展してエラー処理とそのユーザー体験を突き詰めるとユニークなパーサが実装できるかもしれませんね