mcpField
S
shiny-py-optimize

shiny-py-optimize

github.com/posit-dev开发55148 次安装今天更新
代码仓库

关于 shiny-py-optimize

用于诊断和加速现有 Shiny for Python 应用的技能,同时保持应用行为不变。它针对共享 asyncio 事件循环,优先消除或复用计算,再将阻塞任务移出。

主要功能

  1. 01检查响应式与渲染瓶颈
  2. 02通过响应式计算减少重复工作
  3. 03使用缓存和事件门控输入
  4. 04改进数据加载与表格渲染
  5. 05测量改动并保持行为一致

适用场景

  1. 01减少每个会话的数据加载延迟
  2. 02减少输入变化后的重复计算
  3. 03避免长时间操作阻塞所有会话
  4. 04比较代码改动前后的性能

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/posit-dev/skills --skill shiny-py-optimize

安装后在 Claude Code 中输入/shiny-py-optimize确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
async-tasks.md
caching.md
data-loading.md
diagnosis.md
reactive-graph.md
rendering-ui.md

详细介绍

优化 Shiny for Python 应用的性能

在不改变现有 Shiny for Python 应用功能的前提下提升其速度。典型情况是:应用原本供一两位开发者使用时运行良好,现在却变得太慢,或需要支持更多用户。保留所有应用逻辑;优先修改代码,而非调整部署;避免重写。

每项优化的优先顺序:

  1. 消除不必要的工作(不要计算无人查看的输出;输入尚未就绪时不要进行计算)。
  2. 复用重复的工作(共享的 @reactive.calc、模块级缓存)。
  3. 加速不可避免的工作(更快的数据格式、polars、下推)。
  4. 转移阻塞性工作(使用 @reactive.extended_task)——最后的手段;它带来的是并发能力,而非原始速度。

大多数 Python 专属建议都基于一个事实:**Shiny 会在单个 asyncio 事件循环中串行运行响应式函数——绝不会并发运行,即使函数是 async def,甚至同一进程中的不同会话也是如此。**任何响应式函数中的阻塞调用都会冻结该进程中的所有会话。仅使用 async/await 永远无法解决这个问题;只有 @reactive.extended_task 可以。

工作流程

1. 了解问题

明确“慢”具体指什么,并将其设定为要超越的基准:

  • **哪些操作感觉很慢?**首次加载页面?每次交互?某个特定操作?只有多人同时使用时才会慢?
  • 需要支持多少并发用户?(1–5 人、约 20 人、数百人?)
  • **应用运行在哪里?**本地、Posit Connect、Posit Connect Cloud,还是自托管(在 nginx 后运行 uvicorn)?
  • 数据有多大、存放在哪里,以及多久更新一次?
  • Core 还是 Express?(from shiny import ... 与 from shiny.express import ...)。Express 顶层代码会在每个会话中运行一次——这是一个常见且不易察觉的陷阱(data-loading.md)。
2. 扫描代码中的异味,并立即修复

阅读应用代码(app.py、各模块),查找常见的代码异味。此表中的每种异味都有安全且不改变行为的修复方法:在测量之前,立即应用找到的每项修复。这些改动能解决绝大多数运行缓慢的 Shiny for Python 应用问题。

异味(搜索内容)影响修复方法
pd.read_csv/数据库查询位于 server() 或渲染函数内部每个会话或每次失效时都会重新运行每个进程只加载一次:Core 放在模块作用域;Express 放在导入的模块中 → data-loading.md
Express app.py 顶层加载数据Express 顶层代码会为每个会话运行一次将加载操作移到导入的模块(shared.py)中 → data-loading.md
多个输出中重复进行相同的子集筛选/聚合每次变化都会重复计算提取一个共享的 @reactive.calc → reactive-graph.md
耗时的 @render.* 没有 req() 保护启动时会使用空输入或尚未就绪的输入进行计算在函数体开头调用 req() → reactive-graph.md
落地视图上有许多耗时的输出页面可用前必须完成所有计算移到标签页/导航中——隐藏的输出会暂停运行 → rendering-ui.md
多个会话中重复执行相同的耗时操作每位用户都要重复付出相同的计算成本使用模块级缓存(functools.lru_cache、diskcache)——没有内置的 bindCache 等效功能 → caching.md
滑块/文本输入直接触发耗时的计算链拖动一次就会导致数十次重新计算用 @reactive.event() 按钮控制;使用防抖辅助函数 → reactive-graph.md
在计算/副作用/渲染中内联调用 time.sleep、同步 requests、耗时的 pandas/DuckDB 调用会阻塞进程中所有用户的事件循环使用 @reactive.extended_task → async-tasks.md
添加 async def 响应式函数,期望获得加速即使使用异步代码,Shiny 仍会串行运行响应式代码仅对仅支持异步的 API 使用异步;使用 ExtendedTask 实现非阻塞 → async-tasks.md
将 @render.ui 用于常规值更新每次更新都会重建 DOM 并重新绑定输入使用 ui.update_*() → rendering-ui.md
对大型数据框使用 @render.tablepandas Styler 会将数据完整序列化为 HTML使用 @render.data_frame 和 render.DataGrid → rendering-ui.md

