@ledsun blog

無味の味は佳境に入らざればすなわち知れず

WSL上のfish-shellからWindowsのホストのディレクトリでプロンプト表示に時間がかかることがある

現象

表題がわかりにくいですね。 動画で再現するとこうです。

gyazo.com

lsコマンドを実行すると、結果が表示されます。 すこし待つとプロンプトが表示されます。

fishのversionは 3.3.1 です。

今、原因の候補と考えているもの

プロンプトの右側にgitの情報を表示しています。 この情報取得に時間が掛かっているのでしょうか?

Rails 7のload_async

Rails 7の新機能を見ていて次の機能があることに気がつきました。

Ruby on Rails 7の主要な新機能・機能追加・変更点 - Qiita

ActiveRecord::Relation#load_asyncを使用することによって、非同期でSQLクエリを実行し、結果を取得することができるようになりました。

これによって、例えば以下のコードのようにコントローラの同じアクション内で、複数の相互に依存しないクエリを並列に実行することができるようになり、レスポンスを返す時間を短縮することができるようになります。

def index
  @articles = Article.per(params[:per]).page(params[:page]).load_async
  @categories = Category.active.load_async
end

非同期化する方法や、load_asyncというメソッドを呼ぶだけで非同期APIの待ち合わせを実現してる方法が気になります。 Implement Relation#load_async to schedule the query on the background thread pool by casperisfine · Pull Request #41372 · rails/rails · GitHub を見てみます。

@async_executorという変数があります。 RailsConcurrent Rubyを使っています。 executorと言うの名前からすると、 Class: Concurrent::ThreadPoolExecutor — Concurrent Ruby か、近いものを使っていそうです。 並列化するためにスレッドを使っていそうです。 また並列数に上限を持たせるためにスレッドプールを使っていることも予想できます。 Railsのコネクションプールはスレッドに依存しているので、この辺どうなってんだ?というのはさらなる疑問です*1

次にActiveRecord::FutureResultクラスの存在に気がつきます。 なるほど Future パターン - Wikipedia です。 次のように動きそうです。

  1. load_asyncメソッドを呼び出した瞬間に別スレッドでSQLクエリを発行する
  2. ActiveRecord::FutureResultを返す
  3. クエリの結果を参照するとき(主にビューをレンダリングするとき)に
    1. SQLクエリが完了していれば結果を使う
    2. 完了していなければ完了するまで待つ

3-2で待つところで待ち合わせしています。 3で結果を待ったり、すでにある結果を使ったりする仕組みがFutureです。 JavaScriptの世界ではPromiseと呼ばれているので、あんな感じの奴です*2

最初に次のサンプルコードを見たとき

def index
  @articles = Article.per(params[:per]).page(params[:page]).load_async
  @categories = Category.active.load_async
end

疑問を持ったのは、このサンプルコードの範囲で待ち合わせしていると考えたからです。 実際には待ち合わせを実行しているのは、このあビューをレンダリングするために値を参照するときでした。

APIはシンプルですけど、やってることはなかなかややこしいですねー。

参考

*1:https://pawelurbanek.com/rails-load-async によると異なるコネクションを使うそうです。まあ、言われてみればそうですよね。そのためload_asyncをつかうときはコネクションプールの枯渇の考慮しなければいけないことも説明されています。

*2:JavaScriptのPromiseはスレッドを考慮していないので、あれをイメージするとそれはそれで混乱する気はします。

七回死んだ男

甥が夏休みの読書感想文の題材に選んだ本です。 読み切るのにどれくらい時間がかかるのかベンチマークのために読んでみました。 一日で読み切れました。

面白かったです。 SF新本格というミステリーの1ジャンルでした。 ミステリーの探偵役にタイムリープ体質を持ち込むややこしい設定の小説です。 設定もですが筋も凝っています。 読書初心者が読んで面白い本かはわかりませんが、僕には新鮮で面白かったです。

甥には代わりに天才になるための本をすすめておきました。

この本の内容に対して元ネタにされたアンダース・エリクソンは否定的です。 まあ、初心者には厳密だとか正確だとか、こまけえことはいいんです。

