From bd845e6d28de4f4ea3f858ef783e803a0143fcf8 Mon Sep 17 00:00:00 2001 From: Alpha Romer Coma Date: Wed, 7 Oct 2026 20:16:58 +0800 Subject: [PATCH 1/2] Exports on Modal: each XNNPACK and Vulkan window, and the solve, in a Sandbox sized to it The Namespace profile has 15.7 GiB and no swap, which left out every 32k window and most of the models from 1.5B up (finding 40). The GitHub jobs stay on it and send the heavy step to Modal instead (pipeline/remote.py, `python -m pipeline modal-run`): a Sandbox with the memory the window needs, sizing.host_need_bytes times modal.memory_margin, up to the 344064 MiB and 64 cores Modal allows (measured: larger requests are refused), so every window ever built fits again, Qwen3-4B at 32k included. - remote.py builds the image from the pinned requirements (torch from the CPU index, as setup-export does) and the repo's pipeline, uploads inputs to a v2 Volume under a key unique to the run, runs the step with an EXIT trap that commits the Volume with sync on every way out and keeps the step's status (exit 4 included), downloads the outputs, and always stops a live Sandbox and deletes the key. HF_TOKEN goes in as a Secret built from the job's own environment, so nothing is stored on Modal. - export-matrix gives each window its memory_mib; solve-memory sizes the solve at 12 bytes a parameter, as the Blacksmith solves measured. - export-xnnpack.yml: the solve, then one Sandbox per window that exports, smoke-tests through ExecuTorch's C++ runner and gates on the fp32 model's answers before the job publishes. The separate verify job existed for the ARM/x86 split and is gone. export-vulkan.yml sends its export the same way. Both jobs install only the light requirements (setup-modal). - MediaTek and QNN still run on the Namespace profile. - Tested from this workstation on Modal: SmolLM2-135M's Vulkan 2k file came out byte-identical to the published one, and its XNNPACK 2k file exported and passed the smoke test in the same Sandbox; exports were slower than on Blacksmith (finding 41). Codex reviewed it three times; it found the cleanup skipped on a Sandbox timeout, the sync skipped on an explicit exit, the inputs uploaded twice, and the wrong exception caught for a missing Volume path, all fixed and tested. Co-Authored-By: Claude Opus 5.5 --- .github/actions/setup-modal/action.yml | 28 +++ .github/workflows/export-vulkan.yml | 33 ++-- .github/workflows/export-xnnpack.yml | 180 ++++++------------- CLAUDE.md | 6 +- README.md | 8 +- config/pipeline.yaml | 54 ++++-- docs/PLAN.md | 2 +- docs/research/README.md | 1 + pipeline/__main__.py | 57 +++++- pipeline/remote.py | 240 +++++++++++++++++++++++++ pipeline/settings.py | 30 ++++ requirements/modal.txt | 2 + tests/test_namespace_runner.py | 15 +- tests/test_remote.py | 109 +++++++++++ tests/test_versions.py | 4 +- 15 files changed, 597 insertions(+), 172 deletions(-) create mode 100644 .github/actions/setup-modal/action.yml create mode 100644 pipeline/remote.py create mode 100644 requirements/modal.txt create mode 100644 tests/test_remote.py diff --git a/.github/actions/setup-modal/action.yml b/.github/actions/setup-modal/action.yml new file mode 100644 index 0000000..4aae76e --- /dev/null +++ b/.github/actions/setup-modal/action.yml @@ -0,0 +1,28 @@ +name: Set up a Modal-driven job +description: >- + The light toolchain for a job whose heavy step runs on Modal (pipeline/remote.py): Python, + the pipeline's plain requirements and the Modal client, no torch. The step itself runs in a + Sandbox sized to it; this job only sends it, waits, and publishes what comes back. + +runs: + using: composite + steps: + - name: Read versions + id: versions + shell: bash + run: grep -E '^[A-Z_]+=' config/versions.env >>"$GITHUB_OUTPUT" + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ steps.versions.outputs.PYTHON_VERSION }} + cache: pip + cache-dependency-path: | + requirements/dev.txt + requirements/modal.txt + + - name: Install + shell: bash + run: | + python -m pip install -r requirements/dev.txt -r requirements/modal.txt + echo "WORK_DIR=$RUNNER_TEMP/exe-expo-work" >>"$GITHUB_ENV" + mkdir -p "$RUNNER_TEMP/exe-expo-work" diff --git a/.github/workflows/export-vulkan.yml b/.github/workflows/export-vulkan.yml index a5899e5..313ab32 100644 --- a/.github/workflows/export-vulkan.yml +++ b/.github/workflows/export-vulkan.yml @@ -66,40 +66,41 @@ jobs: fail-fast: false matrix: entry: ${{ fromJSON(needs.targets.outputs.entries) }} - # Sized per window by the targets job: the export peak grows with the window squared - # (every attention layer holds a window x window causal mask), so Qwen3-4B needs ~33 GiB - # at 2k and ~108 GiB at 32k. A window no tier in vulkan.runner_tiers can carry is left - # out; since 2026-10-07 that tier is the 15.7 GiB Namespace profile, with no swap. The - # runner has no GPU, so the file gets a structural check (findings 28, 30). - runs-on: ${{ matrix.entry.runner }} + # The export runs on Modal, in a Sandbox with the memory this window needs (the targets + # job's memory_mib: sizing.host_need_bytes plus modal.memory_margin): the peak grows with + # the window squared, from ~5 GiB at 2k to ~134 GiB for Qwen3-4B at 32k. This job only + # sends it, waits, and publishes what comes back. Modal has no GPU either, so the file + # gets a structural check (findings 28, 30). + runs-on: namespace-profile-execupack timeout-minutes: 350 env: HF_TOKEN: ${{ secrets.HF_TOKEN }} + MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} + MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} HF_HUB_DISABLE_PROGRESS_BARS: "1" MODEL_ID: ${{ inputs.model_id }} REVISION: ${{ inputs.revision }} CONTEXT: ${{ matrix.entry.context }} + MEMORY_MIB: ${{ matrix.entry.memory_mib }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ./.github/actions/setup-export - with: - swap-gib: ${{ matrix.entry.swap_gib }} + - uses: ./.github/actions/setup-modal - # Exit 4 means this runner cannot build this window (memory grows with the square of - # the window): recorded as a skip, so the other windows' jobs stand on their own. - # The runner has no GPU: the file gets a structural check, the device benchmarks run it. - - name: Export and check + # Exit 4 means this window cannot be built (the export's own gate): recorded as a skip, + # so the other windows' jobs stand on their own. + - name: Export and check on Modal run: | echo "ARTIFACT=vulkan-${MODEL_ID//\//--}-${CONTEXT}" >>"$GITHUB_ENV" set +e - python -m pipeline export-vulkan "$MODEL_ID" --revision "$REVISION" --context "$CONTEXT" \ - --out "$WORK_DIR/out" --work "$WORK_DIR/work" + python -m pipeline modal-run --memory-mib "$MEMORY_MIB" --requirements requirements/export-xnnpack.txt \ + --get "$WORK_DIR/out" \ + --script 'python -m pipeline export-vulkan "$MODEL_ID" --revision "$REVISION" --context "$CONTEXT" --out "$OUT" --work "$WORK"' rc=$? set -e if [ "$rc" -eq 4 ]; then echo "SKIPPED=1" >>"$GITHUB_ENV" - echo "::warning title=Window skipped::$MODEL_ID at $CONTEXT tokens cannot be exported on this runner" + echo "::warning title=Window skipped::$MODEL_ID at $CONTEXT tokens cannot be exported" exit 0 fi exit "$rc" diff --git a/.github/workflows/export-xnnpack.yml b/.github/workflows/export-xnnpack.yml index 833b70b..26aaa33 100644 --- a/.github/workflows/export-xnnpack.yml +++ b/.github/workflows/export-xnnpack.yml @@ -49,8 +49,7 @@ jobs: HF_TOKEN: ${{ secrets.HF_TOKEN }} outputs: entries: ${{ steps.contexts.outputs.entries }} - contexts: ${{ steps.contexts.outputs.contexts }} - solve_runner: ${{ steps.contexts.outputs.solve_runner }} + solve_memory: ${{ steps.contexts.outputs.solve_memory }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - id: contexts @@ -58,53 +57,53 @@ jobs: CONTEXTS: ${{ inputs.contexts }} MODEL_ID: ${{ inputs.model_id }} REVISION: ${{ inputs.revision }} - # One entry per window with the smallest runner that holds it (export-matrix over - # xnnpack.runner_tiers); the export job gates on the same sizing.host_need_bytes. A - # window no tier can carry is left out here, and verify follows the same list. + # One entry per window with the memory its Modal Sandbox asks for (export-matrix over + # xnnpack.runner_tiers: sizing.host_need_bytes plus modal.memory_margin). A window past + # what Modal allows is left out here. run: | python3 -m pip install --quiet -r requirements/dev.txt entries=$(python3 -m pipeline export-matrix "$MODEL_ID" --revision "$REVISION" --backend xnnpack --contexts "$CONTEXTS") echo "$entries" echo "entries=$entries" >>"$GITHUB_OUTPUT" - echo "contexts=$(python3 -c 'import json,sys; print(json.dumps([e["context"] for e in json.loads(sys.argv[1])]))' "$entries")" >>"$GITHUB_OUTPUT" - # The solve holds the fp32 model (16 GB for a 4B) and one layer's Hessians. On - # Blacksmith every solve up to LFM2.5-2.6B fit 23.4 GiB and past 3B took 48 GB. One - # Namespace profile (15.7 GiB) now runs it; the models that would not fit it have no - # window that fits an export either, so their solve is skipped with the empty matrix. - params=$(python3 -c 'import sys; from pipeline import hub; print(hub.fetch(sys.argv[1], sys.argv[2]).total_params or 0)' "$MODEL_ID" "$REVISION") - solve=namespace-profile-execupack - echo "solve runner: $solve ($params parameters)" - echo "solve_runner=$solve" >>"$GITHUB_OUTPUT" + # The solve holds the fp32 model (16 GB for a 4B) and one layer's Hessians: about 12 + # bytes a parameter, as the Blacksmith solves measured (solve-memory). + solve=$(python3 -m pipeline solve-memory "$MODEL_ID" --revision "$REVISION") + echo "solve: $solve MiB" + echo "solve_memory=$solve" >>"$GITHUB_OUTPUT" solve: # One solve per model, not per window: the codes are per linear, and the window changes # the KV cache and the masks, never the weights. Every window's export reuses this # artifact, which is why it is worth a job of its own. needs: targets - # Nothing to solve for when no window fits a runner (an empty matrix). + # Nothing to solve for when no window fits (an empty matrix). if: ${{ inputs.gptq && needs.targets.outputs.entries != '[]' }} - # Sized on memory: it holds the fp32 model and one layer's Hessians (targets job). - runs-on: ${{ needs.targets.outputs.solve_runner }} + # The solve runs on Modal with the memory targets asked for; this job waits on it. + runs-on: namespace-profile-execupack timeout-minutes: 350 env: HF_TOKEN: ${{ secrets.HF_TOKEN }} + MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} + MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} HF_HUB_DISABLE_PROGRESS_BARS: "1" MODEL_ID: ${{ inputs.model_id }} REVISION: ${{ inputs.revision }} QMODE: ${{ inputs.qmode }} + MEMORY_MIB: ${{ needs.targets.outputs.solve_memory }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ./.github/actions/setup-export - with: - swap-gib: "24" + - uses: ./.github/actions/setup-modal - - name: Solve the int4 codes + - name: Solve the int4 codes on Modal run: | + python -m pipeline modal-run --memory-mib "$MEMORY_MIB" --requirements requirements/export-xnnpack.txt \ + --get "$WORK_DIR" \ + --script 'set -o pipefail ONLY="" if [ -n "$QMODE" ] && [ "$QMODE" != "8da4w" ]; then ONLY="--reference-only"; fi python -m pipeline solve "$MODEL_ID" --revision "$REVISION" $ONLY \ - --out "$WORK_DIR/codes.pt" --work "$WORK_DIR/solve-work" | tee "$WORK_DIR/solve.json" + --out "$OUT/codes.pt" --work "$WORK/solve-work" | tee "$OUT/solve.json"' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -118,53 +117,74 @@ jobs: export: needs: [targets, solve] - # An empty matrix is an error to GitHub, so a model no runner can carry stops at targets. + # An empty matrix is an error to GitHub, so a model no window fits stops at targets. if: ${{ always() && needs.targets.result == 'success' && needs.targets.outputs.entries != '[]' && needs.solve.result != 'failure' }} strategy: fail-fast: false matrix: entry: ${{ fromJSON(needs.targets.outputs.entries) }} - # Sized per window by the targets job: every attention layer holds a window x window - # causal mask, so the need grows with the window squared, and a window no tier in - # xnnpack.runner_tiers can carry is left out. Since 2026-10-07 that tier is the 15.7 GiB - # Namespace profile (x86, no swap). The smoke test stays in its own job (verify). - runs-on: ${{ matrix.entry.runner }} + # One Modal Sandbox per window, sized to it (memory_mib), runs the export, then the smoke + # test through ExecuTorch's C++ runner, then the decision gate against the fp32 model's own + # answers, and only then does this job publish. They used to be two jobs because the + # runner fails on aarch64 (finding 30) and the export ran on ARM; on Modal both are x86. + runs-on: namespace-profile-execupack timeout-minutes: 350 env: HF_TOKEN: ${{ secrets.HF_TOKEN }} + MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} + MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} HF_HUB_DISABLE_PROGRESS_BARS: "1" MODEL_ID: ${{ inputs.model_id }} REVISION: ${{ inputs.revision }} QMODE: ${{ inputs.qmode }} + GPTQ: ${{ inputs.gptq }} CONTEXT: ${{ matrix.entry.context }} + MEMORY_MIB: ${{ matrix.entry.memory_mib }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: ./.github/actions/setup-export - with: - swap-gib: ${{ matrix.entry.swap_gib }} + - uses: ./.github/actions/setup-modal - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 if: ${{ inputs.gptq }} with: name: gptq-codes-${{ github.run_id }} - path: ${{ env.WORK_DIR }} + path: ${{ env.WORK_DIR }}/in - # Exit 4 means this runner cannot build this window (memory grows with the square of - # the window): recorded as a skip, so the other windows' jobs stand on their own. - - name: Export + # Exit 4 means this window cannot be built (the export's own gate): recorded as a skip, + # so the other windows' jobs stand on their own. The gate's rule is the one the verify + # job had: a solved run with no fp32 reference refuses to publish rather than falling + # through to an unguarded one. + - name: Export, smoke-test and gate on Modal run: | echo "ARTIFACT=xnnpack-${MODEL_ID//\//--}-${CONTEXT}" >>"$GITHUB_ENV" + puts=() + for f in codes.pt gate.json; do + if [ -f "$WORK_DIR/in/$f" ]; then puts+=(--put "$WORK_DIR/in/$f"); fi + done set +e + python -m pipeline modal-run --memory-mib "$MEMORY_MIB" --requirements requirements/export-xnnpack.txt \ + "${puts[@]}" --get "$WORK_DIR/out" \ + --script ' CODES="" - if [ -f "$WORK_DIR/codes.pt" ]; then CODES="--codes $WORK_DIR/codes.pt"; fi + if [ -f "$IN/codes.pt" ]; then CODES="--codes $IN/codes.pt"; fi python -m pipeline export-xnnpack "$MODEL_ID" --revision "$REVISION" --context "$CONTEXT" \ - --out "$WORK_DIR/out" --work "$WORK_DIR/work" $CODES --qmode "$QMODE" --skip-smoke + --out "$OUT" --work "$WORK/export" $CODES --qmode "$QMODE" --skip-smoke || exit $? + GATE="" + if [ -f "$IN/gate.json" ]; then + GATE="--gate $IN/gate.json" + elif [ "$GPTQ" = "true" ]; then + echo "::error title=No decision gate::the solve produced no fp32 reference; refusing to publish ungated" + exit 1 + else + echo "::warning title=No decision gate::rounded run, so only the smoke test guards it" + fi + python -m pipeline verify "$OUT" --backend xnnpack --work "$WORK/verify" $GATE' rc=$? set -e if [ "$rc" -eq 4 ]; then echo "SKIPPED=1" >>"$GITHUB_ENV" - echo "::warning title=Window skipped::$MODEL_ID at $CONTEXT tokens cannot be exported on this runner" + echo "::warning title=Window skipped::$MODEL_ID at $CONTEXT tokens cannot be exported" exit 0 fi exit "$rc" @@ -184,90 +204,6 @@ jobs: retention-days: 14 if-no-files-found: warn - - verify: - # The smoke test, and only then the publish. ExecuTorch's runner raises IndexError: stoi - # on aarch64 (finding 30), so this job has to be x86, as every job is now; it loads the .pte - # the export built and asks the same question through the same C++ runner the app uses. - needs: [targets, export] - # Not `needs.export.result == 'success'`: the export matrix is fail-fast: false so that - # one window standing on its own, and gating the whole verify matrix on every window - # having succeeded threw that away. Qwen/Qwen3-1.7B lost a 32k export to a terminated - # runner in run 35413952925, and the four windows that had built correctly were never - # verified and never published because of it. Each verify job already stands down on - # its own when its window uploaded no artifact, which is the per-window check this - # should have relied on all along. - if: ${{ always() && needs.targets.result == 'success' && needs.targets.outputs.entries != '[]' }} - strategy: - fail-fast: false - matrix: - context: ${{ fromJSON(needs.targets.outputs.contexts) }} - runs-on: namespace-profile-execupack - timeout-minutes: 60 - env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} - HF_HUB_DISABLE_PROGRESS_BARS: "1" - MODEL_ID: ${{ inputs.model_id }} - CONTEXT: ${{ matrix.context }} - GPTQ: ${{ inputs.gptq }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - uses: ./.github/actions/setup-export - with: - swap-gib: "8" - - - name: Name the export's artifact - run: echo "ARTIFACT=xnnpack-${MODEL_ID//\//--}-${CONTEXT}" >>"$GITHUB_ENV" - - # A window the export skipped (too wide for its runner) uploads nothing, so this job - # stands down instead of failing on a missing artifact. - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - id: fetch - continue-on-error: true - with: - name: ${{ env.ARTIFACT }} - path: ${{ env.WORK_DIR }}/out - - - name: Note a skipped window - if: steps.fetch.outcome != 'success' - run: | - echo "SKIPPED=1" >>"$GITHUB_ENV" - echo "::notice title=Nothing to verify::$MODEL_ID at $CONTEXT tokens was not exported" - - # The fp32 model's own decisions on the gate prompts, measured in the solve job while - # it was loaded. Without them the gate cannot run and says so; it never passes quietly. - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - if: ${{ inputs.gptq && env.SKIPPED != '1' }} - continue-on-error: true - with: - name: gptq-codes-${{ github.run_id }} - path: ${{ env.WORK_DIR }}/gate - - - name: Smoke-test the exported file, then gate it on the fp32 model's decisions - if: env.SKIPPED != '1' - run: | - GATE="" - if [ -f "$WORK_DIR/gate/gate.json" ]; then - GATE="--gate $WORK_DIR/gate/gate.json" - elif [ "$GPTQ" = "true" ]; then - # A solved run whose reference went missing must not fall through to an - # unguarded publish: that is the exact silence this gate exists to end. - echo "::error title=No decision gate::the solve produced no fp32 reference; refusing to publish ungated" - exit 1 - else - echo "::warning title=No decision gate::rounded run, so only the smoke test guards it" - fi - python -m pipeline verify "$WORK_DIR/out" --backend xnnpack --work "$WORK_DIR/verify-work" $GATE - - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - if: env.SKIPPED != '1' - with: - name: verified-${{ env.ARTIFACT }} - path: ${{ env.WORK_DIR }}/out - retention-days: 14 - if-no-files-found: error - - name: Publish to Hugging Face if: inputs.publish_hf && env.SKIPPED != '1' run: | diff --git a/CLAUDE.md b/CLAUDE.md index 241f375..9cbe9f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co exe-expo exports small dense open-weight LLMs (≤4B, no MoE) from Hugging Face to ExecuTorch `.pte` files for the [openweights](https://github.com/alpharomercoma/openweights) Android app, running entirely on hosted CI runners -(Namespace since 2026-10-07: every job runs on `namespace-profile-execupack`, 4 x86 vCPUs, 15.7 GiB of RAM and no swap, which the container refuses; a `verify` job runs ExecuTorch's C++ runner before anything publishes. The sizing tiers in `config/pipeline.yaml` are that one profile, so a window that does not fit its RAM is skipped (finding 40); a larger Namespace profile added there as a second tier brings the wider windows back.) `docs/PLAN.md` holds the decisions, measured costs, phase status and known +(since 2026-10-07 every GitHub job runs on the Namespace profile `namespace-profile-execupack`, 4 x86 vCPUs, 15.7 GiB, no swap; the XNNPACK and Vulkan exports and the GPTQ solve run on **Modal**, each in a Sandbox sized to the step by `pipeline/remote.py` (`python -m pipeline modal-run`): the window's `sizing.host_need_bytes` plus `modal.memory_margin`, up to Modal's 344064 MiB, with the export, the smoke test through ExecuTorch's C++ runner and the decision gate in one Sandbox before the job publishes (finding 41). MediaTek and QNN still run on the Namespace profile, where a window that does not fit its RAM is skipped (finding 40).) `docs/PLAN.md` holds the decisions, measured costs, phase status and known limits. Read it before changing sizing, backends or naming, and update it when a decision or measurement changes. ## Commands @@ -39,12 +39,12 @@ from the CPU index first, as in `.github/actions/setup-export/action.yml`. Other MediaTek; the next stage starts when the previous has nothing queued or running) and idempotent (a run in flight for the model counts as dispatched); `requeue` puts cancelled dispatches back. The first run only seeds the state; existing models are exported via the `backfill` input. `watch.WORKFLOWS` maps backend → workflow. -- **`solve` job in `export-xnnpack.yml`** → `solve.py`: GPTQ for the int4 codes, once per model because the codes are +- **`solve` job in `export-xnnpack.yml`** (on Modal) → `solve.py`: GPTQ for the int4 codes, once per model because the codes are per linear and the window changes only the KV cache and the masks. Calibration is `calibration.py`: committed prompts in the model's own chat template, each continued by the fp32 model's own greedy reply. The codes reach every window's export as an artifact; `export_with_codes.py` injects them into torchao's tensors before lowering and also carries the LFM2 state fix (`lfm2_state.py`), which the solve applies too. -- **`export-xnnpack.yml`** / **`export-vulkan.yml`** → `export_xnnpack.py` (`backend="xnnpack"|"vulkan"`, same recipe, XNNPACK or Vulkan delegate; Vulkan gets the structural check): `hub.fetch` → `eligibility.evaluate` → `sizing.choose_context` → +- **`export-xnnpack.yml`** / **`export-vulkan.yml`** (each window in a Modal Sandbox via `remote.py`) → `export_xnnpack.py` (`backend="xnnpack"|"vulkan"`, same recipe, XNNPACK or Vulkan delegate; Vulkan gets the structural check): `hub.fetch` → `eligibility.evaluate` → `sizing.choose_context` → download → `convert.py` (HF safetensors → ExecuTorch checkpoint layout) → generated `params.json` + `export_llm` YAML → subprocess `executorch.extension.llm.export.export_llm` → `smoke.py` (greedy generation through the wheel's `TextLLMRunner`, the same C++ runner the app uses) → `export-report-.json` + `config.json`. diff --git a/README.md b/README.md index 79ee885..d756efb 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ # exe-expo Exports small open-weight LLMs from Hugging Face to ExecuTorch `.pte` files for the -[openweights](https://github.com/alpharomercoma/openweights) Android app, on -[Namespace](https://namespace.so) runners (`namespace-profile-execupack`: 4 x86 vCPUs, 16 GB, -no swap), with every window checked against that memory before a job is started. Design and decisions: [docs/PLAN.md](docs/PLAN.md). +[openweights](https://github.com/alpharomercoma/openweights) Android app. The GitHub jobs run +on a [Namespace](https://namespace.so) runner and send each XNNPACK and Vulkan export, and the +GPTQ solve, to a [Modal](https://modal.com) Sandbox with the memory that window needs (up to +336 GiB), so every window from 2k to 32k can be built again; MediaTek and QNN still run on the +Namespace runner. Design and decisions: [docs/PLAN.md](docs/PLAN.md). What has shipped to [`experimentalmachines`](https://huggingface.co/experimentalmachines) on Hugging Face, and what is built but not published: diff --git a/config/pipeline.yaml b/config/pipeline.yaml index df02ca8..1cf2b60 100644 --- a/config/pipeline.yaml +++ b/config/pipeline.yaml @@ -76,12 +76,12 @@ export: # built on two hosted runners differs in the embedding's low bits and small models decode # different text. Affine is deterministic; leave this false to keep builds reproducible. embedding_hqq: false - # The same tier as vulkan.runner_tiers (see there for what was measured), picked per - # window by export_xnnpack.pick_runner on sizing.host_need_bytes(..., "xnnpack"). + # The same tier as vulkan.runner_tiers (see there), picked per window by + # export_xnnpack.pick_runner on sizing.host_need_bytes(..., "xnnpack"). runner_tiers: - - label: namespace-profile-execupack - ram_bytes: 16833269760 - disk_bytes: 101427666944 + - label: modal + ram_bytes: 360777252864 + disk_bytes: 549755813888 swap_gib: 0 vulkan: # The GPU delegate runs the same torchao 8da4w linears (backends/vulkan/vulkan-quantization); @@ -90,22 +90,20 @@ export: group_size: 32 embedding_quantize: "8,0" embedding_hqq: false - # The runners, smallest first; export_xnnpack.pick_runner gives each window the first that - # holds sizing.host_need_bytes in RAM, and when none can, the one with the most RAM that - # holds it with swap. The cost is the window, not the model: every attention layer holds a - # window x window mask, so Qwen3-4B needs ~22 GiB at 2k and ~134 GiB at 32k. + # Since 2026-10-07 the exports run on Modal (pipeline/remote.py, the modal section below): + # each window gets a Sandbox with the memory its sizing.host_need_bytes asks for, so the + # tier is Modal's ceiling rather than a machine size. ram_bytes is the largest request Modal + # accepts (344064 MiB, measured: a larger one is refused with InvalidError), disk_bytes its + # default per-container quota (512 GiB), and there is no swap; a window past the ceiling is + # left out. The cost is the window, not the model: every attention layer holds a window x + # window mask, so Qwen3-4B needs ~22 GiB at 2k and ~134 GiB at 32k. # - # Since 2026-10-07 there is one: the Namespace profile namespace-profile-execupack, 4 x86 - # vCPUs. Its own CI run measured MemTotal 16,438,740 kB, a 101 GB overlay disk shared by / - # and /mnt, and no swap, which the container does not allow (swapon: Operation not - # permitted), so swap_gib is 0 and a window needs to fit in RAM. A window no tier can carry - # is left out of the matrix and recorded as skipped; a larger Namespace profile added here - # as a second tier brings the wider windows back. The Blacksmith tiers this replaced (30.9, - # 62.0 and 113.4 GiB, with 24 to 96 GiB of swap) are in docs/PLAN.md. + # Before: one Namespace profile (15.7 GiB, no swap; finding 40), and before that the + # Blacksmith tiers of 30.9, 62.0 and 113.4 GiB with 24 to 96 GiB of swap (docs/PLAN.md). runner_tiers: - - label: namespace-profile-execupack - ram_bytes: 16833269760 - disk_bytes: 101427666944 + - label: modal + ram_bytes: 360777252864 + disk_bytes: 549755813888 swap_gib: 0 qnn: # HTP context binaries are compiled per chip and load only on that chip. The benchmark @@ -177,3 +175,21 @@ export: disk_bytes: 101427666944 swap_gib: 0 max_window: 2048 + +# Modal: where the exports run since 2026-10-07 (pipeline/remote.py). The GitHub jobs stay on +# the Namespace profile and drive a Sandbox per window, sized to that window, with the repo's +# pipeline inside an image built from the pinned requirements. Limits measured on the +# workspace that day: 344064 MiB of memory and 64 physical cores per Sandbox, refused above. +modal: + app: execupack-exports + # Volumes v2, so a Sandbox can commit its outputs with `sync` before it exits. + volume: execupack-runs + # Physical cores (2 vCPUs each). Blacksmith gave the exports 8 to 32 vCPUs. + cpu: 8 + # The sizing estimates were fitted to measured peaks with their own margins; this covers the + # rest of the container (Python, the downloads in flight) on top. + memory_margin: 1.15 + max_memory_mib: 344064 + # Below the 350-minute limit of the GitHub job that waits on it, so the Sandbox is the one + # that times out and says so. + timeout_minutes: 330 diff --git a/docs/PLAN.md b/docs/PLAN.md index 601dddb..6a34b31 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -14,7 +14,7 @@ for it, Samsung Exynos (ENN). iOS is deferred. | Watched orgs | `Qwen`, `google`, `meta-llama`, `HuggingFaceTB`, each for its own families | | Trigger | Hourly watcher; eligible models are dispatched in stages, XNNPACK for every model first, then Vulkan, then Qualcomm, then MediaTek (a stage waits until the previous one has nothing queued or running) | | First run | Seeds state without exporting; existing models are backfilled by manual dispatch | -| Runners | Namespace since 2026-10-07: one workflow run per backend and one job per window, every job on `namespace-profile-execupack` (4 x86 vCPUs, 15.7 GiB, no swap), and a `verify` job that runs the C++ runner and publishes only once the file has answered (finding 30). A window that does not fit the profile's RAM is left out of the matrix (finding 40). Until then: Blacksmith, ARM for orchestration, solve and export, x86 tiers of 30.9, 62.0 and 113.4 GiB with 24 to 96 GiB of swap for the wide windows | +| Runners | Since 2026-10-07 the GitHub jobs run on the Namespace profile `namespace-profile-execupack` (4 x86 vCPUs, 15.7 GiB, no swap), one workflow run per backend and one job per window. The XNNPACK and Vulkan exports and the GPTQ solve run on Modal: each job sends its step to a Sandbox sized to it (`pipeline/remote.py`; finding 41), and an XNNPACK window's export, smoke test and decision gate run in the same Sandbox before the job publishes. MediaTek and QNN still run on the profile, where a window past its RAM is skipped (finding 40). Until then: Blacksmith, ARM for orchestration, solve and export, x86 tiers of 30.9, 62.0 and 113.4 GiB with 24 to 96 GiB of swap | | Chips | The benchmark devices ("Devices" below): QNN SM8750 (Snapdragon 8 Elite), MediaTek MT6989 (Dimensity 9300+); SM8650 and MT6991 can be added in `config/pipeline.yaml` | | Outputs | Hugging Face Hub, GitHub Releases (files ≤ 2 GiB), Actions artifacts | | HF layout | One repo per model, backend folders; NPU exports published even though the app can't load them yet | diff --git a/docs/research/README.md b/docs/research/README.md index 0f98541..d876fc6 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -66,3 +66,4 @@ Findings from building and running this pipeline, each kept with its evidence. C | 38 | 2026-10-04 | Vulkan | The Vulkan exports run, through both apps, on a Mali and two Adreno GPUs, and whether they beat the XNNPACK file depends on the GPU. Qwen3-0.6B 2k, ExecuTorch 1.5.1 runtime (`executorch-android-vulkan`): openweights' nine engine device tests pass on a Poco X8 Pro Max (MT6991, Mali-G925), a Snapdragon 8 Gen 3 (SM8650, Adreno 750) and a Snapdragon 8 Elite Gen 5 (SM8850); ExecuServe serves the GPU file on all three, and passes its OpenAI 16/16, edge-case 28/28 and Anthropic 8/8 suites on the Poco. On the Poco's Mali-G925 the CPU file decodes 2.2 to 3.9 times faster at both measured prompt lengths (52.1 tok/s against 18.1 and 15.7 through `llama_main`; through ExecuServe 56 to 59 against 15 to 16 after a 27-token prompt and 30 against 13 to 14 after a 702-token one), and the GPU only reads a long prompt faster (371 to 432 tok/s against 325 to 335; engine 402 to 427 against 274 to 279). On the SM8850, through ExecuServe's run history, the GPU reads a 702-token prompt at 901 to 1,180 tok/s against the CPU's 746 to 771 and decodes after it at 61 to 64 against 50 to 51, but decodes after a 27-token prompt at 81 to 93 against 123 to 128; the engine's 937-token matrix agrees (prefill 968 to 1,117 against 548 to 556, decode 51 to 55 against 38 to 39). Resident memory for the same GPU file was 222 to 268 MB on the Adrenos and about 2.2 GB on the Mali, so it is a property of the driver, not the model. | measured | openweights [`docs/research/vulkan-on-device.md`](https://github.com/ExperimentalMachines/openweights/blob/main/docs/research/vulkan-on-device.md) and its raw logs, runs CSV and Codex reviews in [`tools/eval/results/vulkan-2026-10-04/`](https://github.com/ExperimentalMachines/openweights/blob/main/tools/eval/results/vulkan-2026-10-04/); ExecuServe `docs/results/2026-10-04-vulkan.md` | Vulkan stays in the export matrix: it is correct everywhere tried and the better file on a recent Adreno for long prompts. The apps, not the exporter, choose per phone; on Mali the CPU file is the one to use, which the apps do not yet recommend. Only Qwen3-0.6B was measured; hybrid families on 1.5.1's Vulkan delegate are untried. | | 39 | 2026-10-04 | MediaTek | At 16k, LFM2.5 built once no chunk held more than one attention layer; a 4-chunk build with two in some chunks did not. LFM2.5-1.2B-Instruct (attention at layers 2, 5, 8, 10, 12, 14 of 16) at 16k in 4 chunks lost MediaTek's local compiler service while compiling chunk 2, after calibration had finished; its heretic finetune, the same architecture, in 8 chunks (two layers, at most one attention layer each) built and was published: 3,235 s, peak 14,361,923,584 B in use on a 16 vCPU runner. LFM2.5-2.6B (attention at 2, 5, 9, 13, 17, 21, 24, 27 of 30) needs 10 chunks for one attention layer each, and its heretic finetune built at 16k that way: 5,891 s, peak 26,554,892,288 B. Two successes and one failure, so one attention layer per chunk is a working heuristic for these architectures; that it is necessary, and why the compiler service died, are not shown. 32k was started for all three LFM2.5 models (8 and 10 chunks) and cancelled mid-calibration for budget. | measured + inference | run 35994412279 (4 chunks, failed at chunk 2), run 37171790063 (8 chunks, published), run 37176914465 (2.6B-heretic, 10 chunks, 16k published, 32k cancelled); `mtk/mt6991/export-report-16k.json` in `experimentalmachines/LFM2.5-1.2B-Instruct-heretic-ExecuTorch` and `…/LFM2.5-2.6B-heretic-ExecuTorch` | Dispatch 16k with `max_chunks` at the smallest count that leaves one attention layer per chunk (8 for the 1.2B, 10 for the 2.6B); `mtk.max_chunks` stays 4 for the windows that build at it. The successful builds peaked at 13.4 and 24.7 GiB; what killed the failed run's compiler service is not known. LFM2.5-2.6B (base) at 16k and every 32k remain unbuilt. | | 40 | 2026-10-07 | Runner | The Namespace profile `namespace-profile-execupack` has 4 x86_64 CPUs, MemTotal 16,438,740 kB (15.7 GiB), a 101 GB overlay disk shared by / and /mnt, and no swap: `swapon` on a swap file is refused ("Operation not permitted"), so the 24 to 96 GiB the Blacksmith jobs added cannot be had. Against `sizing.host_need_bytes` and `export_mtk.calibration_bytes` that leaves: Vulkan up to 8k for the 0.1B to 1.2B models (Qwen3-0.6B, Qwen2.5-0.5B, SmolLM2, Llama-3.2-1B) and up to 16k for LFM2.5-1.2B, 2k-4k for Qwen2.5-1.5B, Qwen3-1.7B and LFM2.5-2.6B, nothing at 3B and above; XNNPACK up to 8k for the sub-1B models, 2k-4k for Llama-3.2-1B and LFM2.5-1.2B, nothing from 1.5B up; MediaTek Qwen3-0.6B at 512 only, Llama-3.2-1B to 2k, SmolLM2-135M to 4k, and no LFM2.5 at any window by the current estimate (about 17.4 GiB for the 1.2B). Of the windows built before, every 32k window and every 16k except LFM2.5-1.2B's on Vulkan now fall outside it, as do the MediaTek 16k builds of finding 39. That last is the estimate's call, not a measured limit: `calibration_bytes` puts the 1.2B at about 17.4 GiB from the 14.4 to 15.7 bytes per parameter measured on 4-chunk builds, while the 8-chunk 16k build peaked at 13.4 GiB, which this RAM would hold; refitting the estimate for more chunks could bring the 1.2B back. | measured + estimate | CI run 37606922033 (the runner step); `tests/test_namespace_runner.py` pins the tier and the windows; the coverage is the sizing model's, not exports run on the profile | `config/pipeline.yaml` has the one tier with swap_gib 0; `prepare-host.sh` carries on without swap where it is refused; a model no window fits stops at the targets job instead of failing an empty matrix. The wider windows need a larger Namespace profile added as a second tier. QNN has no tier: its export peaked at 15.6 GB of RSS for Qwen3-0.6B (finding 8), at the edge of this profile. | +| 41 | 2026-10-07 | Runner | Modal runs the XNNPACK and Vulkan exports and the solve. A Sandbox may ask for at most 344064 MiB (336 GiB) and 64 physical cores: larger requests are refused at creation with InvalidError ("memory request must be <= 344064 MiB", "CPU request must be <= 64000 mCPU"), so every window ever built fits, Qwen3-4B at 32k (about 134 GiB) included. Inside a Sandbox /proc/meminfo shows the host's memory (680 GiB to 1.3 TiB), not the request, so the export's own memory gate passes there and the request computed from `sizing.host_need_bytes` is what sizes the run; nproc does reflect the request. SmolLM2-135M at 2k: the Vulkan file came out byte-identical to the published one (sha256 0de5a249...), peaking at 1.9 GiB of a 6.1 GiB request; the XNNPACK file exported and passed the smoke test ("The capital of France is Paris") in the same Sandbox. Both were slower than on Blacksmith: 361 s against 145 s for Vulkan and 1,308 s against 348 s for XNNPACK, though the published files were built with ExecuTorch 1.4.0 and these with 1.5.1, so the runner is not the only difference. | measured | `pipeline/remote.py` runs from this workstation, 2026-10-07 (the sandbox limits probed by request; the two exports with their export reports); the published reports in `experimentalmachines/SmolLM2-135M-Instruct-ExecuTorch` | xnnpack and vulkan runner_tiers are one `modal` tier at Modal's ceiling, each window's memory_mib is its need times modal.memory_margin, and the XNNPACK export, smoke test and decision gate share one Sandbox, so the separate verify job is gone. Per-export time is the open cost question: modal.cpu is 8 cores, and whether more cores or a 1.5.1-to-1.4.0 difference explains the gap is not measured. | diff --git a/pipeline/__main__.py b/pipeline/__main__.py index a792ccc..94e9872 100644 --- a/pipeline/__main__.py +++ b/pipeline/__main__.py @@ -175,7 +175,7 @@ def _export_matrix(args) -> int: this sends to a tier is one the job will not refuse, and one no tier can carry is left out here instead of being started on a runner it would kill. """ - from pipeline import export_xnnpack, families, hub, settings, sizing + from pipeline import export_xnnpack, families, hub, remote, settings, sizing cfg = settings.load() recipe = cfg.vulkan if args.backend == "vulkan" else cfg.xnnpack @@ -205,12 +205,47 @@ def _export_matrix(args) -> int: if tier is None: print(f"no runner tier can build a {window}-token window (needs {need:,} B)", file=sys.stderr) continue + entry = {"context": window, "runner": tier.label, "swap_gib": tier.swap_gib} + if tier.label == settings.MODAL_TIER: + # The Sandbox is sized to the window, not the tier: what the estimate needs plus + # the margin, which pick_runner has already checked is within Modal's ceiling. + entry["memory_mib"] = remote.memory_request(need, cfg.modal) + if entry["memory_mib"] is None: + print(f"{window} needs more than Modal allows ({need:,} B)", file=sys.stderr) + continue print(f"{window}: {tier.label}, needs {need / 2**30:.1f} GiB", file=sys.stderr) - entries.append({"context": window, "runner": tier.label, "swap_gib": tier.swap_gib}) + entries.append(entry) print(json.dumps(entries)) return 0 +# The GPTQ solve holds the fp32 model and one layer's Hessians: on Blacksmith every solve up to +# LFM2.5-2.6B fit 23.4 GiB and Qwen3-4B took the 48 GB size, about 12 bytes a parameter. +SOLVE_BYTES_PER_PARAM = 12 +SOLVE_FLOOR_BYTES = 16 * 2**30 + + +def _solve_memory(args) -> int: + """MiB for the solve's Modal Sandbox, from the model's parameter count.""" + from pipeline import hub, remote, settings + + cfg = settings.load() + source = hub.fetch(args.model, args.revision) + need = max(SOLVE_FLOOR_BYTES, SOLVE_BYTES_PER_PARAM * (source.total_params or 0)) + mib = remote.memory_request(need, cfg.modal) + if mib is None: + print(f"the solve needs more than Modal allows ({need:,} B)", file=sys.stderr) + return 2 + print(mib) + return 0 + + +def _modal_run(args) -> int: + from pipeline import remote + + return remote.main(args) + + def _export_mtk(args) -> int: from pipeline import export_mtk @@ -433,6 +468,24 @@ def main(argv: list[str] | None = None) -> int: export_matrix.add_argument("--contexts", default="", help="JSON or comma-separated windows; empty for every tier") export_matrix.set_defaults(func=_export_matrix) + solve_memory = commands.add_parser("solve-memory", help="MiB to request for the GPTQ solve on Modal") + solve_memory.add_argument("model") + solve_memory.add_argument("--revision", default="main") + solve_memory.set_defaults(func=_solve_memory) + + modal_run = commands.add_parser( + "modal-run", help="run a step in a Modal Sandbox sized to it, and bring its outputs back (pipeline/remote.py)" + ) + modal_run.add_argument("--script", required=True, help="bash run in the repo, with $IN, $OUT and $WORK set") + modal_run.add_argument("--memory-mib", type=int, required=True) + modal_run.add_argument("--cpu", type=float, default=None, help="physical cores; default modal.cpu") + modal_run.add_argument("--requirements", default="requirements/export-xnnpack.txt") + modal_run.add_argument("--put", action="append", help="a file or directory to upload to $IN (repeatable)") + modal_run.add_argument("--get", help="local directory that receives $OUT") + modal_run.add_argument("--env", action="append", help="NAME=VALUE set in the Sandbox (repeatable)") + modal_run.add_argument("--keep", action="store_true", help="leave the run's files on the Volume") + modal_run.set_defaults(func=_modal_run) + watch = commands.add_parser("watch", help="check the watched orgs, dispatch exports, update state") watch.add_argument("--state", required=True, help="state JSON (on the state branch)") watch.add_argument("--backfill", default="", help="comma-separated model ids to evaluate and export now") diff --git a/pipeline/remote.py b/pipeline/remote.py new file mode 100644 index 0000000..dc5fac4 --- /dev/null +++ b/pipeline/remote.py @@ -0,0 +1,240 @@ +"""Runs a pipeline step on Modal, in a Sandbox sized to the step, and brings its outputs back. + + python -m pipeline modal-run --memory-mib 12288 --requirements requirements/export-xnnpack.txt \\ + --put "$WORK_DIR/codes.pt" --get "$WORK_DIR/out" \\ + --script 'python -m pipeline export-xnnpack "$MODEL_ID" --context "$CONTEXT" --out "$OUT" --work "$WORK"' + +The GitHub job stays small (the Namespace profile) and waits here; the step itself runs in a +Sandbox with the memory its window needs, which a fixed runner cannot offer: an export's peak +grows with the window squared, from a few GiB at 2k to 134 GiB for Qwen3-4B at 32k. Modal +gives a Sandbox up to 344064 MiB and 64 cores (measured 2026-10-07), with no swap. + +How a step gets in and out: + +- The image is debian slim with the Python of config/versions.env, torch and torchvision from + the CPU index (as .github/actions/setup-export installs them), then the requirements file, + then this repository's pipeline, config, third_party and scripts. Modal caches each layer, + so a run after the first rebuilds only what changed. +- Files given with --put are uploaded to $IN/ on a v2 Volume under a key unique to the run. +- The script runs under bash in /root/execupack, with $IN, $OUT (empty, on the Volume) and + $WORK (container disk) set, the environment given with --env, and HF_TOKEN passed as a + Modal secret built from this process's environment, so no token is stored on Modal. +- Afterwards `sync` commits $OUT, the files are downloaded into the --get directory, and the + key is deleted from the Volume. The script's exit code is this command's, so exit 4 (the + export's "this window cannot be built") keeps its meaning in the workflow. + +Inside a Sandbox /proc/meminfo reports the host's memory, not the request, so a step's own +memory gate always passes there; the matrix that sized the request is the gate that counts. +""" + +from __future__ import annotations + +import os +import re +import sys +import threading +import time +import uuid +from pathlib import Path + +from pipeline import settings + +ROOT = settings.ROOT +# What the image carries of this repository, at /root/execupack. +REPO_DIRS = ("pipeline", "config", "third_party", "scripts", "requirements") +REMOTE_ROOT = "/root/execupack" +VOLUME_MOUNT = "/vol" +CPU_INDEX = "https://download.pytorch.org/whl/cpu" +# Passed into the Sandbox when set here, besides --env. +PASSTHROUGH = ("HF_HUB_DISABLE_PROGRESS_BARS", "MODEL_ID", "REVISION", "CONTEXT", "QMODE", "GPTQ") + + +def python_version() -> str: + return settings.read_env_file(settings.CONFIG_DIR / "versions.env")["PYTHON_VERSION"] + + +def cpu_pins(requirements: Path) -> list[str]: + """torch and torchvision, which come from the CPU index: PyPI's Linux wheels pull CUDA.""" + return [ + line.strip() + for line in requirements.read_text(encoding="utf-8").splitlines() + if re.match(r"^(torch|torchvision)==", line.strip()) + ] + + +def memory_request(need_bytes: int, cfg: settings.ModalSettings) -> int | None: + """MiB to ask Modal for a step that needs `need_bytes`, or None past what Modal allows.""" + mib = -(-int(need_bytes * cfg.memory_margin) // 2**20) + return mib if mib <= cfg.max_memory_mib else None + + +def image(requirements: Path): + import modal + + img = modal.Image.debian_slim(python_version=python_version()).apt_install("git", "build-essential") + pins = cpu_pins(requirements) + if pins: + img = img.pip_install(*pins, index_url=CPU_INDEX) + img = img.pip_install_from_requirements(str(requirements)) + for name in REPO_DIRS: + img = img.add_local_dir(str(ROOT / name), f"{REMOTE_ROOT}/{name}", ignore=["**/__pycache__/**"]) + return img + + +def _stream(stream, out) -> None: + for chunk in stream: + out.write(chunk) + out.flush() + + +def _download(volume, prefix: str, dest: Path) -> int: + """Every file under `prefix` on the Volume, into `dest` at the same relative paths.""" + count = 0 + for entry in volume.listdir(prefix, recursive=True): + if entry.type.name != "FILE": + continue + # Listings name paths with or without the leading slash; compare without it. + relative = entry.path.lstrip("/")[len(prefix.lstrip("/")) :].lstrip("/") + target = dest / relative + target.parent.mkdir(parents=True, exist_ok=True) + with open(target, "wb") as handle: + for chunk in volume.read_file(entry.path): + handle.write(chunk) + count += 1 + return count + + +def run( + script: str, + *, + memory_mib: int, + requirements: Path, + puts: list[Path], + get: Path | None, + env: dict[str, str], + cpu: float | None = None, + keep: bool = False, +) -> int: + import modal + + cfg = settings.load().modal + if cfg is None: + raise SystemExit("config/pipeline.yaml has no modal section") + if memory_mib > cfg.max_memory_mib: + raise SystemExit(f"{memory_mib} MiB is more than Modal allows ({cfg.max_memory_mib})") + + run_id = os.environ.get("GITHUB_RUN_ID", "local") + key = f"{run_id}-{os.environ.get('GITHUB_RUN_ATTEMPT', '1')}-{uuid.uuid4().hex[:8]}" + base = f"{VOLUME_MOUNT}/{key}" + volume = modal.Volume.from_name(cfg.volume, create_if_missing=True, version=2) + app = modal.App.lookup(cfg.app, create_if_missing=True) + + secrets = [] + if os.environ.get("HF_TOKEN"): + secrets.append(modal.Secret.from_dict({"HF_TOKEN": os.environ["HF_TOKEN"]})) + sandbox_env = {name: os.environ[name] for name in PASSTHROUGH if os.environ.get(name)} + sandbox_env.update(env) + sandbox_env.update(IN=f"{base}/in", OUT=f"{base}/out", WORK="/work", HF_HOME="/work/hf-home", PYTHONUNBUFFERED="1") + cores = cpu or cfg.cpu + + sandbox = None + code = None + try: + if puts: + with volume.batch_upload() as batch: + for path in puts: + if path.is_dir(): + batch.put_directory(str(path), f"/{key}/in/{path.name}") + elif path.exists(): + batch.put_file(str(path), f"/{key}/in/{path.name}") + print(f"uploaded {len(puts)} input(s) to {cfg.volume}:/{key}/in", flush=True) + + print(f"Modal Sandbox: {memory_mib} MiB, {cores:g} cores, timeout {cfg.timeout_minutes} min", flush=True) + started = time.time() + with modal.enable_output(): + sandbox = modal.Sandbox.create( + "bash", + "-c", + wrap(script), + app=app, + image=image(requirements), + memory=memory_mib, + cpu=cores, + timeout=cfg.timeout_minutes * 60, + volumes={VOLUME_MOUNT: volume}, + secrets=secrets, + env=sandbox_env, + workdir=REMOTE_ROOT, + ) + readers = [ + threading.Thread(target=_stream, args=(sandbox.stdout, sys.stdout), daemon=True), + threading.Thread(target=_stream, args=(sandbox.stderr, sys.stderr), daemon=True), + ] + for reader in readers: + reader.start() + try: + # raise_on_termination=False still raises on the Sandbox's own timeout (client 1.6.1). + sandbox.wait(raise_on_termination=False) + code = sandbox.returncode + except modal.exception.SandboxTimeoutError: + print(f"::error::the Modal Sandbox ran past its {cfg.timeout_minutes}-minute timeout", flush=True) + for reader in readers: + reader.join(timeout=30) + print(f"Modal Sandbox exited {code} after {(time.time() - started) / 60:.1f} min", flush=True) + + if get is not None: + got = _download(volume, f"/{key}/out", get) + print(f"downloaded {got} file(s) to {get}", flush=True) + finally: + # Whatever happened, nothing is left running or stored: a Sandbox still alive (an + # interrupted wait) is stopped, and the run's inputs and outputs leave the Volume even + # if stopping it fails. + try: + if sandbox is not None and sandbox.poll() is None: + sandbox.terminate() + finally: + if not keep: + try: + volume.remove_file(f"/{key}", recursive=True) + except (FileNotFoundError, modal.exception.NotFoundError): + # Nothing was written under the key (the step failed before it did); the + # original error is the one to see. + pass + # None means it was killed (the timeout, or Modal's out-of-memory killer). + return 1 if code is None else code + + +def wrap(script: str) -> str: + """The Sandbox's bash: the step, with the Volume committed on every way out. + + An EXIT trap, so a step that ends with `exit` (the export's `|| exit $?`, the gate's + `exit 1`) still has its reports committed by `sync` before the job downloads them, and its + status, exit 4 included, is the Sandbox's. A failed sync fails a step that had succeeded. + """ + return ( + "finish() {\n" + " rc=$?\n" + f" if ! sync {VOLUME_MOUNT}; then echo 'sync of the Volume failed' >&2; [ \"$rc\" -eq 0 ] && rc=1; fi\n" + ' exit "$rc"\n' + "}\n" + "trap finish EXIT\n" + 'mkdir -p "$IN" "$OUT" "$WORK"\n' + f"{script}\n" + ) + + +def main(args) -> int: + env = {} + for item in args.env or []: + name, _, value = item.partition("=") + env[name] = value + return run( + args.script, + memory_mib=args.memory_mib, + requirements=Path(args.requirements), + puts=[Path(p) for p in args.put or []], + get=Path(args.get) if args.get else None, + env=env, + cpu=args.cpu, + keep=args.keep, + ) diff --git a/pipeline/settings.py b/pipeline/settings.py index 5880d93..9487a21 100644 --- a/pipeline/settings.py +++ b/pipeline/settings.py @@ -61,6 +61,25 @@ class MtkRecipe: long_samples: int = 14 +@dataclass(frozen=True) +class ModalSettings: + """Where the heavy steps run when a recipe's tier is "modal" (pipeline/remote.py).""" + + app: str + volume: str + # Physical cores; Modal counts 2 vCPUs to a core and allows at most 64 (measured). + cpu: float + # Requested memory is the sizing estimate times this, in whole MiB. + memory_margin: float + # Modal refuses a larger request at creation (measured 2026-10-07: 344064 MiB). + max_memory_mib: int + # A Sandbox lives at most 24 h; the GitHub job that waits on it has its own limit. + timeout_minutes: int + + +MODAL_TIER = "modal" + + @dataclass(frozen=True) class Settings: hub_org: str @@ -83,6 +102,7 @@ class Settings: qnn: QnnRecipe mtk: MtkRecipe executorch_version: str + modal: ModalSettings | None = None def read_env_file(path: Path) -> dict[str, str]: @@ -166,4 +186,14 @@ def load() -> Settings: runner_tiers=runner_tiers(export["mtk"]), ), executorch_version=versions["EXECUTORCH_VERSION"], + modal=ModalSettings( + app=str(raw["modal"]["app"]), + volume=str(raw["modal"]["volume"]), + cpu=float(raw["modal"]["cpu"]), + memory_margin=float(raw["modal"]["memory_margin"]), + max_memory_mib=int(raw["modal"]["max_memory_mib"]), + timeout_minutes=int(raw["modal"]["timeout_minutes"]), + ) + if "modal" in raw + else None, ) diff --git a/requirements/modal.txt b/requirements/modal.txt new file mode 100644 index 0000000..22117e4 --- /dev/null +++ b/requirements/modal.txt @@ -0,0 +1,2 @@ +# The Modal client that pipeline/remote.py drives exports with (python -m pipeline modal-run). +modal==1.6.1 diff --git a/tests/test_namespace_runner.py b/tests/test_namespace_runner.py index 9cb491f..025c4ce 100644 --- a/tests/test_namespace_runner.py +++ b/tests/test_namespace_runner.py @@ -25,13 +25,18 @@ def arch(model_id): return families.architecture(hf_config(model_id), TOTAL_PARAMS[model_id]) +# What the profile could export if an export_llm backend ran on it, which is why they moved +# to Modal: the profile as a tier, as config/pipeline.yaml's mtk section records it. +PROFILE_TIER = CFG.mtk.runner_tiers[0] + + def windows(model_id, backend): - tiers = getattr(CFG, backend).runner_tiers - return [w for w in WINDOWS if export_xnnpack.pick_runner(arch(model_id), w, tiers, backend)] + return [w for w in WINDOWS if export_xnnpack.pick_runner(arch(model_id), w, (PROFILE_TIER,), backend)] -@pytest.mark.parametrize("backend", ["xnnpack", "vulkan", "mtk"]) -def test_every_recipe_runs_on_the_namespace_profile_as_measured(backend): +@pytest.mark.parametrize("backend", ["mtk"]) +def test_the_recipes_not_on_modal_run_on_the_namespace_profile_as_measured(backend): + # XNNPACK and Vulkan moved to Modal the same day (test_remote.py); MediaTek has not yet. (tier,) = getattr(CFG, backend).runner_tiers assert tier.label == PROFILE assert tier.ram_bytes == MEM_TOTAL_BYTES @@ -66,7 +71,7 @@ def test_no_workflow_runs_anywhere_else(): ("Qwen/Qwen3-4B", "xnnpack", []), ], ) -def test_what_the_profile_can_export(model_id, backend, expected): +def test_what_the_profile_could_export(model_id, backend, expected): assert windows(model_id, backend) == expected diff --git a/tests/test_remote.py b/tests/test_remote.py new file mode 100644 index 0000000..9f536cd --- /dev/null +++ b/tests/test_remote.py @@ -0,0 +1,109 @@ +"""The Modal side (pipeline/remote.py): what a window asks for, and that the exports go there. + +Nothing here talks to Modal. The limits are the ones Modal enforced when probed on 2026-10-07: +a Sandbox may ask for 344064 MiB and 64 physical cores, and a larger request is refused. +""" + +import json +import os +import subprocess + +import pytest +from conftest import TOTAL_PARAMS, hf_config + +from pipeline import export_xnnpack, families, remote, settings, sizing + +CFG = settings.load() +WINDOWS = (2048, 4096, 8192, 16384, 32768) + + +def arch(model_id): + return families.architecture(hf_config(model_id), TOTAL_PARAMS[model_id]) + + +def test_modal_is_configured_with_the_limits_it_enforced(): + assert CFG.modal.max_memory_mib == 344064 + assert 0 < CFG.modal.cpu <= 64 + # A Sandbox lives at most 24 h, and the GitHub job that waits on it at most 350 min. + assert CFG.modal.timeout_minutes < 350 + + +@pytest.mark.parametrize("backend", ["xnnpack", "vulkan"]) +def test_the_export_llm_backends_run_on_modal_up_to_its_ceiling(backend): + (tier,) = getattr(CFG, backend).runner_tiers + assert tier.label == settings.MODAL_TIER + assert tier.ram_bytes == CFG.modal.max_memory_mib * 2**20 + assert tier.swap_gib == 0 + + +@pytest.mark.parametrize( + "model_id", ["Qwen/Qwen3-0.6B", "Qwen/Qwen3-1.7B", "Qwen/Qwen3-4B", "meta-llama/Llama-3.2-1B-Instruct"] +) +@pytest.mark.parametrize("backend", ["xnnpack", "vulkan"]) +def test_every_window_up_to_32k_fits_a_sandbox(model_id, backend): + # Everything Blacksmith ever built fits, Qwen3-4B at 32k (about 134 GiB) included. + tiers = getattr(CFG, backend).runner_tiers + for window in WINDOWS: + assert export_xnnpack.pick_runner(arch(model_id), window, tiers, backend), window + need = sizing.host_need_bytes(arch(model_id), window, backend) + assert remote.memory_request(need, CFG.modal) is not None + + +def test_the_request_is_the_need_plus_the_margin_in_whole_mib(): + need = 10 * 2**30 + mib = remote.memory_request(need, CFG.modal) + assert mib * 2**20 >= need * CFG.modal.memory_margin + assert (mib - 1) * 2**20 < need * CFG.modal.memory_margin + # Past the ceiling there is nothing to ask for. + assert remote.memory_request(CFG.modal.max_memory_mib * 2**20, CFG.modal) is None + + +def test_torch_comes_from_the_cpu_index(): + pins = remote.cpu_pins(settings.ROOT / "requirements/export-xnnpack.txt") + assert pins and all(p.startswith(("torch==", "torchvision==")) for p in pins) + assert remote.CPU_INDEX == "https://download.pytorch.org/whl/cpu" + + +def test_the_image_carries_what_the_pipeline_reads(): + for name in remote.REPO_DIRS: + assert (settings.ROOT / name).is_dir(), name + assert {"pipeline", "config", "third_party", "requirements"} <= set(remote.REPO_DIRS) + + +@pytest.mark.parametrize("workflow", ["export-xnnpack.yml", "export-vulkan.yml"]) +def test_the_workflow_sends_each_window_to_modal_with_its_own_memory(workflow): + text = (settings.ROOT / ".github/workflows" / workflow).read_text(encoding="utf-8") + assert "python -m pipeline modal-run --memory-mib" in text + assert "MEMORY_MIB: ${{ matrix.entry.memory_mib }}" in text + assert "MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}" in text + # The heavy toolchain stays off the runner. + assert "./.github/actions/setup-export" not in text + + +def test_the_matrix_entry_shape_the_workflow_reads(): + entry = {"context": 2048, "runner": "modal", "swap_gib": 0, "memory_mib": 6286} + assert json.loads(json.dumps(entry))["memory_mib"] == 6286 + + +@pytest.mark.parametrize( + ("script", "status"), + [("true", 0), ("exit 4", 4), ("false || exit $?", 1), ("echo report; exit 1", 1)], +) +def test_the_sandbox_script_keeps_the_steps_status_and_always_syncs(script, status, tmp_path): + # The EXIT trap runs sync on every way out, an explicit `exit` included, and the step's + # status survives it, exit 4 (a window that cannot be built) above all. sync is stood in + # for by a marker file, since there is no Volume here. + marker = tmp_path / "synced" + wrapped = remote.wrap(script).replace(f"sync {remote.VOLUME_MOUNT}", f"touch {marker}") + env = dict(os.environ, IN=str(tmp_path / "in"), OUT=str(tmp_path / "out"), WORK=str(tmp_path / "w")) + assert subprocess.run(["bash", "-c", wrapped], env=env).returncode == status + assert marker.exists() + + +def test_a_failed_sync_fails_a_step_that_had_succeeded(tmp_path): + env = dict(os.environ, IN=str(tmp_path / "in"), OUT=str(tmp_path / "out"), WORK=str(tmp_path / "w")) + failing = remote.wrap("true").replace(f"sync {remote.VOLUME_MOUNT}", "false") + assert subprocess.run(["bash", "-c", failing], env=env, capture_output=True).returncode == 1 + # A step that already failed keeps its own status. + skipped = remote.wrap("exit 4").replace(f"sync {remote.VOLUME_MOUNT}", "false") + assert subprocess.run(["bash", "-c", skipped], env=env, capture_output=True).returncode == 4 diff --git a/tests/test_versions.py b/tests/test_versions.py index 87dca13..f8daf1c 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -56,7 +56,9 @@ def test_backend_requirements_share_their_core_pins(): def test_every_requirement_is_pinned(): - for path in ("dev.txt", "watch.txt", "export-xnnpack.txt", "export-qnn.txt", "export-mtk.txt", "mtk-tools.txt"): + files = ("dev.txt", "watch.txt", "modal.txt", "export-xnnpack.txt", "export-qnn.txt", "export-mtk.txt") + files += ("mtk-tools.txt",) + for path in files: for line in (settings.ROOT / "requirements" / path).read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#"): From 28f30f6642bda95e02107910cb25098abbc7a23f Mon Sep 17 00:00:00 2001 From: Alpha Romer Coma Date: Wed, 7 Oct 2026 21:00:18 +0800 Subject: [PATCH 2/2] targets jobs: their own Python, because the Namespace runner's system Python refuses pip installs Every export workflow's targets job ran `python3 -m pip install` on the runner's Python, which Blacksmith allowed and Namespace does not (PEP 668: externally-managed-environment), so since the runner switch no XNNPACK, Vulkan, MediaTek or QNN workflow could get past its first job. The first workflow run through Modal found it. setup-light gives those jobs Python from versions.env and requirements/dev.txt, which carries the pyyaml and huggingface_hub they used. Co-Authored-By: Claude Opus 5.5 --- .github/actions/setup-light/action.yml | 23 +++++++++++++++++++++++ .github/workflows/export-mtk.yml | 3 +-- .github/workflows/export-qnn.yml | 3 +-- .github/workflows/export-vulkan.yml | 2 +- .github/workflows/export-xnnpack.yml | 2 +- 5 files changed, 27 insertions(+), 6 deletions(-) create mode 100644 .github/actions/setup-light/action.yml diff --git a/.github/actions/setup-light/action.yml b/.github/actions/setup-light/action.yml new file mode 100644 index 0000000..77f72a4 --- /dev/null +++ b/.github/actions/setup-light/action.yml @@ -0,0 +1,23 @@ +name: Set up a light job +description: >- + Python from config/versions.env and the pipeline's plain requirements, for jobs that only read + Hub metadata and do arithmetic (the targets jobs). The Namespace runner's system Python + refuses pip installs (PEP 668: externally-managed-environment), so these jobs need their own. + +runs: + using: composite + steps: + - name: Read versions + id: versions + shell: bash + run: grep -E '^[A-Z_]+=' config/versions.env >>"$GITHUB_OUTPUT" + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ steps.versions.outputs.PYTHON_VERSION }} + cache: pip + cache-dependency-path: requirements/dev.txt + + - name: Install + shell: bash + run: python -m pip install --quiet -r requirements/dev.txt diff --git a/.github/workflows/export-mtk.yml b/.github/workflows/export-mtk.yml index aee880d..91dfe8a 100644 --- a/.github/workflows/export-mtk.yml +++ b/.github/workflows/export-mtk.yml @@ -61,12 +61,12 @@ jobs: entries: ${{ steps.contexts.outputs.entries }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/setup-light - id: socs env: SOCS: ${{ inputs.socs }} run: | if [ -z "$SOCS" ]; then - python3 -m pip install --quiet pyyaml SOCS=$(python3 -c 'import json, yaml; print(json.dumps(yaml.safe_load(open("config/pipeline.yaml"))["export"]["mtk"]["socs"]))') fi echo "socs=$SOCS" >>"$GITHUB_OUTPUT" @@ -81,7 +81,6 @@ jobs: # disagree about whether a window fits. Memory is one prompt's calibration steps; # disk is every prompt's, since writer_batch_size=1 put the rest in the Arrow cache. run: | - python3 -m pip install --quiet -r requirements/dev.txt entries=$(python3 -m pipeline mtk-matrix "$MODEL_ID" --revision "$REVISION" --contexts "$CONTEXTS") echo "$entries" echo "entries=$entries" >>"$GITHUB_OUTPUT" diff --git a/.github/workflows/export-qnn.yml b/.github/workflows/export-qnn.yml index 37a37b1..a6a1f54 100644 --- a/.github/workflows/export-qnn.yml +++ b/.github/workflows/export-qnn.yml @@ -45,12 +45,12 @@ jobs: contexts: ${{ steps.contexts.outputs.contexts }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/setup-light - id: socs env: SOCS: ${{ inputs.socs }} run: | if [ -z "$SOCS" ]; then - python3 -m pip install --quiet pyyaml SOCS=$(python3 -c 'import json, yaml; print(json.dumps(yaml.safe_load(open("config/pipeline.yaml"))["export"]["qnn"]["socs"]))') fi echo "socs=$SOCS" >>"$GITHUB_OUTPUT" @@ -59,7 +59,6 @@ jobs: CONTEXTS: ${{ inputs.contexts }} # Accepts a JSON list, one number, or a comma-separated list; empty means every tier. run: | - python3 -m pip install --quiet pyyaml CONTEXTS=$(python3 - "$CONTEXTS" <<'PY' import json, sys, yaml raw = sys.argv[1].strip() diff --git a/.github/workflows/export-vulkan.yml b/.github/workflows/export-vulkan.yml index 313ab32..e3014a9 100644 --- a/.github/workflows/export-vulkan.yml +++ b/.github/workflows/export-vulkan.yml @@ -43,6 +43,7 @@ jobs: entries: ${{ steps.contexts.outputs.entries }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/setup-light - id: contexts env: CONTEXTS: ${{ inputs.contexts }} @@ -53,7 +54,6 @@ jobs: # on the same sizing.host_need_bytes, so the two cannot disagree about a window; one # no tier can carry is left out here rather than started on a runner it would kill. run: | - python3 -m pip install --quiet -r requirements/dev.txt entries=$(python3 -m pipeline export-matrix "$MODEL_ID" --revision "$REVISION" --backend vulkan --contexts "$CONTEXTS") echo "$entries" echo "entries=$entries" >>"$GITHUB_OUTPUT" diff --git a/.github/workflows/export-xnnpack.yml b/.github/workflows/export-xnnpack.yml index 26aaa33..09282f1 100644 --- a/.github/workflows/export-xnnpack.yml +++ b/.github/workflows/export-xnnpack.yml @@ -52,6 +52,7 @@ jobs: solve_memory: ${{ steps.contexts.outputs.solve_memory }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/setup-light - id: contexts env: CONTEXTS: ${{ inputs.contexts }} @@ -61,7 +62,6 @@ jobs: # xnnpack.runner_tiers: sizing.host_need_bytes plus modal.memory_margin). A window past # what Modal allows is left out here. run: | - python3 -m pip install --quiet -r requirements/dev.txt entries=$(python3 -m pipeline export-matrix "$MODEL_ID" --revision "$REVISION" --backend xnnpack --contexts "$CONTEXTS") echo "$entries" echo "entries=$entries" >>"$GITHUB_OUTPUT"