按以下大致顺序应用修复(从成本最低的开始):将数据加载移到模块作用域(Express 则移到导入的模块中)→ 添加 req() 保护 → 使用共享的 @reactive.calc → 将耗时输出移到标签页 → 使用模块级缓存 → 使用 @reactive.event() 控制频繁触发的输入 → 使用 ui.update_*() 替代 @render.ui → 使用更快的数据格式(polars、parquet、DuckDB)→ 对耗时数秒的操作使用 @reactive.extended_task → 为剩余的等待添加忙碌指示器(ui.busy_indicators)。

3. 进行测量——用于确认,而非设限

测量用于确认修复效果,并发现扫描无法发现的问题。**绝不能让测量挤占实际行动。**如果应用难以运行、追踪耗时过长,或结果含糊不清,仍应应用扫描发现的修复,并说明哪些内容无法验证。

Shiny(≥ 1.6)内置了用于追踪响应式执行的 OpenTelemetry——这是诊断“哪些代码运行缓慢”的主要工具:

uv pip install "shiny[otel]"
SHINY_OTEL_COLLECT=reactivity \
opentelemetry-instrument --traces_exporter console --logs_exporter console \
    --metrics_exporter none shiny run app.py

与应用交互,然后查看跨度层级:每个运行缓慢的 reactive.calc/输出/扩展任务都会显示其源文件和行号。需要查看 CPU 火焰图时,可用 py-spy 对运行中的进程采样;用 timeit 比较两种实现。要了解“此应用可支持多少用户”,请使用 shinyloadtest 对已部署的应用进行负载测试(录制 → 使用 N 个工作进程回放 → 生成报告)。工具选择与阅读指南:diagnosis.md。

4. 诊断扫描未发现的问题
症状最可能的原因从这里开始
首次加载页面很慢(每个会话都如此)按会话加载数据(尤其是 Express 顶层代码);启动时用空输入计算输出;繁重的会话初始化data-loading.md、reactive-graph.md
一次交互触发了过多重新计算依赖范围过宽;重复计算;输入更新频繁reactive-graph.md、caching.md
某个操作耗时数秒,且应用冻结——影响所有人在共享事件循环中执行了阻塞操作async-tasks.md
单个用户使用时很快,用户增多后性能下降阻塞操作;重复进行相同计算;每用户重新加载;进程数过少caching.md、async-tasks.md、diagnosis.md
图表/表格显示或更新缓慢渲染开销大;使用 @render.table;完整重绘;Plotly 数据未经聚合rendering-ui.md
下载/上传行为异常或触发重新计算下载处理程序每次点击都重新计算;在渲染器中解析上传文件data-loading.md
仅在部署后变慢按进程隔离的全局变量和缓存;进程/连接数限制diagnosis.md
5. 修复,先从成本最低的改动开始

针对已诊断的问题类别,按阶梯逐步处理;测量结果表明速度足够时即可停止。每一步都能保留应用行为。

响应式重新计算 → reactive-graph.md

  1. 使用 req() 守卫——输入未就绪时不进行计算。
  2. 使用共享的 @reactive.calc——每次变更只计算一次派生值。
  3. 缩小依赖范围:用于更新循环的 @reactive.event()、reactive.isolate()、reactive.value.freeze()。
  4. 对频繁更新的输入进行防抖(辅助工具——截至 Shiny 1.8 尚未内置);当更新会累积时,用 ui.input_task_button() 控制整批更新(可直接替换 ui.input_action_button())。
  5. 修正定时器:将 invalidate_later() 放在最后安排,或使用带有廉价检查函数的 @reactive.poll()。

重复进行相同的工作 → caching.md

  1. @reactive.calc 会为每个会话缓存最新值——无需额外操作。
  2. 在模块级纯函数(数据库查询、模型拟合)上使用 @functools.lru_cache——键必须涵盖影响结果的所有输入。
  3. 当结果需要在进程间共享、在重启后保留,或数据会随时间变化时,使用 diskcache/cachetools.TTLCache。
  4. 将缓存结果视为只读共享状态——修改前先返回副本。