Railsガイド日本語訳への貢献

雑談会 - @ledsun blog

昔使ってたDelayed::Jobがなくなっていた

と書いたら次のような情報を頂きました。

修正するためにプルリクエスActiveJobのバックエンドの一覧に英語版にあるDelayed JobとQueがありませんでした by ledsun · Pull Request #1272 · yasslab/railsguides.jp · GitHub を作りました。 マージもデプロイもされています。Railsガイドの日本語訳は修正されています。

Railsガイドの日本語訳へのPRを作るのは簡単なのでオススメです。

https://github.com/yasslab/railsguides.jp#%E3%83%96%E3%83%A9%E3%82%A6%E3%82%B6%E3%81%A7rails%E3%82%AC%E3%82%A4%E3%83%89%E3%81%AE%E4%BF%AE%E6%AD%A3%E3%82%92%E6%8F%90%E6%A1%88%E3%81%99%E3%82%8B-%E3%82%AA%E3%82%B9%E3%82%B9%E3%83%A1にあるように

  1. ブラウザ上で guides/source/ja を開く
  2. 直したいファイルを開く (例: upgrading_ruby_on_rails.md)
  3. 画面右にある ✎ アイコン (Fork this project and edit this file) をクリックする

という感じです。ブラウザだけで作れます。

僕はよく日本語版のRailsガイドを読みます。 一回なおせば次からは考えなくちゃいけないことが1つ減るので便利です。

並列プログラムの作り方

咳さんがどこかですすめてたのを見て2018年に買った本です。 4年の熟成を経て、読んでみました。

原著は1990年発行なので、30年前の本です。 大学の教科書向けの本です。 教科書系の本によくあるように前半は理論というか概念の説明です。 なんでこういう構成なのか不思議でしたが、理論とか概念って30年経っても役立つ内容だからなんですね。 それとも(アンダース・エリクソンのいう)心的イメージを伝えようとしているのかもしれません。

並列のパラダイムを次の3つにわけて整理してます。

  1. 結果並列法(result parallelism)
  2. 専門家並列法(specialist parallelism)
  3. 手順並列法(agenda parallelism)

僕が今やりたいCPUバウンドな処理の並列化は1の結果並列法に入りそうです。 求める結果が分割できるので、結果を求める作業も分割した結果単位に分割して、並列化できます。

専門家並列法は、タスクの依存関係を整理して並列出来る処理を探す方法。 Conwayの法則 「organizations design systems that mirror their own communication structure.(組織は自らのコミュニケーション構造を反映したシステムを設計する)」っぽいなと思いました。 人を増やすだけでは並列数は増えなくて、組織にあったソフトウェアアーキテクチャにすると、実装タスクの並列数を増やせるのかな?とか考えました。

手順並列法は、並列に実行できる手順を定義する方法。 ActiveJobのジョブを実装する感じのようです。 分散データ構造が必要という話が出てきて、SidekiqでいうRedisのことだと理解しました。

書名の通り並列プログラムを作るときにどう考えて設計していけば良いのかが書かれてそうです。 30ページくらいしか読んでいません。

例としてn-体問題が出てきます。 三体に三体問題としてでてくるやつです。

てか、n-体問題って「力学モデル(Force-directed graph drawing)アルゴリズム」なのでは? 正確にはちがって、力学モデルはノードを良い感じに散らばらすためのアルゴリズムです。 力学モデルは繰り返し計算が必要で1値に収束しないで振動します。 これを1値に定めようとするとやたら難しくなるのがn-体問題に似ているように思いました。

雑談会

今年の4月から社内のslackでハドルを使って、週一で30分の(技術的なテーマで)雑談をしています。 今回は僕が司会の回だったので、ActiveJobのバックエンドについて質問しました。

スレッドワーカーのSidekiqで並列化できるわけもなく - @ledsun blog でSidekiqのジョブだと、CPUバウンドな処理の並列化ができなくて困っていました。 ワーカーのプロセスが分かれているActiveJobのバックエンドでなんか良いのありますか?な質問をしました。 ActiveJobのバックエンドってActive Job の基礎 - Railsガイドをみると次のようなラインナップです。

  • Sidekiq
  • Resque
  • Sneakers
  • Sucker Punch
  • Queue Classic
  • Good Job

