shiny-r-optimize について
既存のShiny for Rアプリを動作を変えずに診断し、高速化するスキルです。不要な処理の削減と再利用を優先し、データアクセスの高速化や非同期タスクを検討します。
主な機能
- 01リアクティブ処理と描画のボトルネック調査
- 02共有リアクティブによる重複計算の削減
- 03キャッシュと入力処理の制御
- 04データ読み込みと描画の改善
- 05動作を保ちながら変更を測定
活用シーン
- 01初回ページ表示の遅延を減らす
- 02入力変更後の再計算を抑える
- 03長時間処理中のアプリ応答性を保つ
- 04コード変更前後の性能を比較
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ posit-dev/ skills --skill shiny-r-optimizeインストール後、Claude Code で/shiny-r-optimizeを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/async-tasks.mdcaching.mddata-loading.mddiagnosis.mdreactive-graph.mdrendering-ui.md詳細
Shiny アプリのパフォーマンス最適化
既存の Shiny for R アプリの動作を変えずに高速化します。典型的なケースでは、開発者が 1 人か 2 人なら問題なく動いていたアプリが、遅すぎる、またはより多くのユーザーをサポートする必要がある状態になっています。アプリのロジックはすべてそのままにし、デプロイ設定の調整よりコードの変更を優先し、書き直しは避けてください。
あらゆる修正で優先する順序:
- 不要な処理をなくします(誰も見ていない出力を計算しない。入力の準備ができていないうちは計算しない)。
- 繰り返される処理を再利用します(共有リアクティブ、キャッシュ)。
- 避けられない処理を高速化します(より高速なデータ形式、ベクトル化コード)。
- ブロックする処理を移動します(非同期処理/バックグラウンドタスク)。最後の手段です。得られるのは並行性であり、処理そのものの速度ではありません。
ワークフロー
1. 問題を把握する
「遅い」とは何を意味するのかを明確にし、達成すべきベンチマークとして書き出します。
- 何が遅く感じますか? 最初のページ読み込みですか? 操作のたびですか? 特定の操作ですか? 複数のユーザーが同時に使う場合だけですか?
- サポートする必要がある同時ユーザー数はどのくらいですか?(1~5 人、約 20 人、数百人?)
- どこで実行されていますか? ローカル、Posit Connect、Posit Connect Cloud、Shiny Server?
- データの規模、保存場所、更新頻度はどのくらいですか?
2. ソースコードの問題点を探して、すぐに修正する
アプリのコード(app.R/server.R/ui.R/モジュール)を読み、よくある問題点を探します。この表にある問題点にはすべて、動作を変えずに安全に修正する方法があります。見つけた問題は、計測する前にすぐに修正してください。この 10 個の変更で、遅い Shiny アプリの大半を改善できます。
| 問題点(検索対象) | 問題となる理由 | 修正方法 |
|---|---|---|
server() 内またはレンダー関数内の read.csv/read_csv/DB クエリ | セッションごと、または無効化のたびに再実行されます | グローバルスコープで一度だけ読み込みます → data-loading.md |
| 複数の出力で同じサブセット化/集計を繰り返している | 変更のたびに計算が重複します | 共有の reactive() を 1 つ作成します → reactive-graph.md |
observeEvent()/observe() 内でレンダラーを定義している | 出力を再作成し、リアクティブモデルと競合します | サーバーの最上位で一度だけ定義し、bindEvent()/req() で制御します → reactive-graph.md |
req() ガードのない高コストな render* | 起動時に空の入力や準備途中の入力で計算します | 本体の先頭に req() を追加します → reactive-graph.md |
| 初期画面に重い出力が多数ある | ページを使える状態にする前に、すべて計算する必要があります | タブ/ナビゲーションに移します。非表示の出力は計算されません → rendering-ui.md |
同一の高コストな呼び出しを繰り返しているのに bindCache() を使っていない | 全員が同じコストを再び負担します | bindCache() を使います → caching.md |
| スライダー/テキスト入力が高コストな処理チェーンに直接つながっている | 1 回のドラッグで何十回も再計算されます | debounce()/throttle()/bindEvent() を使います → reactive-graph.md |
Sys.sleep、長時間のモデル適合、低速な API/DB 呼び出しをリアクティブやレンダー内で直接実行している | すべてのユーザーに対して R プロセス全体がブロックされます | ExtendedTask を使います → async-tasks.md |
通常の値の更新に renderUI() を使っている | 更新のたびに DOM の再構築と入力の再バインドが発生します | update*Input() を使います → rendering-ui.md |
eventReactive/observeEvent 内で isolate() を使わず、広範囲に読み取っている | 必要以上に無効化されます | 依存関係を絞り込みます → reactive-graph.md |
おおよそ次の順番で修正を適用してください(簡単なものから):データ読み込みをグローバルスコープに移動 → req() ガード → 共有リアクティブ → 重い出力をタブに移動 → bindCache() → 頻繁に発火する入力にデバウンスを適用 → renderUI ではなく update*Input() を使用 → 高速なデータ形式を使用(fread/feather/DuckDB)→ 数秒かかる処理に ExtendedTask を使用 → 残る待ち時間にビジーインジケーターを使用(bslib::useBusyIndicators())。
3. 測定 — 門前払いではなく、確認のために
測定は、修正を確認し、スキャンでは検出できない問題を見つけるために行います。 測定によって、実際の対処が後回しになってはいけません。 アプリを実行するのが難しい場合、プロファイリングに時間がかかる場合、または結果が曖昧な場合でも、スキャンで見つかった問題は修正し、検証できなかった内容を明記してください。
# Where does the time go? (CPU/memory profile)
p <- profvis::profvis(shiny::runApp("app.R"))
debrief::pv_print_debrief(p) # debrief turns the profvis output into text
# summaries — hot functions/lines, call paths,
# memory, suggestions — much easier to act on
# than the interactive flame graph. Follow up
# with pv_focus(), pv_hot_lines(),
# pv_suggestions(); compare before/after runs
# with pv_print_compare().
# Is implementation A faster than B?
bench::mark(A(x), B(x), check = FALSE)
エコシステムには、あなたが実行するのではなく、ユーザーが使うツールが2つあります。reactlogでは、ユーザーが自分のアプリを操作し、リアクティブグラフの可視化を調べて、リアクティブ式がどのように連携するかを理解します。同じことは通常、コードを読んでリアクティブグラフを自分で再構築すれば、より低コストで、さらに深く把握できます。shinyloadtestは、デプロイ済みアプリに対する負荷テストです。ユーザーが、サポートする予定のユーザー数にインフラが対応できるか確認したい場合に、最適化の最後に実行します。
ツールの選び方、各ツールの出力の読み方、負荷テスト結果の解釈については、diagnosis.mdを参照してください。「どのコードが遅いか」の調査にはprofvis/debriefを、2つの実装の比較にはbenchを使います。reactlogはユーザーに任せ、shinyloadtestはデプロイ済みアプリの処理能力を確認するために使ってください。
4. スキャンで検出できなかった問題を診断する
| 症状 | 主な原因 | まず確認する箇所 |
|---|---|---|
| 初回のページ読み込みが毎回遅い | server() 内でのデータ読み込みや前処理、空の入力で起動時に出力を計算、セッションごとの重いセットアップ | data-loading.md、reactive-graph.md |
| 1つの操作で必要以上に多くの処理が再計算される | 依存関係の範囲が広すぎる、計算の重複、キャッシュがない、入力の更新頻度が高すぎる | reactive-graph.md、caching.md |
| 特定の操作に数秒かかり、アプリがフリーズする | flushサイクル内の長時間にわたる同期処理 | async-tasks.md |
| ユーザーが1人なら速いが、複数人だと性能が低下する | ブロッキング処理がプロセスを占有する、同一の計算を繰り返す、ユーザーごとに再読み込みする | caching.md、async-tasks.md、diagnosis.md |
| プロットやテーブルの表示・更新に時間がかかる | 描画処理が重い、全体を再描画している、大きなデータに対してserver = FALSEのDTを使っている | rendering-ui.md |
| ダウンロードやアップロードの動作がおかしい、または再計算が発生する | downloadHandlerによる再計算、maxRequestSizeの上限 | data-loading.md |
| デプロイ後にのみ遅くなる | プロセスごとのグローバル変数やキャッシュ、スケジューラーの設定 | diagnosis.md |
5. 修正はコストの低いものから
診断されたカテゴリに応じて手順を進め、計測結果から十分に速いと判断できた時点で止めます。どの手順でも、アプリの動作は維持されます。
リアクティブな再計算 → reactive-graph.md
req()ガードを設定します。準備ができていない入力に対しては、何も計算されません。reactive()を共有し、派生値をそれぞれ一度だけ計算します。- 依存関係を絞ります。更新ループには
bindEvent()/eventReactive()、isolate()、freezeReactiveValue()を使用します。 - 更新頻度の高い入力には
debounce()/throttle()を使用します。更新が重なる場合は、bslib::input_task_button()(actionButton()の置き換えとして使えるもの)の後ろでバッチ処理を制御します。 - タイマーを修正します。
on.exit(invalidateLater(...))と、低コストなcheckFuncを使うreactivePoll()を使用します。
同一処理の繰り返し → caching.md
- コストの高いリアクティブ式またはレンダラーに
bindCache()を適用します。キーには、本文内で読み取るすべてのリアクティブ値を含める必要があります。 - 後続処理にも利点がある場合は、プロットではなくデータをキャッシュします。
- コストの高い純粋関数(DB クエリ、モデルのフィッティング)には
memoise()を使用します。 - スコープは意図的に選びます。ユーザー間で共有する場合は
"app"(情報漏えいに注意)、ユーザー固有のデータには"session"、プロセス間や再起動後も共有するにはcachem::cache_disk()を使用します。
ブロッキング処理 → async-tasks.md
- 排除とキャッシュを十分に試した後に限り、
mirai()(またはfuture_promise())とbind_task_button()を使ってExtendedTaskを使用します。 - ワーカーバックエンドは必須です。mirai の
daemons()またはfuture::plan(multisession)を設定します。future のデフォルトであるplan(sequential)では、すべての処理が引き続きブロッキングします。 - ワーカー内ではリアクティブ値を読み取らず、
invoke()経由で値を渡します。
データアクセス → data-loading.md
- データはグローバルスコープに読み込みます。レンダー関数内でデータを読み込んではいけません。
read.csvよりfread/read_csv(vroom を使用)を使い、CSV/RDS の往復よりfeather/qs/fstを使います。- データが RAM の容量を超える場合や、一部のデータだけが必要な場合は、DuckDB と parquet、または
poolとdbplyrのプッシュダウンを使用します。
レンダリングと UI → rendering-ui.md
- タブ/ナビゲーションに重い出力を配置し、非表示の出力が計算されないようにします。
- ボタン(
req()とbindEvent())の後ろで、先行して生成する出力を制御します。 - DT のサーバーサイド処理と
replaceData()、大規模な plotly には WebGL/バンドル、処理中にはビジーインジケーターを使用します。
6. 検証して反復する
変更は一度に一つずつ適用し、そのたびに再計測してベースラインの数値と比較します。観測可能な動作は同一に保ちます。機能が削られた「高速な」アプリは、高速になったとは言えません。作業を終える前に、代表的な出力を一つ選び、元のアプリと変更後のアプリ(または生データ)から値を再計算して、一致することを確認します。これにより、動作の意図しない変化を低コストで検出できます。
報告の完全性:引用するすべての計測結果(スクリプトの出力、タイミングログ)を、アプリのプロジェクト内の outputs/measurements/ に保存します。フォルダーが存在しない場合は作成してください。最終レポートで主張する数値と仕組みを、それぞれ保存した成果物と照合します。成果物で確認できない主張は、断定せず、その旨をレポートに記載してください。
<app-project>/
outputs/
measurements/ # the audit trail for the final report: one file per
# measurement (profvis output, bench results, timings),
# named for the fix or code path it measured
基本原則
- 明らかな変更はまとめ、不確実な変更は切り分けます。 データ読み込みをグローバルスコープに移す、重複したフィルタリングを統合する、
renderUIを置き換えるといった、安全で効果が予測できる修正はまとめて適用し、一度に再計測できます。どの変更が効果をもたらしたか本当に不明な場合、または変更に期待した効果がなかった場合に限り、一度に一つずつ変更してください。効果の帰属を特定するために追加の計測を行う価値があるのは、その場合だけです。 - 動作を維持します。 UI の変更は、体感性能を高める手段(スピナー、タスクボタン、タブ)に限定し、必ずユーザーに伝えてください。出力が同じでも、タイミングの意味を変える変更も同様です(
debounce()は意図的に遅延を追加し、bindCache()/memoise()はセッション間で結果を共有します)。 - 最新の API を優先します。
renderCachedPlot()や生のeventReactiveの組み合わせよりbindCache()/bindEvent()を、手作業で組み立てた promises よりExtendedTaskを優先してください。レガシーな同等機能は、アプリですでに使用されている場合にのみ説明します。 - 非同期処理より先にキャッシュを検討します。 キャッシュによって処理が 10~100 倍になることもよくあります。非同期処理は並行性を高めるだけで、シリアライズとデバッグのコストがかかります。
- 依存関係を追加する前に確認してください(duckdb、data.table、mirai など)。
参考資料
診断で参照先が示された場合に必要な資料だけを読んでください。最初からすべてに目を通す必要はありません。
- diagnosis.md — profvis/debrief、bench、読み方のガイド付き負荷テスト(shinyloadtest/shinycannon)、マルチユーザーのプロセスモデル、Connect および Posit Connect Cloud の容量調整項目。
- reactive-graph.md — 共有リアクティブ、
req()、isolate()、bindEvent()、freezeReactiveValue()、debounce()/throttle()、タイマー、依存関係の集中を解消する方法。 - caching.md —
bindCache()のキー規則とスコープ、キャッシュバックエンド、memoise()、キャッシュ前のチェックリスト。 - async-tasks.md — ブロッキングがパフォーマンスを損なう理由、
ExtendedTask/mirai/future_promiseのパターンと厳守事項。 - data-loading.md — 一度だけ読み込むパターン、ファイル形式のベンチマーク、DuckDB/parquet、
pool/dbplyr、ダウンロードとアップロード。 - rendering-ui.md — タブによる出力のサスペンド、
renderUIの代替手段、プロット/テーブルのレンダリングコスト、体感性能。