阻塞操作 → async-tasks.md

  1. 只有在已尝试消除和缓存之后,才使用 @reactive.extended_task,搭配 ui.input_task_button + @ui.bind_task_button。
  2. 任务中的同步 I/O → asyncio.to_thread(...);CPU 密集型工作 → 模块级 ProcessPoolExecutor(普通线程对 CPU 密集型工作无济于事)。
  3. 任务主体内不要读取响应式值;通过 .invoke() 传入值。

数据访问 → data-loading.md

  1. 在进程作用域加载数据(Core 中为模块作用域;Express 中为导入的模块);切勿在渲染函数中读取数据。
  2. 对较大的数据使用 polars 而非 pandas;使用 parquet 而非 CSV。
  3. 数据超出 RAM 容量,或只需使用部分数据时,使用 DuckDB 读取 parquet,或采用惰性执行 pl.scan_parquet(...).filter(...).collect()。

渲染与 UI → rendering-ui.md

  1. 将开销大的输出放在标签页/导航区域中,使隐藏的输出保持挂起状态。
  2. 使用按钮控制可能提前计算的输出(req() + @reactive.event())。
  3. 表格使用 @render.data_frame + update_data();大型 Plotly 图表使用 WebGL/聚合;运行期间显示忙碌指示器。
6. 验证并迭代

每次只做一项更改后重新测量,并与基准数据进行比较。保持可观察到的行为完全一致——删掉某个功能的应用并不算“更快”。完成前,从原始应用和修改后的应用(或原始数据)中重新计算一个有代表性的输出值,并确认两者一致——这能以较低成本发现无声的行为偏移。

报告完整性:将所有引用的测量数据(跟踪输出、计时日志)保存到应用项目中的 outputs/measurements/ 下;如果文件夹不存在,就创建它;并根据保存的产物核对最终报告中声称的每个数字和机制。如果某项结论无法通过产物验证,就在报告中说明,而不是直接断言。

<app-project>/
  outputs/
    measurements/    # the audit trail for the final report: one file per
                     # measurement (trace output, py-spy profile, timings),
                     # named for the fix or code path it measured

基本原则

  • 明显的问题批量处理;不确定的问题单独处理。 对效果可预测的安全修复——将数据加载移到模块作用域、合并重复的筛选逻辑、替换 @render.ui——可以一并应用,然后重新测量。只有在确实不清楚哪项更改起了作用,或某项更改未达到预期效果时,才一次只改一项;只有在这种情况下,额外的测量轮次才值得投入。
  • 保持行为不变。 UI 更改仅限于改善感知性能的辅助功能(加载指示器、任务按钮、标签页),并且必须告知用户;同样,改变时序语义的更改也必须告知用户,即使输出完全相同(防抖会有意增加延迟;缓存会跨会话共享结果)。
  • async 不是性能修复方案。 async def 响应式函数的存在是为了调用仅支持 async 的 API。它们不会并发运行,也不会让应用保持响应。非阻塞意味着使用 @reactive.extended_task。
  • 先缓存,再考虑 async。 缓存通常能带来 10–100 倍的提升;async 只增加并发能力,还会带来序列化和调试成本。
  • 绝不要通过多工作进程服务扩展。 uvicorn --workers >1、WEB_CONCURRENCY 环境变量以及多工作进程 Gunicorn 都会无声地破坏 Shiny(需要粘性会话,diagnosis.md)。应通过粘性会话负载均衡,在多个单工作进程后扩展。
  • 添加依赖项前先征求同意(polars、duckdb、diskcache、py-spy……)。

参考资料

仅在需要时阅读诊断结果所指向的参考资料——不要一开始就全部阅读。

  • diagnosis.md — OpenTelemetry 跟踪、py-spy、计时检查、使用 shinyloadtest 进行负载测试;单进程 asyncio 模型;粘性会话和部署容量调节参数。
  • reactive-graph.md — 共享计算、req()、reactive.isolate()、@reactive.event()、更新循环和 reactive.value.freeze()、防抖辅助函数、计时器和轮询。
  • caching.md — @reactive.calc 会缓存和不会缓存的内容;模块级 lru_cache/diskcache/cachetools 模式及其严格规则。
  • async-tasks.md — 阻塞为何会影响所有人、@reactive.extended_task 模式及其严格规则、线程与进程池。
  • data-loading.md — 进程作用域加载(Core 和 Express)、polars/parquet/DuckDB 惰性加载、数据库、下载和上传。
  • rendering-ui.md — 通过标签页暂停输出、@render.ui 的替代方案、图表/表格渲染开销、感知性能。

评论

支持 Markdown