昔使ってたDelayed::Jobがなくなっていたり、Good Jobという見慣れない奴がいたり状況は変わっているみたいです。 おしゃべりしながら、次のようなアイデアが得られました。

後者は自分になかった発想で面白かったです。

スレッドワーカーのSidekiqで並列化できるわけもなく

処理を並列化したらデータベースアクセス速度が低下した謎 - @ledsun blog で「SidekiqのジョブをつかってCPUバウンドな処理を並列化したのに速度低下しておかしい。」みたいなことを書きました。 冷静に考えたら並列化されてません。 RubyのスレッドにはGlobal interpreter lock(GIL)*1があります。 ワーカーがスレッドで動くSidekiqでは、CPUバウンドな処理の並列化は出来ないのでした。 並列化するにはワーカーのプロセスをわける必要があります。

次の画像はhttps://sidekiq.org/products/enterprise.htmlにあるSidekiq EnterpriseのFeaturesです。

Sidekiq EnterpriseのFeatures

Multi-Core Processingが含まれています。 Sidekiqを使ってCPUバウンドな処理の並列化をしたい場合は課金すると良さそうです。

Ruby並行並列大全」という大層な名前の技術同人誌を書いておきながら大前提に気がついていませんでした。 悔しいです。

ledsun.booth.pm

なぜ気がついたのか、ふりかえっておきます。 冷静にというか、週末を挟んであらためて「DBへの保存処理をスキップ」したつもりの場所を見直しました。 実際はCPUバウンドな処理も一緒にスキップしてました。そら速いわけです。 で、再度「DBへの保存処理をスキップ」して計測したところ性能が全く変わりません。 これを見たときに、はたと気がつきました。 RubyにはGILがあります。 何でここで気がついたのかはよくわかりません。

7月20日「SidekiqのジョブをつかってCPUバウンドな処理を並列化」のアイデアを思いつきました。 それから20日近い期間全く気がついていませんでした。 つまり意識上にはGILの存在は欠片もありませんでした。

どうやら、次のような連想をしたように思います。

  1. 並列化処理からIOバウンドな処理を除いた
  2. CPUバウンドな処理だけ並列化したはず
  3. 速度低下がおきている
  4. CPUバウンドな処理の並列化が速度低下を起こしている
  5. GILじゃん

意識のどこかに「CPUバウンドな処理は高速化しているが、IOバウンドな処理がそれ以上に低速化している」という仮定が強くあったみたいです。 実験によりこの仮定が崩れたため、意識外にあったGILにたどり着いたみたいです。

京都大学OCWの国際経営史

お知らせ|京都大学OCW で閉鎖になることを知りました。 そもそも公開されていることも知りませんでした。 せっかくなので何か見てみようと思って一番最近公開されていた国際経営史という講義を見てみました。

ocw.kyoto-u.ac.jp

いまあつい経営者のイーロン・マスクの話から入ります。 身近でわかりやすい話です。 そこから20世紀の経営の評価方法が経営者から会計にうつりまた経営者に戻ってきた、歴史の話になりました。 なるほど(国際の方はよくわかりませんが)「経営史」ってそういう分野なんだなと、ざっくりしたイメージがつかめて良かったです。

処理を並列化したらデータベースアクセス速度が低下した謎

ActiveJobとSidekiqで実装されたとある非同期処理を高速化しようと試みています。 入力ファイルからデータを読み取ってDBへ保存する処理です。 事前処理がそれなりにあるのでCPUバウンドな処理とI/Oバウンドな処理が半々ぐらいです。

CPUバウンドな処理は並列化すればいいので、1つのジョブの複数のジョブに分割することにしました。 入力ファイルは複数のファイルを圧縮したものです。 処理の単位も解凍後のファイル単位で分割出来ます。 解凍後のファイル毎にジョブをわけることにしました。 解凍後は7万ファイルに分かれることもあるので、ファイル1つずつではなく100個ずつジョブをわけます。

