From a6e9987be6c7da1d1d12173b32dc73cc619b0ad8 Mon Sep 17 00:00:00 2001 From: shy3130 Date: Sun, 12 Jul 2026 14:08:24 +0800 Subject: [PATCH] fix(packaging): support legacy Polars CPUs --- .env.example | 5 +++-- .github/workflows/release.yml | 18 +++++++++++++++++- dev.ps1 | 34 ++++++++++++++++++++++++++++++---- dev.sh | 26 ++++++++++++++++++++++---- docs/configuration.md | 4 ++-- docs/deployment.md | 7 ++++++- packaging/tickflow.spec | 24 +++++++++++------------- 7 files changed, 91 insertions(+), 27 deletions(-) diff --git a/.env.example b/.env.example index 6ce58f6..0fac35d 100644 --- a/.env.example +++ b/.env.example @@ -20,8 +20,9 @@ LOG_LEVEL=INFO # 建议至少 6 位。.env 文件权限保持 600 且不要提交到 Git。 AUTH_PASSWORD= -# Optional Docker build extras. Set to legacy-cpu on older VPS hosts -# without AVX2/FMA support. +# Optional backend dependency extras for Docker and ./dev.sh / .\dev.ps1. +# Set to legacy-cpu on older CPUs without AVX2/FMA support. +# Combine with backtest as: legacy-cpu backtest BACKEND_EXTRAS= # ===== Data ===== diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8608511..c56eb0e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -127,7 +127,7 @@ jobs: uv run python ../packaging/generate_icon.py - name: 打包 (PyInstaller) - # 在 backend 目录跑 (用该目录的 venv: pywebview/polars 等)。 + # 在 backend 目录跑 (用该目录的 venv: pywebview/polars 等依赖)。 # 产物输出到 backend/dist/ (PyInstaller 默认路径), 与 packaging/tickflow.iss # 第 78 行的 Source 路径 (..\backend\dist\TickFlowStockPanel\*) 保持一致。 # SPECPATH 由 spec 文件位置决定 (packaging/tickflow.spec → 项目根), @@ -135,6 +135,22 @@ jobs: working-directory: backend run: uv run pyinstaller ../packaging/tickflow.spec --noconfirm + - name: 检查 Polars 兼容内核 (仅 Windows) + if: matrix.platform == 'windows' + shell: pwsh + run: | + $runtimeRoot = 'backend/dist/TickFlowStockPanel/_internal' + $required = @( + '_polars_runtime_32/_polars_runtime.pyd', + '_polars_runtime_compat/_polars_runtime.pyd' + ) + $missing = $required | Where-Object { + -not (Test-Path (Join-Path $runtimeRoot $_)) + } + if ($missing) { + throw "PyInstaller output is missing Polars runtime(s): $($missing -join ', ')" + } + - name: 安装 Inno Setup (仅 Windows) if: matrix.platform == 'windows' # Inno Setup 6: 把 PyInstaller 产出封装成单文件安装包 diff --git a/dev.ps1 b/dev.ps1 index c9bc16a..730c95e 100644 --- a/dev.ps1 +++ b/dev.ps1 @@ -108,11 +108,37 @@ function Free-Port($name, $port) { Free-Port 'backend' $BackendPort Free-Port 'frontend' $FrontendPort -# ===== 3. First-time dependency install ===== -if (-not (Test-Path (Join-Path $BackendDir '.venv'))) { - Log-Info 'first run - installing Python deps (1-2 min)...' +# ===== 3. Dependency install ===== +# Match Docker's whitespace-separated BACKEND_EXTRAS behavior so old CPUs can +# select Polars' rtcompat runtime before the backend starts. +$BackendExtras = $env:BACKEND_EXTRAS +if (-not (Test-Path Env:BACKEND_EXTRAS)) { + $envFile = Join-Path $Root '.env' + if (Test-Path $envFile) { + foreach ($line in Get-Content $envFile) { + if ($line -match '^\s*BACKEND_EXTRAS\s*=\s*(.*?)\s*$') { + $BackendExtras = $Matches[1] + break + } + } + } +} + +$BackendExtraArgs = @() +if (-not [string]::IsNullOrWhiteSpace($BackendExtras)) { + foreach ($extra in ($BackendExtras -split '\s+' | Where-Object { $_ })) { + $BackendExtraArgs += '--extra', $extra + } +} + +if (-not (Test-Path (Join-Path $BackendDir '.venv')) -or $BackendExtraArgs.Count) { + if ($BackendExtraArgs.Count) { + Log-Info "syncing Python deps with extras: $BackendExtras" + } else { + Log-Info 'first run - installing Python deps (1-2 min)...' + } Push-Location $BackendDir - try { & uv sync } finally { Pop-Location } + try { & uv sync @BackendExtraArgs } finally { Pop-Location } if ($LASTEXITCODE -ne 0) { Log-Err 'uv sync failed'; exit 1 } Log-Ok 'backend deps installed' } diff --git a/dev.sh b/dev.sh index 4304b36..b8deca7 100755 --- a/dev.sh +++ b/dev.sh @@ -16,6 +16,20 @@ FRONTEND_DIR="$ROOT/frontend" BACKEND_PORT="${BACKEND_PORT:-3018}" FRONTEND_PORT="${FRONTEND_PORT:-3011}" +# Match Docker's BACKEND_EXTRAS behavior so old CPUs can select Polars' +# rtcompat runtime before the backend starts. An exported value wins over .env. +if [[ -z "${BACKEND_EXTRAS+x}" && -f "$ROOT/.env" ]]; then + BACKEND_EXTRAS="$(awk '/^[[:space:]]*BACKEND_EXTRAS[[:space:]]*=/ {sub(/^[^=]*=/, ""); gsub(/^[[:space:]]+|[[:space:]]+$/, ""); print; exit}' "$ROOT/.env")" +fi +BACKEND_EXTRAS="${BACKEND_EXTRAS:-}" +BACKEND_EXTRA_ARGS=() +if [[ -n "$BACKEND_EXTRAS" ]]; then + read -r -a backend_extras <<< "$BACKEND_EXTRAS" + for extra in "${backend_extras[@]}"; do + BACKEND_EXTRA_ARGS+=(--extra "$extra") + done +fi + BLUE='\033[0;34m' GREEN='\033[0;32m' RED='\033[0;31m' @@ -71,10 +85,14 @@ free_port() { free_port backend "$BACKEND_PORT" free_port frontend "$FRONTEND_PORT" -# ===== 3. 首次依赖安装 ===== -if [ ! -d "$BACKEND_DIR/.venv" ]; then - info "后端首次启动 — 安装 Python 依赖(约 1-2 分钟)..." - ( cd "$BACKEND_DIR" && uv sync ) +# ===== 3. 依赖安装 ===== +if [ ! -d "$BACKEND_DIR/.venv" ] || [ "${#BACKEND_EXTRA_ARGS[@]}" -gt 0 ]; then + if [ "${#BACKEND_EXTRA_ARGS[@]}" -gt 0 ]; then + info "同步后端 Python 依赖,extras: $BACKEND_EXTRAS" + else + info "后端首次启动 — 安装 Python 依赖(约 1-2 分钟)..." + fi + ( cd "$BACKEND_DIR" && uv sync "${BACKEND_EXTRA_ARGS[@]}" ) ok "后端依赖装好了" fi diff --git a/docs/configuration.md b/docs/configuration.md index 18aaa2d..ca774cf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -93,13 +93,13 @@ AUTH_PASSWORD=你的密码 # 至少 6 位;仅首次生效,已设过则不覆 --- -## Docker 构建 Extras(可选) +## 后端依赖 Extras(可选) ```ini BACKEND_EXTRAS= # 留空默认;legacy-cpu 兼容老 CPU ``` -老 VPS 无 AVX2/FMA 支持时设为 `legacy-cpu`,会给 Polars 切到 `rtcompat` 运行时;需回测则 `legacy-cpu backtest`。详见 [deployment.md → 老 CPU 兼容](./deployment.md#老-cpu-兼容avx2fma-缺失)。 +老 CPU 无 AVX2/FMA 支持时设为 `legacy-cpu`,会给 Polars 切到 `rtcompat` 运行时;需回测则 `legacy-cpu backtest`。Docker 构建和 `./dev.sh` / `.\dev.ps1` 都会读取此值并同步依赖。详见 [deployment.md → 老 CPU 兼容](./deployment.md#老-cpu-兼容avx2fma-缺失)。 --- diff --git a/docs/deployment.md b/docs/deployment.md index e831cd3..1ad19ca 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -27,6 +27,8 @@ cp .env.example .env # 按需填 TICKFLOW_API_KEY(留空 = None 模式) ```bash # 后端 cd backend && uv sync --extra backtest # 含回测依赖 +# 老 CPU: uv sync --extra legacy-cpu +# 老 CPU + 回测: uv sync --extra legacy-cpu --extra backtest uv run uvicorn app.main:app --reload --port 3018 # 前端 @@ -69,13 +71,16 @@ Fork 本仓库后,手动触发 [Release 打包工作流](https://github.com/shy3 如果运行时报 `avx2`/`fma` 缺失,或进程 `exit 132`,说明 CPU 不支持 AVX2 指令集(常见于老 VPS)。解决: - **桌面客户端**:安装包已内置兼容内核,新老 CPU 通吃 -- **Docker / 源码**:在 `.env` 打开 `BACKEND_EXTRAS=legacy-cpu` 后重建,会给 Polars 切到 `rtcompat` 运行时 +- **Dev 源码启动**:在根目录 `.env` 设置后运行 `./dev.sh` 或 Windows 的 `.\dev.ps1`;即使已有 `.venv`,启动器也会同步兼容内核 +- **Docker**:在根目录 `.env` 设置后执行 `docker compose up --build` ```ini BACKEND_EXTRAS=legacy-cpu # 兼容老 CPU BACKEND_EXTRAS=legacy-cpu backtest # 兼容老 CPU + 回测依赖 ``` +手动启动源码时,也可以在 `backend/` 目录直接执行 `uv sync --extra legacy-cpu`。不要设置 `POLARS_SKIP_CPU_CHECK`,它只会隐藏警告,实际执行不支持的指令时仍可能崩溃。 + ### 回测依赖说明 vectorbt → numba 体积较大,作为可选 extras(`uv sync --extra backtest`)。macOS / Intel 无预构建 wheel 时需 `brew install cmake` 现场编译。 diff --git a/packaging/tickflow.spec b/packaging/tickflow.spec index 34039eb..11cb59e 100644 --- a/packaging/tickflow.spec +++ b/packaging/tickflow.spec @@ -13,6 +13,7 @@ pyinstaller packaging/tickflow.spec # 产物在 dist/TickFlowStockPanel/ """ import sys +from importlib.util import find_spec from pathlib import Path from PyInstaller.utils.hooks import ( @@ -48,20 +49,17 @@ for pkg in ("polars", "pyarrow", "duckdb", "fastexcel"): binaries += b hiddenimports += h -# polars-runtime-32 (rtcompat 兼容内核): release.yml 用 --extra legacy-cpu 安装。 -# 它是独立的伴侣二进制包 (含 .pyd/.so), 与 polars 主包分开发布, -# collect_all("polars") 抓不到它的目录 —— 必须显式收集, 否则老 CPU 用户 -# 运行时 rtcompat 加载器找不到兼容库仍会崩 (Illegal instruction)。 -# 不存在时 (未装 legacy-cpu) collect_all 返回空, 不影响普通构建。 -try: - rt_d, rt_b, rt_h = collect_all("polars_runtime_32") - datas += rt_d - binaries += rt_b - hiddenimports += rt_h -except Exception: - pass +# Polars 的发行包名为 polars-runtime-32 / polars-runtime-compat, 但实际 +# Python 导入包带前导下划线。release.yml 安装 legacy-cpu 后必须收集二者, +# 否则 onedir 产物无法在没有 AVX2/FMA 的旧 CPU 上加载兼容内核。 +for pkg in ("_polars_runtime_32", "_polars_runtime_compat"): + if find_spec(pkg) is not None: + rt_d, rt_b, rt_h = collect_all(pkg) + datas += rt_d + binaries += rt_b + hiddenimports += rt_h -# polars 新 ABI 运行时目录 (_polars_runtime_32) 需显式收集子模块 +# Polars 新 ABI 运行时由加载器选择,需显式收集子模块。 hiddenimports += collect_submodules("polars") # ── pywebview 平台后端 (动态导入, PyInstaller 默认抓不到) ────────────