現象
表題がわかりにくいですね。 動画で再現するとこうです。
lsコマンドを実行すると、結果が表示されます。 すこし待つとプロンプトが表示されます。
fishのversionは 3.3.1 です。
今、原因の候補と考えているもの
プロンプトの右側にgitの情報を表示しています。 この情報取得に時間が掛かっているのでしょうか?
表題がわかりにくいですね。 動画で再現するとこうです。
lsコマンドを実行すると、結果が表示されます。 すこし待つとプロンプトが表示されます。
fishのversionは 3.3.1 です。
プロンプトの右側にgitの情報を表示しています。 この情報取得に時間が掛かっているのでしょうか?
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
という変数があります。
RailsはConcurrent Rubyを使っています。
executorと言うの名前からすると、
Class: Concurrent::ThreadPoolExecutor — Concurrent Ruby か、近いものを使っていそうです。
並列化するためにスレッドを使っていそうです。
また並列数に上限を持たせるためにスレッドプールを使っていることも予想できます。
Railsのコネクションプールはスレッドに依存しているので、この辺どうなってんだ?というのはさらなる疑問です*1。
次にActiveRecord::FutureResultクラスの存在に気がつきます。 なるほど Future パターン - Wikipedia です。 次のように動きそうです。
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はスレッドを考慮していないので、あれをイメージするとそれはそれで混乱する気はします。
昔使ってたDelayed::Jobがなくなっていた
と書いたら次のような情報を頂きました。
ちなみに、Railsガイドの英語版を見ると、Delayed::Jobの記載はありますね~https://t.co/mSD6s4Gz5N
— thinkAmi (@thinkAmi) August 14, 2022
修正するためにプルリクエスト ActiveJobのバックエンドの一覧に英語版にあるDelayed JobとQueがありませんでした by ledsun · Pull Request #1272 · yasslab/railsguides.jp · GitHub を作りました。 マージもデプロイもされています。Railsガイドの日本語訳は修正されています。
Railsガイドの日本語訳へのPRを作るのは簡単なのでオススメです。
という感じです。ブラウザだけで作れます。
僕はよく日本語版のRailsガイドを読みます。 一回なおせば次からは考えなくちゃいけないことが1つ減るので便利です。
咳さんがどこかですすめてたのを見て2018年に買った本です。 4年の熟成を経て、読んでみました。
原著は1990年発行なので、30年前の本です。 大学の教科書向けの本です。 教科書系の本によくあるように前半は理論というか概念の説明です。 なんでこういう構成なのか不思議でしたが、理論とか概念って30年経っても役立つ内容だからなんですね。 それとも(アンダース・エリクソンのいう)心的イメージを伝えようとしているのかもしれません。
並列のパラダイムを次の3つにわけて整理してます。
僕が今やりたい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ガイドをみると次のようなラインナップです。
昔使ってたDelayed::Jobがなくなっていたり、Good Jobという見慣れない奴がいたり状況は変わっているみたいです。 おしゃべりしながら、次のようなアイデアが得られました。
後者は自分になかった発想で面白かったです。
処理を並列化したらデータベースアクセス速度が低下した謎 - @ledsun blog で「SidekiqのジョブをつかってCPUバウンドな処理を並列化したのに速度低下しておかしい。」みたいなことを書きました。 冷静に考えたら並列化されてません。 RubyのスレッドにはGlobal interpreter lock(GIL)*1があります。 ワーカーがスレッドで動くSidekiqでは、CPUバウンドな処理の並列化は出来ないのでした。 並列化するにはワーカーのプロセスをわける必要があります。
次の画像はhttps://sidekiq.org/products/enterprise.htmlにあるSidekiq EnterpriseのFeaturesです。
Multi-Core Processingが含まれています。 Sidekiqを使ってCPUバウンドな処理の並列化をしたい場合は課金すると良さそうです。
「Ruby並行並列大全」という大層な名前の技術同人誌を書いておきながら大前提に気がついていませんでした。 悔しいです。
なぜ気がついたのか、ふりかえっておきます。 冷静にというか、週末を挟んであらためて「DBへの保存処理をスキップ」したつもりの場所を見直しました。 実際はCPUバウンドな処理も一緒にスキップしてました。そら速いわけです。 で、再度「DBへの保存処理をスキップ」して計測したところ性能が全く変わりません。 これを見たときに、はたと気がつきました。 RubyにはGILがあります。 何でここで気がついたのかはよくわかりません。
7月20日「SidekiqのジョブをつかってCPUバウンドな処理を並列化」のアイデアを思いつきました。 それから20日近い期間全く気がついていませんでした。 つまり意識上にはGILの存在は欠片もありませんでした。
どうやら、次のような連想をしたように思います。
意識のどこかに「CPUバウンドな処理は高速化しているが、IOバウンドな処理がそれ以上に低速化している」という仮定が強くあったみたいです。 実験によりこの仮定が崩れたため、意識外にあったGILにたどり着いたみたいです。
お知らせ|京都大学OCW で閉鎖になることを知りました。 そもそも公開されていることも知りませんでした。 せっかくなので何か見てみようと思って一番最近公開されていた国際経営史という講義を見てみました。
いまあつい経営者のイーロン・マスクの話から入ります。 身近でわかりやすい話です。 そこから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の検索部分が速くなりそうな作戦はあります。 実施すれば解消されるかもしれません。 されないかもしれません。 仮にされても気持ち悪いです。 気になるところです。
ある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つです。
つまり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
ツイッターでつぶやいたらいろいろなご意見が寄せられました。 まとめておきます。
・ゲーム機感覚のカートリッジ
— さけいくら (@sakeikuratarako) July 27, 2022
・テレビに繋がる=安価
・機体の選択肢豊富
とかかなぁ。
友達が持ってたけどゲーム機になってたな。 https://t.co/r4sXTcaIN0
うちの親は「標準規格」が魅力で買ってきました。
— 山本 薫(やまもと かおる) (@yamamotokaoru77) July 27, 2022
気軽に打ち込めるBASICだけでサクサクのスプライト処理のゲームを作れるのが楽しみでした…
— A-Honecker (@HoneckerAlpha) July 27, 2022
ドットキャラ作成の勉強にもよかったと思いました。
当時はですが… https://t.co/gfEBPzyyFG
自分は親戚のお古のPC-88を持っていましたが、スプライトが出るまでのハードルの低さが段違いでした。
— Yuji Naito (rot-z) (@rotz32670916) July 27, 2022
細かいことは忘れましたが、論理演算する処理を書かなくても透明色処理がさっくりできた記憶があって、ゲーム作りたかった自分にはすごい魅力的だった。。。 https://t.co/AT1llD2nIq
11歳のときにMSX2を買ったのはやっぱり価格(パナのFS-A1はFDDもFM音源もないけど3万円しなかった)だなあ。友達が持ってたMSX1との上位互換性も理由の一部だけど。 https://t.co/bnHvHdzuN7
— Kentaro Inomata (@matarillo) July 26, 2022
酸味系のナッツみたいな香りがすごいコーヒーでした。 イエメン ハイミ イエメニア ナチュラル - @ledsun blog によると半年前にも似たような感想を抱いていました。 よほど、印象的な味と香りのようです。
【メール便・配達日時指定不可】 7月のおすすめ豆4種類お試しコーヒーメール便 (4袋セット/珈琲解説付き)2022年に含まれていたので飲みました。 100g2000円を超えているので、僕が自分用に単体で買うにはちょっと高嶺すぎる花です。
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のフルパスを指定します。
新規のタブを開くときにディレクトリが引き継がれなくなりました。 WezTermでコマンドプロンプトが開けなくなりました。 便利になったような、なってないような微妙な感じです。
速度を計測する簡単なスクリプトが手に入りました。 計測しやすくするために、よりシンプルに変更します。
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インスタンスのほかに、次の形式が選べます。
試してみましょう。
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は早いです。
This is the fastest import mechanism and also the most primitive.
と、あるだけのことはあります
activerecord-importはバリデーションをスキップできます。
直接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側の性能に関しては、バッチサイズを変更する意味はなさそうです。