結果、処理速度は約1/3に低下しました。 1秒に3ファイル程度処理できていたのが1ファイルになりました。 1つのジョブで100ファイルを処理します。 ジョブあたり30秒程度で終わることを期待していました。 100秒になりました。

試しにDBへの保存処理をスキップしてみたところ、高速化しています。 CPUバウンドな処理が並列化で高速化したのは予想通りです。 それを上回るレベルでDBへのアクセスが遅くなったのが予想外です。

DBへアクセスするクライアントが増えたので、DBのスイッチング回数が増えて速度が低下するのはありそうな話です。 ですが、動かしているワーカーは4つです。 CPUのコア数が4つしかないので、4つに制限しました。 たかだか4ワーカーからのアクセスでそんなに遅くなるものなのでしょうか? 30秒が35秒になることはあっても、100秒になる気はしません。 ワーカー数を減らしても速度は回復しません。

DBへのアクセスは検索もそれなりに含まれています。 ジョブを分割した分、ジョブが切り替わるたびにSQLキャッシュがリセットされます。 100秒に一回SQLキャッシュをリセットすることがそんなに影響あるでしょうか? 30秒が40秒になることはあっても、100秒になる気はしません。

というわけで謎です。 SQLの検索部分が速くなりそうな作戦はあります。 実施すれば解消されるかもしれません。 されないかもしれません。 仮にされても気持ち悪いです。 気になるところです。

thorのワーニング

現象

あるRailsアプリケーションのRailsのバージョンを6.1.5にアップグレードしたところ、rails consoleを実行すると次のようなthor gemのワーニングがでるようになりました。

ledsun@MSI:~/pubannotation►bin/rails c
Deprecation warning: Expected string default value for '--quiet'; got false (boolean).
This will be rejected in the future unless you explicitly pass the options `check_default_type: false` or call `allow_incompatible_default_type!` in your code
You can silence deprecations warning by setting the environment variable THOR_SILENCE_DEPRECATION.
Deprecation warning: Expected string default value for '--syslog'; got false (boolean).
This will be rejected in the future unless you explicitly pass the options `check_default_type: false` or call `allow_incompatible_default_type!` in your code
You can silence deprecations warning by setting the environment variable THOR_SILENCE_DEPRECATION.
Deprecation warning: Expected string default value for '--logfile'; got true (boolean).
This will be rejected in the future unless you explicitly pass the options `check_default_type: false` or call `allow_incompatible_default_type!` in your code
You can silence deprecations warning by setting the environment variable THOR_SILENCE_DEPRECATION.
Loading development environment (Rails 7.0.3)

原因

原因は以下の3つです。

  1. thor 1.0から上記のワーニングをだすようになった*1
  2. railties 6.1からthor 1.0以上を要求*2
  3. backup gemのthorの使い方が上記のワーニングに該当*3

つまりbackup gemを使ったRails アプリケーションを6.1.5にアップグレードすると上記のワーニングが表示されるようになります。

backup gemはFix CLI option types · backup/backup@16f1f0a · GitHubで対応していそうです。 おそらく5.0.0.beta.3を使えば上記のワーニングはでなくなると思います。 試す前にbackup gemが不要なことに気がついて削除しました。 確かめていません。

再現手順

gem install rails
rails new .
bundle add backup
bin/rails c

MSXって何が良かったの?

ツイッターでつぶやいたらいろいろなご意見が寄せられました。 まとめておきます。

イエメン ハイミ イエメニア ナチュラル

酸味系のナッツみたいな香りがすごいコーヒーでした。 イエメン ハイミ イエメニア ナチュラル - @ledsun blog によると半年前にも似たような感想を抱いていました。 よほど、印象的な味と香りのようです。

【メール便・配達日時指定不可】 7月のおすすめ豆4種類お試しコーヒーメール便 (4袋セット/珈琲解説付き)2022年に含まれていたので飲みました。 100g2000円を超えているので、僕が自分用に単体で買うにはちょっと高嶺すぎる花です。

WezTerm

