diff --git a/.github/workflows/peft-quick-start.yml b/.github/workflows/peft-quick-start.yml new file mode 100644 index 00000000..8ad027db --- /dev/null +++ b/.github/workflows/peft-quick-start.yml @@ -0,0 +1,77 @@ +# peft quick start guard - project thin trigger. +# +# Calls the common engine .github/workflows/quick-start-template.yml; +# this file only declares what varies per project: schedule, +# concurrency, runner, container image + options, upstream repo, +# monitored doc, and the test entry command. The guard loop, cache +# I/O, state relay and publishing all live in the engine. See the +# engine's `workflow_call.inputs` block for the full input contract. + +name: peft-quick-start + +concurrency: + # format() is load-bearing: a '||' between 'manual-' and + # github.run_id would short-circuit on the truthy literal and + # every dispatch would share one 'manual-' group. + group: ${{ github.event_name == 'schedule' && 'peft-quick-start-schedule' || format('manual-{0}', github.run_id) }} + # cancel-in-progress: false because (1) a schedule run cancelled + # mid-way loses its outcome writeback - the outcome is what makes + # the retry mechanism work, and the 'if: always()' guard isn't + # enough when the container is being torn down; (2) dispatch / PR + # runs already live in unique groups so there's nothing to cancel. + cancel-in-progress: false + +on: + schedule: + # Run every 3 hours: '30 */3 * * *' fires at minute 30 of every + # 3rd hour (00:30, 03:30, 06:30, ..., 21:30). GitHub Actions caps + # schedule at 5 min minimum, so tighter cadences need + # workflow_dispatch instead. + - cron: '30 */3 * * *' + workflow_dispatch: + # PR trigger: docs/tests changes get a guard run. paths filter avoids burning + # the self-hosted NPU runner on unrelated PRs. `pull_request` (not + # `pull_request_target`): contents: read is enough, no write-token risk. + pull_request: + branches: [main] + paths: + - 'sources/peft/**' + - 'tests/peft/**' + +permissions: + contents: read + +jobs: + peft-quick-start: + uses: ./.github/workflows/quick-start-template.yml + with: + # Namespaces cache keys (monitor-state-peft-*), artifacts + # (peft-quick-start-) and the test working dir + # (workflows/tests/peft). + project: peft + # Self-hosted NPU runner for the test job only; the engine pins + # the cache I/O jobs (restore-cache / publish-and-persist) to + # GitHub-hosted ubuntu-latest. + test_runner: '["linux-aarch64-a2-1"]' + image: swr.cn-south-1.myhuaweicloud.com/ascendhub/cann:9.1.0-910b-ubuntu22.04-py3.12 + container_options: >- + --volume=/root/.cache/modelscope:/root/.cache/modelscope + # 60 min budget. Caveat: cold-cache download of Qwen2.5-3B-Instruct + # observed ~80 min on linux-aarch64-a2-1 at ~1.2 MB/s, so a fresh + # cache still times out here. Hot-cache runs (post warm-up) finish + # in <5 min and use the full budget for safety. + timeout_minutes: 60 + upstream_repo: huggingface/peft + # Doc URL points to the upstream Ascend/docs repo. {0} is filled by the + # engine: PR head SHA on PR runs, 'main' otherwise. Same-repo PRs (head + # SHA exists on Ascend/docs) test the PR-version of the doc; fork PRs + # (head SHA on a fork) 404 on doc fetch - accepted, since the content + # lands on Ascend/docs post-merge. + doc_url: 'https://raw.githubusercontent.com/Ascend/docs/{0}/sources/peft/quick_start.md' + doc_path: https://github.com/Ascend/docs/blob/main/sources/peft/quick_start.md + # cwd is the repo root inside the `workflows` checkout (matches + # engine template's `working-directory: workflows`); env contract + # (MONITORED_DOC_URL / UPSTREAM_REF / NPU_READY) is injected by the + # engine. All project env prep and installs live in the test + # subclass's prepare_environment hook. + test_command: python -m unittest tests.peft.test_quick_start_ascend -v 2>&1 diff --git a/.github/workflows/quick-start-template.yml b/.github/workflows/quick-start-template.yml new file mode 100644 index 00000000..47a19ab7 --- /dev/null +++ b/.github/workflows/quick-start-template.yml @@ -0,0 +1,770 @@ +# Quick Start guard engine - the common, reusable part of the quick +# start guarding pipeline. +# +# The guard logic below (monitor / decide / record / publish) is +# ported verbatim from the legacy single-project workflow +# ms-swift-quick-start.yml (4ee7f63); only the project-specific +# values are parameterized as workflow_call inputs (see the +# `on.workflow_call.inputs` block below for the full input contract). +# Do not editorialize the bash here - when the guard logic evolves, +# re-port it from the legacy workflow. +# +# Division of labor: +# * The caller (per-project thin trigger, e.g. +# ms-swift-quick-start.yml) owns: cron schedule, dispatch, +# workflow-level concurrency, and every project variant, declared +# as inputs here. Note that caller env does NOT cross the `uses:` +# boundary. +# * This engine owns the fixed guard loop: restore monitor state -> +# monitor (release > doc > retry) -> decide -> test -> record -> +# validate + publish result.json -> persist monitor state. +# * Two runner classes (engine-fixed, not project-configurable): the +# cache I/O jobs (restore-cache / publish-and-persist) run on +# GitHub-hosted ubuntu-latest because the self-hosted NPU runner +# can't reach the cache blob storage and can't compute a consistent +# cache version hash (platform / compression differences); only the +# test job runs on the project-declared runner inside its container. +# * The cluster pip/uv mirror env lives here at test-job level: the +# framework dependency (mistune) is installed by the test step's +# `python -m pip install 'mistune>=3,<4>'` (see step below), BEFORE +# the test process imports. This must be in job env (not in the +# test step's `env:` block) because the test-process bootstrap +# triggers as soon as `unittest` loads `test_quick_start_ascend`, +# before any project Python hook could override it. A project test +# subclass may still override PIP_INDEX_URL etc. with direct +# assignment in prepare_environment. +# +# Test-process env contract (read-only from the test side): +# MONITORED_DOC_URL doc to fetch and execute (inputs.doc_url) +# UPSTREAM_REF release tag substituted into the doc's +# NPU_READY 'true'; gates the project's E2E test class +# UV_* / PIP_* cluster mirror defaults (overridable in hook) + +name: quick-start-template + +on: + workflow_call: + inputs: + project: + description: Project name (e.g. ms-swift); namespaces cache keys, artifact names; the doc lives under sources// and the test under tests// + type: string + required: true + test_runner: + description: JSON array of runner labels for the test job, e.g. '["linux-aarch64-a2-1"]' (cache jobs stay on ubuntu-latest) + type: string + required: true + image: + description: Container image for the test job (also recorded in result.json) + type: string + required: true + container_options: + description: Container options for the test job (device mounts, read-only volumes, shm size, ...) + type: string + required: true + timeout_minutes: + description: Test job timeout in minutes + type: number + default: 180 + upstream_repo: + description: Upstream repo (owner/name) polled for releases; recorded as target_repo in result.json + type: string + required: true + doc_url: + description: URL of the monitored quick-start doc; the monitor hashes it and the test fetches the same URL + type: string + required: true + doc_path: + description: Doc locator recorded in result.json's path field - conventionally the GitHub blob URL (human-clickable link); only needs to identify the tested doc + type: string + required: true + test_command: + description: Test entry command, executed with cwd = repo root (inside the workflows checkout) + type: string + required: true + +# Concurrency deliberately lives in the caller (thin trigger): it is +# evaluated at the run entry point and covers the whole called chain. + +permissions: + contents: read + +jobs: + # Restore the previous run's monitor state on a GitHub-hosted + # ubuntu-latest runner, then ship the four tracked fields as job + # outputs to test (which runs on a self-hosted NPU + # runner that can't reach the cache blob storage and can't be + # trusted to compute the same cache version hash because of + # platform / compression differences). Doing the restore here + # keeps save (publish-and-persist, also ubuntu-latest) and + # restore on the same runner type, so the cache version hash + # is consistent across write and read. + restore-cache: + name: restore monitor state from cache + runs-on: ubuntu-latest + outputs: + cache_last_release_id: ${{ steps.read-monitor.outputs.cache_last_release_id }} + cache_doc_hash: ${{ steps.read-monitor.outputs.cache_doc_hash }} + cache_test_result: ${{ steps.read-monitor.outputs.cache_test_result }} + cache_test_error: ${{ steps.read-monitor.outputs.cache_test_error }} + steps: + - name: Restore monitor state + uses: actions/cache/restore@v6 + with: + path: .monitor-state + key: cache-not-used-restore-keys-only + restore-keys: monitor-state-${{ inputs.project }}- + + # Re-emit each field as a separate step output rather than passing + # the raw file content as one big string - keeps the materializer + # in test trivially shell-quotable. Cold cache = + # no outputs written; downstream needs.X.outputs.Y evaluates + # to '' and the materializer writes empty values into .monitor. + - id: read-monitor + run: | + set -euo pipefail + if [ -f .monitor-state/.monitor ]; then + . .monitor-state/.monitor + [ -n "${last_release_id:-}" ] && echo "cache_last_release_id=$last_release_id" >> "$GITHUB_OUTPUT" + [ -n "${doc_hash:-}" ] && echo "cache_doc_hash=$doc_hash" >> "$GITHUB_OUTPUT" + [ -n "${test_result:-}" ] && echo "cache_test_result=$test_result" >> "$GITHUB_OUTPUT" + [ -n "${test_error:-}" ] && echo "cache_test_error=$test_error" >> "$GITHUB_OUTPUT" + echo "monitor state restored: cache_last_release_id='${last_release_id:-}' cache_doc_hash='${doc_hash:-}' cache_test_result='${test_result:-}' cache_test_error='${test_error:-}'" + else + echo "no cache entry restored - cold start (empty monitor state)" + fi + + test: + name: monitor + test on change + needs: restore-cache + runs-on: ${{ fromJSON(inputs.test_runner) }} + timeout-minutes: ${{ inputs.timeout_minutes }} + # The NPU runner is firewalled from GitHub's cache blob storage, + # so monitor state persists via job outputs to publish-and-persist + # (ubuntu-latest, the only runner that can reach cache storage). + outputs: + result_json: ${{ steps.write-result.outputs.result_json }} + trigger: ${{ steps.write-result.outputs.trigger }} + reason: ${{ steps.decide.outputs.reason }} + job_status: ${{ steps.write-result.outputs.job_status }} + need_to_test: ${{ steps.decide.outputs.need_to_test }} + monitor_last_release_id: ${{ steps.record-monitor-outcome.outputs.monitor_last_release_id }} + monitor_doc_hash: ${{ steps.record-monitor-outcome.outputs.monitor_doc_hash }} + monitor_test_result: ${{ steps.record-monitor-outcome.outputs.monitor_test_result }} + monitor_test_error: ${{ steps.record-monitor-outcome.outputs.monitor_test_error }} + defaults: + run: + shell: bash + container: + image: ${{ inputs.image }} + options: ${{ inputs.container_options }} + env: + # Engine-fixed env: upstream repo + monitored doc come from the + # caller's inputs (caller env does not cross the `uses:` boundary). + UPSTREAM_REPO: ${{ inputs.upstream_repo }} + MONITORED_DOC_URL: ${{ format(inputs.doc_url, github.event_name == 'pull_request' && github.event.pull_request.head.sha || 'main') }} + GH_TOKEN: ${{ github.token }} + GH_API: https://api.github.com + # Cluster-internal nginx PyPI cache fronts every package + # (PEP 517 build deps + NPU wheels), so installs don't fall + # back to external mirrors the runner can't reach. These are + # cluster-level defaults: a project test subclass may override + # them via direct assignment in prepare_environment (they must + # be in job env because the test process's mistune install + # (`python -m pip install 'mistune>=3,<4>'` in the test step + # below) reads PIP_INDEX_URL before any project Python hook + # runs). + UV_INDEX_URL: "http://cache-service.nginx-pypi-cache.svc.cluster.local/pypi/simple" + UV_EXTRA_INDEX_URL: "https://repo.huaweicloud.com/ascend/repos/pypi" + UV_INDEX_STRATEGY: "unsafe-best-match" + UV_INSECURE_HOST: "cache-service.nginx-pypi-cache.svc.cluster.local" + UV_HTTP_TIMEOUT: "120" + UV_NO_CACHE: "1" + UV_SYSTEM_PYTHON: "1" + PIP_INDEX_URL: "http://cache-service.nginx-pypi-cache.svc.cluster.local/pypi/simple" + PIP_EXTRA_INDEX_URL: "https://repo.huaweicloud.com/ascend/repos/pypi" + PIP_TRUSTED_HOST: "cache-service.nginx-pypi-cache.svc.cluster.local" + PIP_TIMEOUT: "120" + steps: + # Diagnostics only. No `source set_env.sh` here: an export inside + # this step wouldn't survive the step boundary anyway - the CANN + # env the test actually runs with is sourced inside the project + # test subclass's prepare_environment (test process, same env as + # the doc commands). + - name: Print context and probe NPU + run: | + echo "==========================================" + echo "Trigger: ${{ github.event_name }}" + echo "Runner: ${{ runner.name }}" + echo "Project: ${{ inputs.project }}" + echo "Upstream: ${{ inputs.upstream_repo }}" + echo "==========================================" + export PATH="/usr/local/sbin:$PATH" + npu-smi info || true + + - name: Checkout workflows (provides the test code and doc) + uses: actions/checkout@v6 + with: + path: workflows + fetch-depth: 1 + + # restore-cache ran on ubuntu-latest (the only runner that can reach + # the cache blob storage) and shipped each tracked field as a job + # output. Reconstruct the same bash-sourceable key=value file + # here so the monitor step below can `.` it as if it had been + # restored locally. Cold cache means upstream outputs are empty + # strings; the monitor step's lower-case defaults handle that. + - name: Materialize monitor state file + env: + RESTORED_LAST_RELEASE_ID: ${{ needs.restore-cache.outputs.cache_last_release_id }} + RESTORED_DOC_HASH: ${{ needs.restore-cache.outputs.cache_doc_hash }} + RESTORED_TEST_RESULT: ${{ needs.restore-cache.outputs.cache_test_result }} + RESTORED_TEST_ERROR: ${{ needs.restore-cache.outputs.cache_test_error }} + run: | + set -euo pipefail + mkdir -p .monitor-state + { + echo "last_release_id='$RESTORED_LAST_RELEASE_ID'" + echo "doc_hash='$RESTORED_DOC_HASH'" + echo "test_result='$RESTORED_TEST_RESULT'" + echo "test_error='$RESTORED_TEST_ERROR'" + } > .monitor-state/.monitor + echo "materialized .monitor-state/.monitor:" + cat .monitor-state/.monitor + + # Schedule-only: this step is what maintains .monitor-state/.monitor + # (the failure flag / last_release_id / doc_hash). Dispatch is a + # one-shot read-only path that must not mutate that state - + # otherwise a manual run would clobber the failure flag the next + # schedule cycle needs to decide whether to retry. The dispatch + # branch in `decide` below fetches the latest release tag + # independently. + - name: Monitor upstream release and doc + id: monitor + if: github.event_name == 'schedule' + env: + DOC_URL: ${{ env.MONITORED_DOC_URL }} + run: | + set -euo pipefail + STATE_DIR=".monitor-state" + DECISION_FILE="/tmp/.decision" + MONITOR_FILE="$STATE_DIR/.monitor" + + # Default all four keys so set -u stays safe when the cache is cold + # and MONITOR_FILE doesn't exist yet. + last_release_id="" + doc_hash="" + test_result="" + test_error="" + if [ -f "$MONITOR_FILE" ]; then + echo "MONITOR_FILE exist" + . "$MONITOR_FILE" + fi + echo "monitor cache: release(id=$last_release_id) doc(hash=$doc_hash) test(result=$test_result error=$test_error)" + + # ----- release signal ----- + # Automatic fallback chain — no caller input needed: + # 1. /releases/latest (default path; most upstreams) + # 2. /releases?per_page=20 (covers prerelease-only repos, e.g. + # torchtitan pre-stable where + # /releases/latest 404s because every + # release is marked prerelease; picks + # most recent non-draft by published_at) + # 3. /tags?per_page=1 (covers repos that never publish + # GitHub Releases — Ascend/vision is + # the canonical case; /releases/latest + # 404s AND /releases returns []) + # 4. /commits/HEAD (covers repos with NO releases AND + # NO tags — sgl-project/SpecForge is + # the canonical case; all three + # above return empty. Returns the + # default-branch HEAD SHA which is + # used as both id (change-detection + # key — fires NEED_TO_TEST on every + # new push) and tag_name (clone ref + # — `git clone --branch ` is + # valid). No hardcoded branch name; + # works for `main` / `master` / + # custom default. Cost: noisy — + # SpecForge commits every 1–5 days + # so 6h schedule fires ~1x per cycle + # on average; accepted as the price + # of monitoring tagless upstreams + # uniformly with the release/tag + # path.) + # Each step normalizes to {id, tag_name} so the downstream .id / + # .tag_name reads are uniform across sources. `set -e` is masked + # via `|| true` + jq presence checks so a 404 (or an empty body) + # at one step falls through cleanly to the next instead of + # aborting the whole monitor cycle. + : > /tmp/release.json # start empty; each step either writes or leaves empty + if curl -fsSL --retry 3 --max-time 30 \ + "$GH_API/repos/${{ env.UPSTREAM_REPO }}/releases/latest" -o /tmp/r1.json 2>/dev/null \ + && jq -e '.tag_name // empty' /tmp/r1.json > /dev/null 2>&1; then + mv /tmp/r1.json /tmp/release.json + echo "monitor: release source=/releases/latest" + elif curl -fsSL --retry 3 --max-time 30 \ + "$GH_API/repos/${{ env.UPSTREAM_REPO }}/releases?per_page=20" -o /tmp/r2.json 2>/dev/null \ + && jq -e 'length > 0' /tmp/r2.json > /dev/null 2>&1; then + jq -r '[.[] | select(.draft == false)] | sort_by(.published_at) | last | {id: .id, tag_name: .tag_name}' \ + /tmp/r2.json > /tmp/release.json + echo "monitor: release source=/releases?per_page=20 (prerelease fallback)" + elif curl -fsSL --retry 3 --max-time 30 \ + "$GH_API/repos/${{ env.UPSTREAM_REPO }}/tags?per_page=1" -o /tmp/r3.json 2>/dev/null; then + jq -r 'if (.[0] // null) != null then {id: .[0].commit.sha, tag_name: .[0].name} else empty end' \ + /tmp/r3.json > /tmp/release.json + echo "monitor: release source=/tags?per_page=1 (tags-only fallback)" + elif curl -fsSL --retry 3 --max-time 30 \ + "$GH_API/repos/${{ env.UPSTREAM_REPO }}/commits/HEAD" -o /tmp/r4.json 2>/dev/null; then + jq -r 'if (.sha // null) != null then {id: .sha, tag_name: .sha} else empty end' \ + /tmp/r4.json > /tmp/release.json + echo "monitor: release source=/commits/HEAD (tagless upstream, default-branch HEAD sha)" + else + echo "monitor: all four release sources failed; treating as no signal" + fi + CURRENT_RELEASE_ID=$(jq -r '.id // empty' /tmp/release.json) + CURRENT_RELEASE_TAG=$(jq -r '.tag_name // empty' /tmp/release.json) + + NEED_TO_TEST="false" + REF="" + REASON="" + SKIP_DOC="false" + echo "monitor: upstream latest release id=$CURRENT_RELEASE_ID tag=$CURRENT_RELEASE_TAG (cached last_release_id=$last_release_id)" + if [ -n "$CURRENT_RELEASE_ID" ] && [ "$last_release_id" != "$CURRENT_RELEASE_ID" ]; then + last_release_id="$CURRENT_RELEASE_ID" + NEED_TO_TEST="true" + REF="$CURRENT_RELEASE_TAG" + REASON="release" + SKIP_DOC="true" + echo "monitor: release signal FIRED (id=$CURRENT_RELEASE_ID tag=$CURRENT_RELEASE_TAG)" + else + echo "monitor: release signal not fired (current=$CURRENT_RELEASE_ID cached=$last_release_id)" + fi + + # ----- doc signal (only if release didn't fire) ----- + # raw.githubusercontent.com is not always reachable from the + # NPU runner (cluster firewall). Treat a fetch failure as + # "doc signal unknown" rather than aborting the whole cycle: + # doc is the lower-priority signal, release (when it fires) + # already covers the test path, and a missed doc signal just + # delays the next doc-driven cycle until the next schedule. + if [ "$SKIP_DOC" = "false" ]; then + if ! CURRENT_DOC_HASH=$(curl -fsSL --retry 3 --max-time 30 \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github.raw" \ + "$DOC_URL" \ + | sha256sum | cut -d' ' -f1); then + echo "monitor: doc signal not fired (fetch failed; release signal, if any, still drives the cycle)" + elif [ "$doc_hash" != "$CURRENT_DOC_HASH" ]; then + doc_hash="$CURRENT_DOC_HASH" + NEED_TO_TEST="true" + REF="$CURRENT_RELEASE_TAG" + REASON="doc" + echo "monitor: doc signal FIRED (hash=$CURRENT_DOC_HASH)" + else + echo "monitor: doc signal not fired (hash unchanged)" + fi + else + echo "monitor: doc signal skipped (release already fired)" + fi + + # ----- retry signal (lowest priority) ----- + # Triggered when neither release nor doc signal changed but + # the previous cycle's outcome was failure. REASON stays + # 'release' / 'doc' / empty above; this only sets 'retry'. + if [ "$NEED_TO_TEST" = "false" ] && [ "$test_result" = "failure" ]; then + NEED_TO_TEST="true" + REF="$CURRENT_RELEASE_TAG" + REASON="retry" + echo "monitor: retry signal FIRED (previous cycle test_result=failure)" + else + echo "monitor: retry signal not fired (need_to_test=$NEED_TO_TEST test_result=${test_result:-none})" + fi + + # Values are single-quoted so literals like 'none' and any + # shell-meta chars round-trip safely when record-monitor- + # outcome sources this file via `.`. + { + echo "last_release_id='$last_release_id'" + echo "doc_hash='$doc_hash'" + echo "test_result='$test_result'" + echo "test_error='$test_error'" + } > "$MONITOR_FILE" + echo "monitor state: release(id=$last_release_id) doc(hash=$doc_hash) test(result=$test_result error=$test_error)" + + { + echo "need_to_test='$NEED_TO_TEST'" + echo "ref='$REF'" + echo "reason='$REASON'" + } > "$DECISION_FILE" + echo "monitor decision: release_tag=$REF need_to_test=$NEED_TO_TEST reason=$REASON" + + # Schedule: source /tmp/.decision. workflow_dispatch forces + # need_to_test=true and resolves the latest release tag itself - + # NOT a hardcoded 'main' - so dispatch always tests the most + # recent upstream release. + - name: Decide which ref to test + id: decide + env: + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + DECISION_FILE="/tmp/.decision" + + # Defaults so set -u stays safe; dispatch / PR overwrite, + # schedule sources /tmp/.decision when present. + need_to_test="false" + reason="" + ref="" + + # Auto-fallback chain (shared by dispatch + PR): + # /releases/latest -> /releases?per_page=20 -> /tags?per_page=1 -> /commits/HEAD. + # Each tier falls through on 404/empty. Defined before the if/elif + # so both branches can call it (a function inside an if block only + # exists on the branch that runs). + resolve_ref() { + local _ref="" + if curl -fsSL --retry 3 --max-time 30 \ + "$GH_API/repos/${{ env.UPSTREAM_REPO }}/releases/latest" -o /tmp/r1.json 2>/dev/null; then + _ref=$(jq -r '.tag_name // empty' /tmp/r1.json) + fi + if [ -z "$_ref" ] && curl -fsSL --retry 3 --max-time 30 \ + "$GH_API/repos/${{ env.UPSTREAM_REPO }}/releases?per_page=20" -o /tmp/r2.json 2>/dev/null; then + _ref=$(jq -r '[.[] | select(.draft == false)] | sort_by(.published_at) | last | .tag_name // empty' /tmp/r2.json) + fi + if [ -z "$_ref" ] && curl -fsSL --retry 3 --max-time 30 \ + "$GH_API/repos/${{ env.UPSTREAM_REPO }}/tags?per_page=1" -o /tmp/r3.json 2>/dev/null; then + _ref=$(jq -r '.[0].name // empty' /tmp/r3.json) + fi + # 4th tier: upstreams with no releases AND no tags (e.g. SpecForge). + # /commits/HEAD returns the default-branch HEAD sha; valid as a + # `git clone --branch` ref, no hardcoded branch name needed. + if [ -z "$_ref" ] && curl -fsSL --retry 3 --max-time 30 \ + "$GH_API/repos/${{ env.UPSTREAM_REPO }}/commits/HEAD" -o /tmp/r4.json 2>/dev/null; then + _ref=$(jq -r '.sha // empty' /tmp/r4.json) + echo "monitor: ref source=/commits/HEAD (tagless upstream, default-branch HEAD sha)" + fi + printf '%s' "$_ref" + } + + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + need_to_test="true" + reason="manual" + ref=$(resolve_ref) + [ -n "$ref" ] || { echo "ERROR: failed to resolve latest upstream ref (auto-fallback chain exhausted)"; exit 1; } + echo "monitor decision (manual override): ref=$ref need_to_test=true reason=manual" + elif [ "$EVENT_NAME" = "pull_request" ]; then + need_to_test="true" + reason="pr" + ref=$(resolve_ref) + [ -n "$ref" ] || { echo "ERROR: failed to resolve latest upstream ref for PR run (auto-fallback chain exhausted)"; exit 1; } + echo "monitor decision (pr run): ref=$ref need_to_test=true reason=pr" + elif [ -f $DECISION_FILE ]; then + . $DECISION_FILE + fi + + echo "reason=$reason" >> "$GITHUB_OUTPUT" + + if [ "$need_to_test" = "true" ]; then + echo "need_to_test=true" >> "$GITHUB_OUTPUT" + echo "test_ref=$ref" >> "$GITHUB_OUTPUT" + echo "Monitor fired: reason=$reason ref=$ref" + + # /releases/latest returns .id as the release id (not the + # commit SHA); /releases?per_page=20 same. /tags carries the + # commit SHA in .commit.sha, but the dispatch path above + # doesn't keep that side — and the auto-fallback chain means + # the monitor can't tell which source fired. Always doing the + # /commits/$ref lookup is one extra API call per fire but + # uniformly correct. (Optimization: when /tags fired we + # could read .commit.sha directly, but tracking which source + # fired across the bash steps would add state for a marginal + # saving — not worth it.) + SHA=$(curl -fsSL "$GH_API/repos/${{ env.UPSTREAM_REPO }}/commits/$ref" \ + | jq -r '.sha // empty') + [ -z "$SHA" ] && { echo "failed to resolve $ref"; exit 1; } + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + echo "Upstream commit: $SHA" + else + echo "need_to_test=false" >> "$GITHUB_OUTPUT" + echo "No upstream changes - skipping install + test" + fi + + # The project's test entry: everything project-specific (env prep, + # installs, doc fetch/parse/execute) lives in projects/'s + # test command; the engine only supplies the env contract below + # plus the cluster mirror env from job env. + - name: Run quick start test + id: test + if: steps.decide.outputs.need_to_test == 'true' + env: + # Only the vars the project test actually reads; + # UPSTREAM_REPO / MONITORED_DOC_URL propagate from job env + # and REASON is purely diagnostic. UPSTREAM_COMMIT is not + # injected - no consumer in the test process. + NPU_READY: 'true' + UPSTREAM_REF: ${{ steps.decide.outputs.test_ref }} + working-directory: workflows + run: | + set -euo pipefail + # Framework dep: mistune is required by + # tests/doc_test/base.py at import time. + # Install here (in the test step itself) rather than as a + # standalone step: pip failures surface in the same context as + # the test failure, and warm runners skip the install via + # pip's own resolver. Inherits PIP_INDEX_URL / + # PIP_TRUSTED_HOST from job env (cluster cache + trusted-host). + python -m pip install 'mistune>=3,<4' + ${{ inputs.test_command }} + + # Schedule + need_to_test - dispatch forces need_to_test=true + # but must not mutate monitor state (otherwise it would clobber + # the failure flag the next schedule cycle uses to decide + # whether to retry). need_to_test is set BEFORE test.conclusion + # is known, so this step still runs through the failure path - + # writing the new failure flag is what makes the next cycle + # decide to retry. + - name: Record monitor outcome + id: record-monitor-outcome + if: | + !cancelled() && github.event_name == 'schedule' && + steps.decide.outputs.need_to_test == 'true' + env: + # REASON / REF come from /tmp/.decision — written by the + # monitor step on schedule. (decide's dispatch branch no + # longer writes it: c87d2b2 made decide own the output.) + CONCLUSION: ${{ steps.test.conclusion }} + run: | + set -euo pipefail + DECISION_FILE="/tmp/.decision" + STATE_DIR=".monitor-state" + MONITOR_FILE="$STATE_DIR/.monitor" + + # Defaults so set -u stays safe when DECISION_FILE is absent. + reason="" + ref="" + + if [ -f $DECISION_FILE ]; then + . $DECISION_FILE + fi + REASON="$reason" + + # ----- Map test conclusion to outcome ----- + case "$CONCLUSION" in + success) + RESULT="success" + ERROR="none" + ;; + failure) + RESULT="failure" + ERROR="test failed" + ;; + skipped) + RESULT="failure" + ERROR="prior step failed before test could run" + ;; + *) + RESULT="failure" + ERROR="unknown conclusion (${CONCLUSION})" + ;; + esac + + # REASON is guaranteed non-empty here: this step's if: gates on + # need_to_test=true, which the decide step sets together with + # REASON in the same cycle. Overwriting test_result / test_error + # unconditionally is therefore safe -- there is no "REASON empty" + # branch to preserve. A retry on a still-pending failure is + # handled by the monitor step's TEST_RESULT=failure branch. + + last_release_id="" + doc_hash="" + test_result="" + test_error="" + if [ -f "$MONITOR_FILE" ]; then + . "$MONITOR_FILE" + fi + + echo "outcome for ${REASON}: result=${RESULT} error=${ERROR}" + test_result="$RESULT" + test_error="$ERROR" + + # ----- Write the merged cache file ----- + { + echo "last_release_id='$last_release_id'" + echo "doc_hash='$doc_hash'" + echo "test_result='$test_result'" + echo "test_error='$test_error'" + } > "$MONITOR_FILE" + + { + echo "monitor_last_release_id=$last_release_id" + echo "monitor_doc_hash=$doc_hash" + echo "monitor_test_result=$test_result" + echo "monitor_test_error=$test_error" + } >> "$GITHUB_OUTPUT" + echo "shipped monitor state: release(id=$last_release_id) doc(hash=$doc_hash) test(result=$test_result error=$test_error)" + + # need_to_test gates dispatch (always writes - manual run + # produces a result) and schedule (writes only when a monitor + # fired, so the schema's target_ref minLength:1 is never + # violated by a no-signal cycle). + - name: Write result and summarize + id: write-result + if: | + !cancelled() && steps.decide.outputs.need_to_test == 'true' + env: + # Route upstream step outputs through env so jq / printf + # parameter-expand them; values containing shell-meta chars + # never get re-parsed by bash. + TRIGGER: ${{ github.event_name }} + REASON: ${{ steps.decide.outputs.reason }} + TARGET_REPO: ${{ inputs.upstream_repo }} + TARGET_REF: ${{ steps.decide.outputs.test_ref }} + DOC_PATH: ${{ inputs.doc_path }} + IMAGE: ${{ inputs.image }} + JOB_STATUS: ${{ job.status }} + UPSTREAM_SHA: ${{ steps.decide.outputs.sha }} + run: | + set -euo pipefail + mkdir -p output + + jq -n \ + --arg trigger "$TRIGGER" \ + --arg reason "$REASON" \ + --arg target_repo "$TARGET_REPO" \ + --arg target_ref "$TARGET_REF" \ + --arg path "$DOC_PATH" \ + --arg image "$IMAGE" \ + --arg job_status "$JOB_STATUS" \ + '{ + trigger: $trigger, + reason: $reason, + target_repo: $target_repo, + target_ref: $target_ref, + path: $path, + image: $image, + job_status: $job_status + }' > output/result.json + echo "wrote $(wc -c < output/result.json) bytes:" + cat output/result.json + # Ship result.json across jobs via job-level outputs (channel 2). + # The NPU runner can't reach GitHub's artifact blob storage, + # so upload-artifact lives in publish-and-persist on ubuntu-latest. + printf 'result_json=%s\n' "$(jq -c . output/result.json)" \ + >> "$GITHUB_OUTPUT" + { + printf 'trigger=%s\n' "$TRIGGER" + printf 'job_status=%s\n' "$JOB_STATUS" + } >> "$GITHUB_OUTPUT" + + # ----- Summarize ----- + echo "============================================================" + echo "QUICK START TEST SUMMARY" + echo " Upstream repo: ${{ env.UPSTREAM_REPO }}" + echo " Upstream ref: $TARGET_REF" + echo " Upstream commit: $UPSTREAM_SHA" + echo " Trigger reason: $REASON" + echo "------------------------------------------------------------" + # Defaults so set -u stays safe when the monitor file is + # missing or half-written. + STATE_DIR=".monitor-state" + MONITOR_FILE="$STATE_DIR/.monitor" + _r="" + _e="" + if [ -f "$MONITOR_FILE" ]; then + . "$MONITOR_FILE" + _r="$test_result"; _e="$test_error" + echo "reading cache: $MONITOR_FILE => result=${_r} error=${_e}" + if [ "$_r" = "failure" ] && [ "$_e" != "none" ]; then + echo " Last failure: $_e" + fi + fi + echo "============================================================" + + # Complement of test: actual upload + cache save + + # cache restore, all on ubuntu-latest (the only runner that can reach + # GitHub's blob / cache storage). + publish-and-persist: + name: publish result.json + persist monitor state + needs: test + if: always() + runs-on: ubuntu-latest + steps: + # Fresh runner, so checkout brings .github/workflows/schemas/result.schema.json + # alongside the just-materialized output/result.json. + - name: Checkout workflows (provides .github/workflows/schemas/result.schema.json) + uses: actions/checkout@v6 + with: + path: workflows + fetch-depth: 1 + + - name: Materialize result.json + env: + RESULT_JSON: ${{ needs.test.outputs.result_json }} + run: | + set -euo pipefail + mkdir -p output + # Empty result_json means upstream skipped - exit cleanly so + # the workflow conclusion is driven by test alone. + if [ -z "$RESULT_JSON" ]; then + echo "no result_json from test; nothing to publish" + exit 0 + fi + printf '%s\n' "$RESULT_JSON" > output/result.json + echo "materialized $(wc -c < output/result.json) bytes:" + cat output/result.json + + - name: Validate result.json against schema + if: always() + run: | + if [ ! -s output/result.json ]; then + echo "result.json is empty; skipping schema validation" + exit 0 + fi + python3 -m pip install -q check-jsonschema + check-jsonschema \ + --schemafile workflows/.github/workflows/schemas/result.schema.json output/result.json + + - name: Upload result.json + if: always() + uses: actions/upload-artifact@v6 + with: + name: ${{ inputs.project }}-quick-start-${{ github.run_id }} + path: output/result.json + if-no-files-found: warn + + # Cache fields arrive as one job output each (set by + # record-monitor-outcome); reconstruct the merged file on + # ubuntu-latest, which CAN reach cache blob storage. + - name: Restore monitor state to disk + # Schedule only: dispatch must not write monitor state, + # otherwise it would clobber the cached failure flag the + # next schedule cycle uses to decide whether to retry. + if: github.event_name == 'schedule' && needs.test.outputs.need_to_test == 'true' && needs.test.outputs.reason != '' + env: + monitor_last_release_id: ${{ needs.test.outputs.monitor_last_release_id }} + monitor_doc_hash: ${{ needs.test.outputs.monitor_doc_hash }} + monitor_test_result: ${{ needs.test.outputs.monitor_test_result }} + monitor_test_error: ${{ needs.test.outputs.monitor_test_error }} + run: | + set -euo pipefail + STATE_DIR=".monitor-state" + mkdir -p "$STATE_DIR" + MONITOR_FILE="$STATE_DIR/.monitor" + { + echo "last_release_id='$monitor_last_release_id'" + echo "doc_hash='$monitor_doc_hash'" + echo "test_result='$monitor_test_result'" + echo "test_error='$monitor_test_error'" + } > "$MONITOR_FILE" + echo " wrote $MONITOR_FILE (from test outputs):" + cat "$MONITOR_FILE" + + # Run-id suffix: actions/cache refuses to overwrite an existing + # entry with the same key, so a static key would silently no-op + # every save after the first. + - name: Persist monitor state to cache + if: github.event_name == 'schedule' && needs.test.outputs.need_to_test == 'true' && needs.test.outputs.reason != '' + uses: actions/cache/save@v6 + with: + path: .monitor-state + key: monitor-state-${{ inputs.project }}-${{ github.run_id }} diff --git a/.github/workflows/schemas/result.schema.json b/.github/workflows/schemas/result.schema.json new file mode 100644 index 00000000..df0b1bee --- /dev/null +++ b/.github/workflows/schemas/result.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/Ascend/ascend_docs/blob/main/.github/workflows/schemas/result.schema.json", + "title": "Guard job result", + "description": "Machine-readable result.json uploaded by every example or quick-start job, success or failure.", + "type": "object", + "required": [ + "trigger", + "target_repo", + "target_ref", + "path", + "image", + "job_status" + ], + "properties": { + "trigger": { + "type": "string", + "description": "How this run was started. Typical values: workflow_dispatch, schedule." + }, + "target_repo": { + "type": "string", + "pattern": "^[^/]+/[^/]+$", + "description": "Repository that was checked out and tested, owner/name." + }, + "target_ref": { + "type": "string", + "minLength": 1, + "description": "Branch, tag, or SHA that was tested." + }, + "path": { + "type": "string", + "minLength": 1, + "description": "Example script or documented path that this job exercised." + }, + "image": { + "type": "string", + "minLength": 1, + "description": "Container image used to run the job." + }, + "job_status": { + "type": "string", + "enum": ["success", "failure", "cancelled"], + "description": "GitHub job conclusion for this result." + } + } +} diff --git a/.gitignore b/.gitignore index a75164dd..79f072c4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ sources/pytorch/api_doc.rst .tasks/ venv/ _static/ascend_config.json +__pycache__/ +*.pyc +*.pyo diff --git a/conf.py b/conf.py index 80f5f816..168817d4 100644 --- a/conf.py +++ b/conf.py @@ -69,7 +69,8 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', '.venv', 'README.md'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', '.venv', 'README.md', + '.github', 'tests'] # -- Options for HTML output ------------------------------------------------- diff --git a/index.rst b/index.rst index b7b459b3..8b902e9e 100644 --- a/index.rst +++ b/index.rst @@ -411,6 +411,7 @@ sources/LLaMA-Factory/index.rst sources/ms-swift/index.rst + sources/peft/index.rst sources/roll/index.rst sources/torchtitan/index.rst sources/trl/index.rst diff --git a/sources/peft/index.rst b/sources/peft/index.rst new file mode 100644 index 00000000..2c96e6c7 --- /dev/null +++ b/sources/peft/index.rst @@ -0,0 +1,15 @@ +peft +==== + +peft 通用文档由 huggingface 官方维护。本页收录 Ascend NPU 适配的快速上手。 + +.. toctree:: + :maxdepth: 2 + + quick_start + +外部链接 +-------- + +- GitHub:`huggingface/peft `_ +- 文档中心:`PEFT Docs `_ diff --git a/sources/peft/quick_start.md b/sources/peft/quick_start.md new file mode 100644 index 00000000..b5a8972a --- /dev/null +++ b/sources/peft/quick_start.md @@ -0,0 +1,272 @@ +# 快速开始 + +在单卡昇腾上对 Qwen2.5-3B-Instruct 应用 LoRA、保存并重新加载 PEFT 适配器。 + +## 前置条件 + +### 硬件 + +Atlas 900 A2 / A3 训练系列产品或者 Ascend 950 系列产品,并按需完成物理机或容器内的设备挂载。 + +### 基础软件 + +在跑本文档**之前**,你的机器上需要已经装好并可用: + +- 可用的 Python 环境 +- 可用的 CANN(参考[快速安装昇腾环境](https://ascend.github.io/docs/sources/ascend/quick_install.html)) +- 与上面 CANN 匹配的 `torch` + `torch_npu`,且 `torch` 能正常 `import` 并 `torch.npu.is_available() == True`(参考 [Ascend PyTorch 安装文档](https://gitcode.com/Ascend/pytorch),按 torch ↔ torch_npu ↔ CANN 三方兼容矩阵选择版本) + +### 本文档示例使用的版本 + +**配套机器**: + +- **机器类型**:Atlas 900 A2 PODc(Ascend 910B4,64 GB × 1) +- **操作系统**:Ubuntu 22.04 + +**配套镜像**: + +swr.cn-south-1.myhuaweicloud.com/ascendhub/cann:9.1.0-910b-ubuntu22.04-py3.12 + +**软件版本**: + +| 组件 | 版本 | +| --- | --- | +| Python | 3.12 | +| CANN | 9.1.0 | +| torch | 2.9.0+cpu | +| torch_npu | 2.9.0.post2 | +| transformers | `<5.0` | +| peft | 最新 release 的源码/二进制 | +| modelscope | 1.37.0 | +| 模型 | [Qwen/Qwen2.5-3B-Instruct](https://www.modelscope.cn/models/Qwen/Qwen2.5-3B-Instruct) | + +### 前置安装 +确认能看到 NPU 设备: + +```shell +npu-smi info +``` + +输出类似: + +```text ++------------------------------------------------------------------------------------------------+ +| npu-smi 25.5.2 Version: 25.5.2 | ++---------------------------+---------------+----------------------------------------------------+ +| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page)| +| Chip | Bus-Id | AICore(%) Memory-Usage(MB) HBM-Usage(MB) | ++===========================+===============+====================================================+ +| 5 910B4 | OK | 89.9 39 0 / 0 | +| 0 | 0000:41:00.0 | 0 0 / 0 2922 / 32768 | ++===========================+===============+====================================================+ ++---------------------------+---------------+----------------------------------------------------+ +| NPU Chip | Process id | Process name | Process memory(MB) | ++===========================+===============+====================================================+ +| No running processes found in NPU 5 | ++===========================+===============+====================================================+ +``` + +> 如果 `npu-smi` 不存在,请回到 [Ascend 官方快速安装指南](https://ascend.github.io/docs/sources/ascend/quick_install.html) 补装驱动 + +检查 Python 版本: + +```shell #test id="check-py" +python --version +``` +输出结果如下: +```shell #test-result id="check-py" fuzzy='xxx' +Python 3.12.xxx +``` + +检查 NPU 设备运行时可用: + +```shell #test id="check-npu-runtime" +python -c "import torch, torch_npu; print(f'torch={torch.__version__}'); print(f'torch_npu={torch_npu.__version__}'); print('is_available:', torch.npu.is_available()); print('count:', torch.npu.device_count())" +``` + +输出结果如下: + +```shell #test-result id="check-npu-runtime" +torch=2.9.0+cpu +torch_npu=2.9.0.post2 +is_available: True +count: 1 +``` + +> 如果 `import torch_npu` 失败,回到 [Ascend PyTorch 安装文档](https://gitcode.com/Ascend/pytorch) 检查 torch / torch_npu / CANN 三方兼容矩阵。 + +安装 transformers / modelscope: + +```shell #test-setup +pip install 'transformers<5.0' 'modelscope==1.37.0' +``` + +打印安装版本: +```shell #test id="install-deps" +python -c "import transformers, modelscope; print(f'transformers={transformers.__version__} modelscope={modelscope.__version__}')" +``` + +输出结果如下: + +```shell #test-result id="install-deps" fuzzy='xxx' +transformers=xxx modelscope=1.37.0 +``` + +## 安装 PEFT + +### 使用 uv 进行安装 + +```shell #test id="peft-install-binary" +uv pip install --index-url https://mirrors.aliyun.com/pypi/simple peft +python -c "import peft; print('peft', peft.__version__)" +``` + +输出结果类似如下: + +```shell #test-result id="peft-install-binary" fuzzy='xxx' +peft xxx +``` +- xxx 表示最新的版本号 + + +### 从源码安装 + + +克隆上游仓库并 checkout 到工作流注入的最新 release tag,安装并且验证 + +```shell #test id="peft-install-source" load="upstream_ref>>ref" +git clone --depth 1 --branch https://github.com/huggingface/peft.git +cd peft +uv pip install -e . +python -c "import peft; print('peft', peft.__version__)" +``` +\ 为安装的最新的 release 分支 + +输出结果类似如下: + +```shell #test-result id="peft-install-source" fuzzy='xxx' +peft xxx +``` +- xxx 表示最新的版本号 + +## 使用 PEFT 方法(例如 LoRA)准备训练模型 + +将基础模型和 PEFT 配置包装起来 `get_peft_model`,并保存适配器。对于 Qwen2.5-3B-Instruct 这种 3B 模型,仅训练约 0.12% 的参数! + +### 下载基础模型 + +默认使用 **ModelScope** 进行模型下载。 + +```shell #test-setup store="model_path" +python -c "from modelscope import snapshot_download; print(snapshot_download('Qwen/Qwen2.5-3B-Instruct'))" | tail -n 1 +``` + +### 应用 LoRA 适配器 + +把基础模型加载到 NPU 上(`bfloat16` 省显存),构造 `LoraConfig` 描述要插入的 LoRA 矩阵(rank=16 / alpha=32 / 自回归 LM 任务),再用 `get_peft_model` 包成 PEFT 模型——底座权重默认冻结,只有新注入的 LoRA 矩阵参与训练。 + +```shell #test id="apply-lora" load="model_path>>model_path" +python << 'PY' +import torch +from transformers import AutoModelForCausalLM +from peft import LoraConfig, TaskType, get_peft_model + +model = AutoModelForCausalLM.from_pretrained( + "", torch_dtype=torch.bfloat16, +).to("npu:0") + +peft_config = LoraConfig( + r=16, + lora_alpha=32, + task_type=TaskType.CAUSAL_LM, +) +peft_model = get_peft_model(model, peft_config) +peft_model.print_trainable_parameters() +peft_model.save_pretrained("output/peft-adapter") +PY +ls output/peft-adapter/adapter_config.json output/peft-adapter/adapter_model.safetensors +``` + +> `` 为上面“下载基础模型” 章节对应命令的输出 + +输出结果如下: + +```shell #test-result id="apply-lora" fuzzy='xxx' +trainable params: xxx || all params: xxx || trainable%: xxx +output/peft-adapter/adapter_config.json +output/peft-adapter/adapter_model.safetensors +``` + +## 加载用于推理的 PEFT 模型 + +推理的入口。PEFT 把「底座」与「适配器」解耦得很干净——同一份底座可以快速切换不同任务的适配器,无需拷贝整个模型。本节演示:先加载底座(与训练同源),再把上一步保存的 LoRA 适配器「贴」上去,最后用 `generate()` 端到端跑一次生成验证链路通。 + +### 加载 PEFT 模型 + +推理的第一步:加载 `tokenizer` + 底座(`AutoModelForCausalLM`),然后用 `PeftModel.from_pretrained(base, "output/peft-adapter")` 把适配器「贴」上去——这一步在底座上原地构造 PEFT 包装,权重来自上一步保存的目录。 + +```shell #test id="load-adapter" load="model_path>>model_path" +python << 'PY' +import torch +from transformers import AutoModelForCausalLM, AutoTokenizer +from peft import PeftModel + +base = AutoModelForCausalLM.from_pretrained( + "", torch_dtype=torch.bfloat16, +).to("npu:0") +tokenizer = AutoTokenizer.from_pretrained("") +peft_model = PeftModel.from_pretrained(base, "output/peft-adapter") +peft_model.print_trainable_parameters() +PY +``` + +输出结果如下: + +```shell #test-result id="load-adapter" +trainable params: ... || all params: ... || trainable%: ... +``` + +> 这里的 `` 和上面 `apply-lora` 块里的一样,由「下载基础模型」一节的 `#test-setup store="model_path"` 捕获并注入;不需要在本块手动替换。 + +### 跑一次生成验证 + +端到端跑一次生成:tokenizer 把 prompt 编码成 ids,搬到 NPU 上,`model.generate(max_new_tokens=20, do_sample=False)` 续写 20 个 token,解码回文本。PEFT 模型继承 `PreTrainedModel` 接口,`generate` 调用方式与底座完全一致。 + +```shell #test id="infer" load="model_path>>model_path" +python << 'PY' +import torch +from transformers import AutoModelForCausalLM, AutoTokenizer +from peft import PeftModel + +base = AutoModelForCausalLM.from_pretrained( + "", torch_dtype=torch.bfloat16, +).to("npu:0") +tokenizer = AutoTokenizer.from_pretrained("") +peft_model = PeftModel.from_pretrained(base, "output/peft-adapter") + +inputs = tokenizer("Preheat the oven to 350 degrees and place the cookie dough", return_tensors="pt").to("npu:0") +outputs = peft_model.generate(**inputs, max_new_tokens=20, do_sample=False) +print(tokenizer.decode(outputs[0], skip_special_tokens=True)) +PY +``` + +输出结果如下: + +```shell #test-result id="infer" +Preheat the oven to 350 degrees and place the cookie dough... +``` + +小贴士: + +- 如果要切换其他 PEFT 方法(如 AdaLoRA、IA3、VeRA 等),只需要把 `LoraConfig` 换成对应方法的 config 即可,调用方式保持不变。 +- `target_modules` 接受字符串列表、模块类或正则;不指定时,PEFT 会自动对所有 `nn.Linear` 子模块注入 LoRA。 +- `task_type` 用来辅助 PEFT 保存与任务相关的层;自回归 LM 任务填 `TaskType.CAUSAL_LM`,分类任务填 `TaskType.SEQ_CLS`。 +- 推理段 prompt 选的是英文烘焙场景:base 模型未针对该任务训练,生成内容是 base 的自然续写,验证 `PeftModel.from_pretrained` 链路可用即可。 \ No newline at end of file diff --git a/tests/doc_test/__init__.py b/tests/doc_test/__init__.py new file mode 100644 index 00000000..dfc6f72d --- /dev/null +++ b/tests/doc_test/__init__.py @@ -0,0 +1,10 @@ +"""Shared workflow utilities and base classes. + +This is the namespace package for code reused across projects (e.g. the +markdown documentation test framework). Submodules are imported as +``workflows.`` after ``sys.path`` is set up to include the repo +root's ``src/`` (typically by each project's ``tests/__init__.py``). + +Framework dependencies (mistune) are installed by the common +quick-start workflow template, not at import time here. +""" \ No newline at end of file diff --git a/tests/doc_test/base.py b/tests/doc_test/base.py new file mode 100644 index 00000000..b578a64d --- /dev/null +++ b/tests/doc_test/base.py @@ -0,0 +1,940 @@ +"""Markdown document label test base class: template method pre_process -> parse -> execute -> post_process. + +The contract is defined in docs/markdown_doc_test_label.md: every code block's info string carries +``#test`` / ``#test-result`` / ``#test-setup`` labels, plus ``id=`` / ``store=`` / +``load='x>>y'`` / ``fuzzy='xxx'`` parameters. This module turns the contract into an executable framework: + +* Parsing (``parse`` -> mistune AST + inner fence re-scan + ``_parse_block`` -> + ``_fold``) returns the ``SetupCommand`` / ``TestCommand`` main sequence + + the ``TestExpectedOutput`` registry; +* Validation (``_validate``) is embedded in parsing; rules 2/5/7/10 + load-store ordering violations raise ``LabelSpecError``; +* Execution (``execute``) runs commands in document order; ``SetupCommand`` captures stdout into + ``captures``; ``TestCommand`` substitutes ```` placeholders then runs, then looks up by id in + ``TestExpectedOutput`` for comparison; +* Logging (``log`` / ``log_block``) uses a unified format; on failure, dumps the failing command itself + actual output. + +The parser relies on mistune v3's AST to handle the "outer fence + HTML comment span", and applies +a line-scan to fences inside ``block_html.raw`` to rescue setups inside comments that got folded by the CommonMark HTML block +parser (the v2 contract supports ```` form inside +comments, but no standard markdown library carves out the inner fence by itself). + +Subclasses get ``pre_process`` (fetch markdown text from ``MONITORED_DOC_URL``) and +``post_process`` (no-op cleanup) as working defaults; override either to swap doc +source or add teardown. Typical customisation lives in ``setUpClass`` / +``prepare_environment`` (env-specific install) plus a single +``def test_runs_doc(self): self.run_template()`` entry. +``DEFAULT_COMMAND_TIMEOUT`` (timeout seconds shared by all subprocesses, default 1800) +may also be overridden. +""" + +from __future__ import annotations + +import os +import re +import subprocess +import time +import unittest +import urllib.error +import urllib.request +from abc import ABC +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import mistune + +# ============================================================ +# Data structures: one schema per label +# ============================================================ + + +@dataclass(frozen=True) +class SetupCommand: + """#test-setup command: run + capture stdout. + + ``hidden=True`` means the setup block sits inside an HTML comment (not rendered on the page), but it still participates in + execution and the store chain (contract rule 10). + + ``load`` mirrors ``TestCommand.load`` — ``((store_var, local_name), ...)`` pairs for ```` + placeholder substitution. ``substitute_placeholders`` runs on ``cmd`` before execution so a setup + block can reference earlier captures (e.g. a Step N setup block that needs Step N-1's path). + Without this, ```` strings inside a heredoc body reach bash literally and break + downstream tooling — e.g. speculators' ``convert_model(model="")`` would call + huggingface_hub with the literal string ```` and crash on repo-id validation. + + ``__post_init__`` validates fields at construction time: cmd non-empty, store non-empty string, + load tuple shape (same contract as TestCommand.load). This is the "immutable contract" — when + the runner receives the dataclass, every field is guaranteed valid; no extra defense needed. + """ + + cmd: str + store: str | None + hidden: bool + load: tuple = () # ((store_var, local_name), ...) + + def __post_init__(self) -> None: + if not self.cmd: + raise LabelSpecError( + 'SetupCommand.cmd must be a non-empty string' + ) + if self.store is not None and not self.store: + raise LabelSpecError( + 'SetupCommand.store must be None or a non-empty string' + ) + for i, item in enumerate(self.load): + if ( + not isinstance(item, tuple) + or len(item) != 2 + or not all(isinstance(x, str) and x for x in item) + ): + raise LabelSpecError( + f'SetupCommand.load[{i}] must be a (str, str) tuple; ' + f'got {item!r}' + ) + + +@dataclass(frozen=True) +class TestCommand: + """#test command: run + compare against expected. Note: does not carry expected. + + At comparison time, the runner looks up the expected output by ``id`` in the ``TestExpectedOutput`` registry. + ``__post_init__`` validates required fields + the load tuple shape. + """ + + id: str + cmd: str + language: str + load: tuple = () # ((store_var, local_name), ...) + + def __post_init__(self) -> None: + if not self.id: + raise LabelSpecError('TestCommand.id must be non-empty') + if not self.cmd: + raise LabelSpecError('TestCommand.cmd must be non-empty') + if not self.language: + raise LabelSpecError('TestCommand.language must be non-empty') + # load is the ((store_var, local_name), ...) shape + for i, item in enumerate(self.load): + if ( + not isinstance(item, tuple) + or len(item) != 2 + or not all(isinstance(x, str) and x for x in item) + ): + raise LabelSpecError( + f'TestCommand.load[{i}] must be a (str, str) tuple; ' + f'got {item!r}' + ) + + +@dataclass(frozen=True) +class TestExpectedOutput: + """#test-result command: expected output, stored in the registry, not in the main sequence. + + ```` placeholders in ``body`` are substituted by ``substitute_placeholders`` before comparison + (using the same captures); ``fuzzy`` is a non-greedy placeholder set (default + ``...``). Multiple are supported: each placeholder is a synonym for "non-greedy wildcard", + and any of them appearing in expected is treated as a wildcard. + When ``disable_fuzzy=True``, all placeholders (including the default ``...``) are matched literally. + ``__post_init__`` validates: required fields non-empty, fuzzy items non-empty strings, + fuzzy must be empty when ``disable_fuzzy=True`` (parse-time #test-result 扩展规则 3 already blocks this; defensive fallback here). + """ + + id: str + body: str + fuzzy: tuple = () # tuple of placeholder strings; empty means use only the default '...' + disable_fuzzy: bool = False # when True, disables all non-greedy matching + load: tuple = () # ((store_var, local_name), ...) + + def __post_init__(self) -> None: + if not self.id: + raise LabelSpecError('TestExpectedOutput.id must be non-empty') + if not self.body: + raise LabelSpecError('TestExpectedOutput.body must be non-empty') + for i, p in enumerate(self.fuzzy): + if not isinstance(p, str) or not p: + raise LabelSpecError( + f'TestExpectedOutput.fuzzy[{i}] must be a non-empty ' + f'string; got {p!r}' + ) + if self.disable_fuzzy and self.fuzzy: + raise LabelSpecError( + 'TestExpectedOutput.disable_fuzzy=True conflicts with ' + f'non-empty fuzzy={self.fuzzy!r}' + ) + for i, item in enumerate(self.load): + if ( + not isinstance(item, tuple) + or len(item) != 2 + or not all(isinstance(x, str) and x for x in item) + ): + raise LabelSpecError( + f'TestExpectedOutput.load[{i}] must be a (str, str) ' + f'tuple; got {item!r}' + ) + + +class LabelSpecError(Exception): + """Contract violation. The error message includes enough context (id / load value / currently-known + store set) to locate the offending code block directly in the document.""" + +# ============================================================ +# Module-level utilities +# ============================================================ + + +def _rescan_fences(raw: str) -> list[tuple[str, str]]: + """Carve out all ``` fences from ``block_html.raw``, returning ``[(info, body), ...]``. + + Example: + + Input raw (mistune's ``block_html.raw`` field):: + + + + Returns ``[ + ('shell #test-setup store="x"', 'echo captured'), + ('shell #test-setup store="y"', 'echo twice'), + ]`` — splits the two fences swallowed inside the comment. + + Unclosed raises ``LabelSpecError`` (keep the contract's error type so doc authors don't see a pile of different exception classes). + """ + out: list[tuple[str, str]] = [] + lines = raw.splitlines() + i = 0 + while i < len(lines): + if lines[i].lstrip().startswith('```'): + info = lines[i].lstrip()[3:].strip() + j = i + 1 + body_lines: list[str] = [] + closed = False + while j < len(lines): + if lines[j].lstrip().startswith('```'): + out.append((info, '\n'.join(body_lines))) + i = j + 1 + closed = True + break + body_lines.append(lines[j]) + j += 1 + if not closed: + raise LabelSpecError( + f'unclosed fence inside HTML comment: ' + f'info={info!r} body_head={body_lines[:1]!r}' + ) + else: + i += 1 + return out + +# ============================================================ +# Base class: template method pattern +# ============================================================ + + +# mistune module singleton: renderer='ast' yields a dict stream; plugins=[] disables all extensions to avoid +# changing fence-splitting behavior. A test run calls once per doc, re-instantiation would be wasteful. +_MD_AST = mistune.create_markdown(renderer='ast', plugins=[]) + + +class MarkdownDocTestBase(ABC): + """Abstract base class: template method ``pre_process`` -> ``parse`` -> ``execute`` -> ``post_process``. + + Subclasses may override: + ``pre_process()`` -> ``str`` get the markdown text + ``post_process()`` -> ``None`` cleanup / report + + Subclasses may override: + ``DEFAULT_COMMAND_TIMEOUT`` timeout shared by all subprocesses (seconds), default 1800 + + Usage (in a unittest TestCase subclass): + ``@unittest.skipIf(...)`` per-project gating + ``def test_runs_doc(self):`` + ``self.run_template()`` template-method entry + """ + + DEFAULT_COMMAND_TIMEOUT: int = 1800 # 30 minutes; subclasses with long training commands should override. + USER_AGENT: str = 'markdown-doc-test/1.0' # subclasses mirroring a monitored source override. + ERROR_MARKERS: tuple[str, ...] = ( + # stderr substrings that trigger a full dump (<= 256 KB) instead of head/tail. + # Generic markers; subclasses extend with project-specific ones (e.g. CANN ERR99999). + '[ERROR]', + 'Traceback (most recent call last)', + ) + + # ============================================================ + # Private: parser internals + # ============================================================ + + _LABEL_TEST = '#test' + _LABEL_TEST_RESULT = '#test-result' + _LABEL_TEST_SETUP = '#test-setup' + _KNOWN_LABELS = (_LABEL_TEST, _LABEL_TEST_RESULT, _LABEL_TEST_SETUP) + # Parameter names recognized by the contract (typo fail-fast): fuzzy / disable_fuzzy only allowed on #test-result, + # but _KNOWN_PARAMS is label-agnostic — label-specific checks happen in _parse_block. + _KNOWN_PARAMS = frozenset({'id', 'store', 'load', 'fuzzy', 'disable_fuzzy'}) + # Default non-greedy placeholder: when fuzzy= is not specified, this placeholder is always in effect. + # _parse_block auto-injects this item into the fuzzy field when fuzzies is empty. + _DEFAULT_FUZZY_PLACEHOLDER = '...' + # The contract currently supports shell only. Other languages (text / console / python / etc.) directly trigger + # rule 7 violation. To add a new language, land the selector on the executor side first, then add to the tuple. + _KNOWN_LANGUAGES = ('shell',) + + # Value-less flag arguments (no ``=value``). After recognition, the value is ``['1']`` as a placeholder, + # actual semantics are decided by key name in _parse_block / compare_output. + _FLAG_PARAMS = ('disable_fuzzy',) + + @staticmethod + def _parse_params(param_strs: list[str]) -> dict[str, list[str]]: + """Parse ``key='value'`` / ``key="value"`` tokens into a multi-value dict. + + Value-less flags (those in ``_FLAG_PARAMS``, e.g. ``disable_fuzzy``) are accepted + only in their bare form — ``disable_fuzzy='false'`` is rejected. The consumer + (``_parse_block``) reads the flag's presence via ``bool(params.get(key))`` and + ignores any list contents, so a quoted ``'false'`` would silently flip semantics + from "the author wanted to disable" to "flag is set". Failing here keeps the + contract's "no value" promise visible at parse time. + """ + params: dict[str, list[str]] = {} + for tok in param_strs: + if '=' not in tok: + if tok in MarkdownDocTestBase._FLAG_PARAMS: + params.setdefault(tok, ['1']) + continue + raise LabelSpecError( + f"invalid parameter (no '='): {tok!r}" + ) + key, _, value = tok.partition('=') + # Flags are contractually value-less; reject ``flag='x'`` so the author's + # intent isn't silently inverted by ``bool(non-empty list) == True``. + if key in MarkdownDocTestBase._FLAG_PARAMS: + raise LabelSpecError( + f"flag parameter {key!r} takes no value; write it " + f"bare, got {tok!r}" + ) + # len(value) < 2 means only quotes, no content + if len(value) < 2 or not ( + (value.startswith("'") and value.endswith("'")) + or (value.startswith('"') and value.endswith('"')) + ): + raise LabelSpecError( + f'parameter value must be single- or double-quoted: {tok!r}' + ) + params.setdefault(key, []).append(value[1:-1]) + return params + + @staticmethod + def _parse_load_value(value: str) -> tuple[str, str]: + """``xxx>>yyy`` -> ``(xxx, yyy)``。""" + if '>>' not in value: + raise LabelSpecError( + f"load= value must be in xxx>>yyy form: {value!r}" + ) + parts = value.split('>>') + if len(parts) != 2 or not all(parts): + raise LabelSpecError( + f"load= value must be exactly 'store>>placeholder': {value!r}" + ) + return parts[0], parts[1] + + def _scan_blocks(self, text: str) -> list[dict]: + """Identify code blocks, or code blocks inside HTML comments () + """ + # mistune's Markdown.__call__ has no precise type annotation (returns list[dict]), + # so static checkers can't see the node fields; use Any here and access as dict. + ast: Any = _MD_AST(text) + blocks: list[dict] = [] + for node in ast: + if node['type'] == 'block_html': + raw = node['raw'] + if not raw.lstrip().startswith('