Windows Terminalに不満はないです。 俺は新しいツールも使いこなせるんだ、という欲望を満たすために wezterm - Wez's Terminal Emulator を使っています。

Windowsにはwingetでインストールできました。 6月27日にインストールを試したときは上手く行きませんでした。 そういうときもあるみたいです。

PS C:\Users\led_l> winget install wez.wezterm
見つかりました WezTerm [wez.wezterm] バージョン 20220624-141144-bd1b7c5d
このアプリケーションは所有者からライセンス供与されます。
Microsoft はサードパーティのパッケージに対して責任を負わず、ライセンスも付与しません。
Downloading https://github.com/wez/wezterm/releases/download/20220624-141144-bd1b7c5d/WezTerm-nightly-setup.exe
コマンドの実行中に予期しないエラーが発生しました:
Download request status is not success.
0x80190194 : ������܂��� (404)�B

初期シェルをWSLに変えてみました。

Launching Programs - Wez's Terminal Emulator

The value of the %COMSPEC% environment variable is used if it is set.

にあるようにCOMSPEC環境変数でwsl.exeのフルパスを指定します。

COMSPEC環境変数の設定例

新規のタブを開くときにディレクトリが引き継がれなくなりました。 WezTermでコマンドプロンプトが開けなくなりました。 便利になったような、なってないような微妙な感じです。

参考

alacritty+tmuxもいいけど、weztermがすごい件

activerecord-importを速くつかう

速度を計測する簡単なスクリプトが手に入りました。 計測しやすくするために、よりシンプルに変更します。

class User < ApplicationRecord
  class << self
    def benchmark_bulk_insert
      # create data
      instances = []
      1_000.times { instances << new(name: 'name', created_at: Time.current, updated_at: Time.current) }

      hashes = []
      1_000.times { hashes << { name: 'name', created_at: Time.current, updated_at: Time.current } }

      values = []
      1_000.times { values << "('name', '#{Time.current.to_s(:db)}', '#{Time.current.to_s(:db)}')" }
      sql = "INSERT INTO users (name, created_at, updated_at) VALUES #{values.join(',')}"

      Benchmark.bm 40 do |r|
        run(r, 'sql') { connection.execute sql }
        run(r, 'insert_all') { insert_all hashes }
        run(r, 'import instances') { import instances }
      end
    end

    def run(r, message)
      transaction do
        r.report(message) { 10.times {  yield } }
        raise ActiveRecord::Rollback
      end
    end
  end
end

実行します。

                                               user     system      total        real
sql                                        0.011214   0.000122   0.011336 (  0.011334)
insert_all                                 0.433187   0.000000   0.433187 (  0.433194)
import instances                           0.397242   0.000000   0.397242 (  0.397241)

データの形式を変える

activerecord-importは、入力データの形式にActiveRecordインスタンスのほかに、次の形式が選べます。

  • ハッシュ
  • Columns And Arrays

試してみましょう。

class User < ApplicationRecord
  class << self
    def benchmark_bulk_insert
      # create data
      instances = []
      1_000.times { instances << new(name: 'name', created_at: Time.current, updated_at: Time.current) }

      hashes = []
      1_000.times { hashes << { name: 'name', created_at: Time.current, updated_at: Time.current } }

      arrays = []
      1_000.times { arrays << ['name', Time.current.to_s(:db), Time.current.to_s(:db)] }

      Benchmark.bm 40 do |r|
        run(r, 'insert_all') { insert_all hashes }
        run(r, 'import instances') { import instances }
        run(r, 'import hashes') { import hashes }
        run(r, 'import columns and arrays') { import [:name, :created_at, :updated_at], arrays }
      end
    end

    def run(r, message)
      transaction do
        r.report(message) { 10.times {  yield } }
        raise ActiveRecord::Rollback
      end
    end
  end
end
                                               user     system      total        real
insert_all                                 0.431001   0.000000   0.431001 (  0.431001)
import instances                           0.446847   0.000000   0.446847 (  0.446847)
import hashes                              0.541531   0.000000   0.541531 (  0.541530)
import columns and arrays                  0.351939   0.000000   0.351939 (  0.351939)

ハッシュは遅くなります。意外です。 Columns And Arraysは早いです。

GitHub - zdennis/activerecord-import: A library for bulk insertion of data into your database using ActiveRecord.

This is the fastest import mechanism and also the most primitive.

と、あるだけのことはあります

バリデーションをスキップする

activerecord-importはバリデーションをスキップできます。

insert_all | Railsドキュメント

直接SQLを実行するのでバリデーションやコールバックはスキップ

insert_allはもともとスキップしています。 試してみましょう。

class User < ApplicationRecord
  class << self
    def benchmark_bulk_insert
      # create data
      instances = []
      1_000.times { instances << new(name: 'name', created_at: Time.current, updated_at: Time.current) }

      hashes = []
      1_000.times { hashes << { name: 'name', created_at: Time.current, updated_at: Time.current } }

      arrays = []
      1_000.times { arrays << ['name', Time.current.to_s(:db), Time.current.to_s(:db)] }

      Benchmark.bm 40 do |r|
        run(r, 'insert_all') { insert_all hashes }
        run(r, 'import instances') { import instances }
        run(r, 'import instances without validations') { import instances, validate: false }
        run(r, 'import hashes without validations') { import hashes, validate: false }
        run(r, 'import c and a without validations') { import [:name, :created_at, :updated_at], arrays, validate: false }
      end
    end

    def run(r, message)
      transaction do
        r.report(message) { 10.times {  yield } }
        raise ActiveRecord::Rollback
      end
    end
  end
end
                                               user     system      total        real
insert_all                                 0.430299   0.000000   0.430299 (  0.430296)
import instances                           0.404580   0.000000   0.404580 (  0.404608)
import instances without validations       0.330146   0.000000   0.330146 (  0.330145)
import hashes without validations          0.408283   0.000000   0.408283 (  0.408279)
import c and a without validations         0.203939   0.000000   0.203939 (  0.203946)

元の倍ぐらい速くなりました。 ActiveRecord.insert_allを使っていて、SQLボトルネックではなくSQL文字列生成がボトルネックの場合、activerecord-importに置き換えるのは有効そうです。

おまけ

バッチサイズをかえる

activerecord-importにはバルクインサートのバッチサイズを指定するオプションがあります。 試してみました。

class User < ApplicationRecord
  class << self
    def benchmark_bulk_insert
      # create data
      instances = []
      1_000.times { instances << new(name: 'name', created_at: Time.current, updated_at: Time.current) }

      hashes = []
      1_000.times { hashes << { name: 'name', created_at: Time.current, updated_at: Time.current } }

      arrays = []
      1_000.times { arrays << ['name', Time.current.to_s(:db), Time.current.to_s(:db)] }

      Benchmark.bm 40 do |r|
        run(r, 'insert_all') { insert_all hashes }
        run(r, 'import instances') { import instances }
        run(r, 'import instances batch_size 1') { import instances, batch_size: 1 }
        run(r, 'import instances batch_size 10') { import instances, batch_size: 10 }
        run(r, 'import instances batch_size 100') { import instances, batch_size: 100 }
        run(r, 'import instances batch_size 1000') { import instances, batch_size: 1000 }
      end
    end

    def run(r, message)
      transaction do
        r.report(message) { 10.times {  yield } }
        raise ActiveRecord::Rollback
      end
    end
  end
end
                                               user     system      total        real
insert_all                                 0.439237   0.000146   0.439383 (  0.439380)
import instances                           0.409890   0.000064   0.409954 (  0.409953)
import instances batch_size 1              1.376732   0.009987   1.386719 (  1.386733)
import instances batch_size 10             0.475238   0.009898   0.485136 (  0.485136)
import instances batch_size 100            0.380801   0.010013   0.390814 (  0.390812)
import instances batch_size 1000           0.376781   0.000000   0.376781 (  0.376780)

バッチサイズを小さくすると遅くなります。 大きくすると多少速くなりますが、デフォルトと大差はありません。 Ruby側の性能に関しては、バッチサイズを変更する意味はなさそうです。