diff --git a/.github/workflows/bitsandbytes-examples.yml b/.github/workflows/bitsandbytes-examples.yml new file mode 100644 index 000000000..4262ace96 --- /dev/null +++ b/.github/workflows/bitsandbytes-examples.yml @@ -0,0 +1,70 @@ +# bitsandbytes examples guard - project thin trigger. +# +# Calls the common engine .github/workflows/examples-template.yml; +# this file only declares what varies per project: schedule, +# concurrency, upstream repo, dispatch input forwarding, and matrix +# parallelism. The guard loop (release + workflow-files monitor / +# manifest-check / run-example matrix / validate-results / +# save-monitor-state) lives in the engine. See examples-guards/README.md +# for the design. + +name: bitsandbytes-examples + +concurrency: + # Three groups: schedule runs serialize (a run cancelled mid-way + # loses its outcome writeback, and the outcome drives the next + # cycle's retry); each PR serializes per number so a new push + # cancels the stale run instead of queueing another matrix on the + # NPU runners; each manual run stands alone. Group names are + # repository-wide, so the PR group carries the workflow name: a bare + # pr- would let two projects' runs on one PR cancel each + # other. + group: ${{ github.event_name == 'schedule' && 'bitsandbytes-examples-schedule' || (github.event_name == 'pull_request' && format('bitsandbytes-examples-pr-{0}', github.event.pull_request.number)) || format('manual-{0}', github.run_id) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +on: + # Daily at 04:00 Beijing time (UTC 20:00). 18:00 UTC is + # accelerate-examples; 19:00 UTC is the tgi examples slot. + schedule: + - cron: '0 20 * * *' + workflow_dispatch: + inputs: + target_ref: + description: Branch, tag, or SHA to test (empty = latest upstream release; falls back to main on API failure) + required: false + default: '' + type: string + # PR runs need no merge: GitHub executes the workflow files from the + # PR merge ref, so a newly added trigger and engine run on the PR + # that adds them. Whether a fork PR waits for a maintainer's approval + # first is the repository's fork-PR policy. `pull_request`, not + # `pull_request_target`: the declared read permissions are all the + # engine needs. paths lists only this project's files, as the + # quick-start triggers do: a PR that touches only the shared engine, + # scripts or schema starts no project's matrix. + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + paths: + - 'examples-guards/bitsandbytes/**' + - '.github/workflows/bitsandbytes-examples.yml' + +# Engine jobs query the GitHub Job API (validate-results); called +# workflows run with the caller's GITHUB_TOKEN permissions. +permissions: + actions: read + contents: read + +jobs: + bitsandbytes-examples: + uses: ./.github/workflows/examples-template.yml + with: + # Namespaces cache keys (examples-monitor-state-bitsandbytes_*), + # artifact names and the scheduling paths under + # examples-guards/bitsandbytes. + project: bitsandbytes + upstream_repo: bitsandbytes-foundation/bitsandbytes + target_ref: ${{ inputs.target_ref }} + # Two supported entries, different hub repos. Parallel is safe; + # they do not write the same snapshot. + max_parallel: 2 diff --git a/examples-guards/bitsandbytes/constraints-npu.txt b/examples-guards/bitsandbytes/constraints-npu.txt new file mode 100644 index 000000000..2c0009d2e --- /dev/null +++ b/examples-guards/bitsandbytes/constraints-npu.txt @@ -0,0 +1,43 @@ +# Keep CUDA metapackages out of bitsandbytes example installs. +# The runners are aarch64 without CUDA. A resolver on the default index +# will pull nvidia-* / cuda-* as transitive deps; those wheels fail to +# install or sit unused. torch itself is installed from the CPU index +# as a +cpu wheel, not from this file. +cuda-toolkit<0 +cuda-python<0 +cuda-bindings<0 +cuda-core<0 +cuda-pathfinder<0 +flashinfer-python<0 +nvidia-cublas<0 +nvidia-cuda-runtime<0 +nvidia-cuda-nvrtc<0 +nvidia-cuda-cupti<0 +nvidia-cudnn<0 +nvidia-cudnn-frontend<0 +nvidia-cufft<0 +nvidia-curand<0 +nvidia-cusolver<0 +nvidia-cusparse<0 +nvidia-cutlass-dsl<0 +nvidia-cutlass-dsl-libs-base<0 +nvidia-cutlass-dsl-libs-core<0 +nvidia-cutlass-dsl-libs-cu12<0 +nvidia-ml-py<0 +nvidia-nccl<0 +nvidia-nvjitlink<0 +nvidia-nvtx<0 +nvidia-cublas-cu12<0 +nvidia-cuda-nvdisasm<0 +nvidia-cuda-runtime-cu12<0 +nvidia-cuda-nvrtc-cu12<0 +nvidia-cuda-cupti-cu12<0 +nvidia-cudnn-cu12<0 +nvidia-cufft-cu12<0 +nvidia-curand-cu12<0 +nvidia-cusolver-cu12<0 +nvidia-cusparse-cu12<0 +nvidia-cusparselt-cu12<0 +nvidia-nccl-cu12<0 +nvidia-nvjitlink-cu12<0 +nvidia-nvtx-cu12<0 diff --git a/examples-guards/bitsandbytes/examples_manifest.yaml b/examples-guards/bitsandbytes/examples_manifest.yaml new file mode 100644 index 000000000..fdaf12940 --- /dev/null +++ b/examples-guards/bitsandbytes/examples_manifest.yaml @@ -0,0 +1,47 @@ +version: 1 +scan: + root: examples + include_extensions: ['.py'] + max_depth: 3 +supported: + # CPU AdamW finetune. The script keeps tensors on CPU; a green run + # means bitsandbytes' CPU optimizer still trains, not that NPU + # inference worked. Success is the script's own loss line. + - path: examples/cpu/cpu_training.py + profile: cpu + runner: linux-aarch64-a2-1 + npu_devices: '0' + image: swr.cn-south-1.myhuaweicloud.com/ascendhub/cann:9.1.0-910b-ubuntu22.04-py3.12 + overlay_args: + - --steps 5 + - --log_interval 1 + - --max_length 64 + - --batch_size 1 + timeout_minutes: 90 + # 8-bit gemma-2-2b-it, torch.compile, 32 new tokens. The run script + # requires NPUCachingAllocator in the log after exit 0. + - path: examples/compile_inference.py + profile: npu + runner: linux-aarch64-a2-1 + npu_devices: '0' + image: swr.cn-south-1.myhuaweicloud.com/ascendhub/cann:9.1.0-910b-ubuntu22.04-py3.12 + timeout_minutes: 120 +unsupported: + # tag 0.50.2 examples/int8_inference_huggingface.py: + # model_name = "meta-llama/Llama-2-7b-hf"; Hub API reports gated=manual. + # max_memory is built from torch.cuda.mem_get_info()[0], and + # n_gpus = torch.cuda.device_count(). + # LlamaForCausalLM.from_pretrained(..., device_map="auto", load_in_8bit=True). + - examples/int8_inference_huggingface.py + # tag 0.50.2 examples/xpu/paged_xpu_training.py: + # --device defaults to "xpu". main() then asserts + # hasattr(torch, "xpu") and torch.xpu.is_available(), or + # torch.cuda.is_available() when --device cuda. There is no npu branch. + # from_pretrained(..., device_map=device) and the batch is .to(device). + - examples/xpu/paged_xpu_training.py + # tag 0.50.2 examples/xpu/benchmark_paged_memory.py: + # --device defaults to "xpu". get_accelerator() returns torch.xpu or, + # for any other name, torch.cuda. The model is + # LlamaForCausalLM(config).to(dtype=dtype, device=args.device), and + # peak memory is read from that accelerator. There is no npu branch. + - examples/xpu/benchmark_paged_memory.py diff --git a/examples-guards/bitsandbytes/scripts/assert_native_lib.py b/examples-guards/bitsandbytes/scripts/assert_native_lib.py new file mode 100644 index 000000000..70b6ba41a --- /dev/null +++ b/examples-guards/bitsandbytes/scripts/assert_native_lib.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Fail if bitsandbytes fell back to its mock native library.""" +from __future__ import annotations + +import sys + +import bitsandbytes +import bitsandbytes.cextension as ce + +MOCK = 'ErrorHandlerMockBNBNativeLibrary' + + +def main() -> int: + name = type(ce.lib).__name__ + print('bitsandbytes', bitsandbytes.__version__) + print('BNB_BACKEND', ce.BNB_BACKEND) + print('lib', name) + if name == MOCK: + print('native library missing; CPU .so did not load', file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/examples-guards/bitsandbytes/scripts/assert_npu_stack.py b/examples-guards/bitsandbytes/scripts/assert_npu_stack.py new file mode 100644 index 000000000..839e3c3bd --- /dev/null +++ b/examples-guards/bitsandbytes/scripts/assert_npu_stack.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Fail unless the NPU compile stack matches the pinned pair.""" +from __future__ import annotations + +import sys +from importlib.metadata import version + +import torch +import torch_npu +import triton + + +def main() -> int: + torch_ok = torch.__version__.startswith('2.10.0') + npu_ok = torch_npu.__version__.startswith('2.10.0') + triton_ok = version('triton-ascend') == '3.2.2' + npu_up = bool(torch.npu.is_available()) + print('torch', torch.__version__) + print('torch_npu', torch_npu.__version__) + print('npu_count', torch.npu.device_count()) + print('triton-ascend', version('triton-ascend')) + print('triton_file', triton.__file__) + print('npu_available', npu_up) + if not torch_ok or not npu_ok or not triton_ok or not npu_up: + print( + 'NPU compile stack mismatch: ' + f'torch={torch.__version__} torch_npu={torch_npu.__version__} ' + f'triton-ascend={version("triton-ascend")} npu_available={npu_up}', + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/examples-guards/bitsandbytes/scripts/expand_overlay.py b/examples-guards/bitsandbytes/scripts/expand_overlay.py new file mode 100644 index 000000000..8624b21b9 --- /dev/null +++ b/examples-guards/bitsandbytes/scripts/expand_overlay.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Print OVERLAY_ARGS as a shell-quoted token list for run_example.sh.""" +from __future__ import annotations + +import json +import os +import shlex +import sys + + +def main() -> int: + raw = os.environ.get('OVERLAY_ARGS', '').strip() + if not raw or raw in ('null', '""'): + return 0 + items = json.loads(raw) + if not isinstance(items, list) or not all(isinstance(item, str) for item in items): + print('OVERLAY_ARGS must be a JSON array of strings', file=sys.stderr) + return 1 + tokens: list[str] = [] + for item in items: + tokens.extend(shlex.split(os.path.expandvars(item), posix=True)) + print(' '.join(shlex.quote(token) for token in tokens)) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/examples-guards/bitsandbytes/scripts/hub_cache.py b/examples-guards/bitsandbytes/scripts/hub_cache.py new file mode 100644 index 000000000..231e5ee4e --- /dev/null +++ b/examples-guards/bitsandbytes/scripts/hub_cache.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +"""Put bitsandbytes example assets into the shared Hugging Face cache. + +setup_example.sh passes asset names, for example +`hub_cache.py LLAMA_MODEL ALPACA_DATASET`. Each name is resolved to the +hub id the example script already uses: + +- a cached snapshot that holds every required file, with no corrupt + safetensors, is reused and nothing is downloaded; +- otherwise the asset is downloaded, Hugging Face first and ModelScope + as the fallback, then published into the default hub cache so + from_pretrained / load_dataset hit it with no path argument. + +The cache root is HF_HOME (default ~/.cache/huggingface). On this +runner pool that tree lives on the persistent volume shared by every +pod. huggingface_hub's own locks do not exclude writers across pods, so +downloads go to a per-job staging directory on the same volume, get +checked, and are renamed into the snapshot. A mkdir lock per asset lets +one job download while others wait; correctness does not depend on the +lock. +""" +from __future__ import annotations + +import os +import shutil +import sys +import tempfile +import threading +import time +import uuid +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path + +from datasets import load_dataset +from huggingface_hub import HfApi, snapshot_download +from modelscope import snapshot_download as ms_snapshot_download +from safetensors import SafetensorError, safe_open + +HUB_ROOT = Path(os.environ.get( + 'HF_HOME', os.path.expanduser('~/.cache/huggingface'))) / 'hub' +STAGING_DIR = 'guard-staging' +LOCKS_DIR = 'guard-locks' +MODELSCOPE_METADATA = {'.mdl', '.msc', '.mv'} +HEARTBEAT_SECONDS = 30 +LOCK_STALE_SECONDS = 300 +LOCK_POLL_SECONDS = 10 +LOCK_WAIT_SECONDS = 900 +STAGING_STALE_SECONDS = 24 * 3600 + + +@dataclass(frozen=True) +class Asset: + hf_id: str + # Empty when no ModelScope copy is known. Hugging Face failure then + # stays a download failure. + ms_id: str + repo_type: str + patterns: tuple[str, ...] + required: tuple[str, ...] + + +ASSETS = { + 'GEMMA_MODEL': Asset( + 'google/gemma-2-2b-it', + 'LLM-Research/gemma-2-2b-it', + 'model', + ('*.json', '*.safetensors', 'tokenizer.model'), + ('config.json', 'model.safetensors.index.json', + 'model-00001-of-00002.safetensors', + 'model-00002-of-00002.safetensors', + 'tokenizer_config.json'), + ), + 'LLAMA_MODEL': Asset( + 'JackFram/llama-68m', + '', + 'model', + ('config.json', 'generation_config.json', 'special_tokens_map.json', + 'tokenizer_config.json', 'tokenizer.model', 'pytorch_model.bin'), + ('config.json', 'pytorch_model.bin', 'tokenizer.model'), + ), + 'ALPACA_DATASET': Asset( + 'yahma/alpaca-cleaned', + 'AI-ModelScope/alpaca-cleaned', + 'dataset', + ('README.md', 'alpaca_data_cleaned.json'), + ('alpaca_data_cleaned.json',), + ), +} + + +def repo_dir(asset: Asset) -> Path: + kind = 'datasets' if asset.repo_type == 'dataset' else 'models' + return HUB_ROOT / f"{kind}--{asset.hf_id.replace('/', '--')}" + + +def safetensors_header_ok(path: Path) -> bool | None: + """True when the header parses, False when the file is readable but + malformed, None when it cannot be read. + + A read error on the shared volume is not evidence of corruption, so + None must not delete a shard. + """ + try: + with safe_open(str(path), framework='numpy') as handle: + list(handle.keys()) + except SafetensorError: + return False + except OSError: + return None + return True + + +def corrupt_shards(root: Path) -> list[Path]: + return [ + path for path in sorted(root.rglob('*.safetensors')) + if safetensors_header_ok(path) is False + ] + + +def cached_snapshot(asset: Asset) -> Path | None: + refs = repo_dir(asset) / 'refs' / 'main' + if not refs.is_file(): + return None + snap = repo_dir(asset) / 'snapshots' / refs.read_text().strip() + if not snap.is_dir(): + return None + if not all(any(path.is_file() for path in snap.glob(pattern)) + for pattern in asset.required): + return None + corrupt = corrupt_shards(snap) + if corrupt: + print( + f'[corrupt] {asset.hf_id}: ' + f'{[path.relative_to(snap).as_posix() for path in corrupt]}', + flush=True, + ) + return None + return snap + + +def purge_corrupt(asset: Asset) -> None: + """Delete corrupt shards and the blobs behind them. + + snapshot_download copies a blob the cache already holds without + rechecking it, so a damaged blob is reused until it is removed. + """ + snapshots = repo_dir(asset) / 'snapshots' + if not snapshots.is_dir(): + return + for snap in sorted(snapshots.iterdir()): + for shard in corrupt_shards(snap): + blob = Path(os.path.realpath(shard)) if shard.is_symlink() else None + print( + f'[purge] {asset.hf_id}: {shard.relative_to(snap).as_posix()}', + flush=True, + ) + shard.unlink(missing_ok=True) + if blob is not None: + blob.unlink(missing_ok=True) + + +def revision(asset: Asset) -> str: + api = HfApi() + try: + if asset.repo_type == 'dataset': + info = api.dataset_info(asset.hf_id) + else: + info = api.model_info(asset.hf_id) + return info.sha + except Exception: # noqa: BLE001 - Hugging Face may be why we fall back + refs = repo_dir(asset) / 'refs' / 'main' + if refs.is_file(): + return refs.read_text().strip() + return 'modelscope' + + +def from_huggingface(asset: Asset, dest: Path) -> Path: + dest.mkdir(parents=True, exist_ok=True) + snapshot_download( + repo_id=asset.hf_id, + repo_type=asset.repo_type, + allow_patterns=list(asset.patterns), + local_dir=str(dest), + ) + return dest + + +def from_modelscope(asset: Asset, dest: Path) -> Path: + dest.mkdir(parents=True, exist_ok=True) + ms_repo_type = 'dataset' if asset.repo_type == 'dataset' else 'model' + return Path(ms_snapshot_download( + repo_id=asset.ms_id, + repo_type=ms_repo_type, + local_dir=str(dest), + allow_patterns=list(asset.patterns), + )) + + +def checked(src: Path, asset: Asset) -> Path: + corrupt = corrupt_shards(src) + if corrupt: + raise RuntimeError( + 'corrupt safetensors downloaded: ' + f'{[path.relative_to(src).as_posix() for path in corrupt]}') + missing = [ + pattern for pattern in asset.required + if not any(path.is_file() for path in src.glob(pattern)) + ] + if missing: + raise RuntimeError(f'{asset.hf_id}: download missing {missing}') + return src + + +def intact(dest: Path, staged: Path) -> bool: + try: + if dest.stat().st_size != staged.stat().st_size: + return False + except FileNotFoundError: + return False + if dest.suffix != '.safetensors': + return True + return safetensors_header_ok(dest) is not False + + +def publish(asset: Asset, src: Path, sha: str) -> Path: + """Rename src's files into snapshots/, then point refs/main at it. + + Intact files stay in place: another job may be reading them. + """ + snap = repo_dir(asset) / 'snapshots' / sha + for item in sorted(src.rglob('*')): + rel = item.relative_to(src) + if (not item.is_file() or rel.parts[0] == '.cache' + or rel.as_posix() in MODELSCOPE_METADATA): + continue + dest = snap / rel + if intact(dest, item): + continue + dest.parent.mkdir(parents=True, exist_ok=True) + os.replace(item, dest) + refs = repo_dir(asset) / 'refs' + refs.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + 'w', dir=refs, prefix='.main-', delete=False) as handle: + # No trailing newline: the hub compares this string with the + # snapshot folder name without stripping it. + handle.write(sha) + temp_name = handle.name + os.replace(temp_name, refs / 'main') + return snap + + +def fetch(asset: Asset) -> Path: + sha = revision(asset) + staging_root = HUB_ROOT.parent / STAGING_DIR + staging_root.mkdir(parents=True, exist_ok=True) + staging = Path(tempfile.mkdtemp( + prefix=f'{repo_dir(asset).name}-', dir=staging_root)) + try: + print(f'[download] {asset.hf_id} <- Hugging Face', flush=True) + try: + src = checked(from_huggingface(asset, staging / 'hf'), asset) + except Exception as exc: # noqa: BLE001 - ModelScope is the fallback + if not asset.ms_id: + raise + print( + f'[fallback] {asset.hf_id}: Hugging Face failed ' + f'({type(exc).__name__}: {exc}); trying ModelScope {asset.ms_id}', + flush=True, + ) + src = checked(from_modelscope(asset, staging / 'ms'), asset) + snap = publish(asset, src, sha) + finally: + shutil.rmtree(staging, ignore_errors=True) + if cached_snapshot(asset) != snap: + raise RuntimeError( + f'{asset.hf_id}: downloaded snapshot {snap} lacks {asset.required}') + print(f'[cached] {asset.hf_id} -> {snap}', flush=True) + return snap + + +def verify_dataset(asset: Asset) -> None: + """Materialize the dataset from the planted hub snapshot. + + cpu_training.py calls load_dataset on the hub id. HF_HUB_OFFLINE + keeps that read on the snapshot just published. HF_DATASETS_OFFLINE + stays unset here so the arrow cache can be built from those local + files; the run step turns it on afterwards. + """ + saved = os.environ.get('HF_HUB_OFFLINE') + os.environ['HF_HUB_OFFLINE'] = '1' + try: + dataset = load_dataset(asset.hf_id, split='train') + finally: + if saved is None: + os.environ.pop('HF_HUB_OFFLINE', None) + else: + os.environ['HF_HUB_OFFLINE'] = saved + if len(dataset) == 0: + raise RuntimeError(f'{asset.hf_id}: dataset resolved empty') + print(f'[dataset] {asset.hf_id} rows={len(dataset)}', flush=True) + + +def heartbeat(lock: Path, stop: threading.Event) -> None: + while not stop.wait(HEARTBEAT_SECONDS): + try: + os.utime(lock) + except OSError: + return + + +def break_if_stale(lock: Path) -> None: + try: + idle = time.time() - lock.stat().st_mtime + except FileNotFoundError: + return + if idle < LOCK_STALE_SECONDS: + return + print(f'[lock] {lock.name}: no heartbeat for {idle:.0f}s, breaking it', + flush=True) + stale = lock.with_name(f'{lock.name}.stale-{uuid.uuid4().hex}') + try: + os.rename(lock, stale) + except FileNotFoundError: + return + shutil.rmtree(stale, ignore_errors=True) + + +@contextmanager +def download_lock(asset: Asset) -> Iterator[None]: + locks = HUB_ROOT.parent / LOCKS_DIR + locks.mkdir(parents=True, exist_ok=True) + lock = locks / repo_dir(asset).name + deadline = time.monotonic() + LOCK_WAIT_SECONDS + waiting = False + while True: + try: + lock.mkdir() + break + except FileExistsError: + pass + if time.monotonic() >= deadline: + print( + f'[lock] {asset.hf_id}: still held after {LOCK_WAIT_SECONDS}s, ' + 'downloading without it', + flush=True, + ) + yield + return + if not waiting: + print(f'[lock] {asset.hf_id}: another job is downloading it, waiting', + flush=True) + waiting = True + break_if_stale(lock) + time.sleep(LOCK_POLL_SECONDS) + stop = threading.Event() + beat = threading.Thread(target=heartbeat, args=(lock, stop), daemon=True) + beat.start() + try: + yield + finally: + stop.set() + beat.join() + shutil.rmtree(lock, ignore_errors=True) + + +def resolve(name: str) -> Path: + asset = ASSETS[name] + snap = cached_snapshot(asset) + if snap is None: + with download_lock(asset): + snap = cached_snapshot(asset) + if snap is None: + purge_corrupt(asset) + snap = fetch(asset) + else: + print(f'[cache] {asset.hf_id} -> {snap}', flush=True) + if asset.repo_type == 'dataset': + verify_dataset(asset) + return snap + + +def sweep_staging() -> None: + staging_root = HUB_ROOT.parent / STAGING_DIR + if not staging_root.is_dir(): + return + cutoff = time.time() - STAGING_STALE_SECONDS + for entry in staging_root.iterdir(): + try: + if entry.stat().st_mtime < cutoff: + shutil.rmtree(entry, ignore_errors=True) + except OSError: + continue + + +def main(argv: list[str]) -> int: + unknown = [name for name in argv if name not in ASSETS] + if not argv or unknown: + print( + f'usage: hub_cache.py NAME... (known: {sorted(ASSETS)}; ' + f'unknown: {unknown})', + file=sys.stderr, + ) + return 2 + sweep_staging() + for name in argv: + asset = ASSETS[name] + try: + snap = resolve(name) + except Exception as exc: # noqa: BLE001 - reported as guard noise + print( + f'{asset.hf_id}: not in the shared cache and neither ' + f'Hugging Face nor ModelScope delivered it ({exc}) - guard ' + 'noise, not an example failure', + file=sys.stderr, + ) + return 1 + print(f'{name}={snap}', flush=True) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main(sys.argv[1:])) diff --git a/examples-guards/bitsandbytes/scripts/run_example.sh b/examples-guards/bitsandbytes/scripts/run_example.sh new file mode 100755 index 000000000..30365ee0f --- /dev/null +++ b/examples-guards/bitsandbytes/scripts/run_example.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Run one bitsandbytes example from the CI checkout of the target tree. +# Overlay CLI args come from OVERLAY_ARGS, a JSON array. +set -euo pipefail + +export PYTHONNOUSERSITE=1 + +if [[ $# -lt 1 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +EXAMPLE_REL="$1" +TARGET_ROOT="${TARGET_ROOT:?TARGET_ROOT is required}" +CI_OUTPUT_DIR="${CI_OUTPUT_DIR:?CI_OUTPUT_DIR is required}" +HERE=$(cd "$(dirname "$0")" && pwd) + +case "$EXAMPLE_REL" in + examples/cpu/cpu_training.py) + KIND=cpu + ;; + examples/compile_inference.py) + KIND=npu_compile + ;; + *) + echo "unsupported bitsandbytes guard path: $EXAMPLE_REL" >&2 + exit 1 + ;; +esac + +EXAMPLE_PATH="$TARGET_ROOT/$EXAMPLE_REL" +if [[ ! -f "$EXAMPLE_PATH" ]]; then + echo "example not found: $EXAMPLE_PATH" >&2 + exit 1 +fi + +mkdir -p "$CI_OUTPUT_DIR" +RUN_LOG="$CI_OUTPUT_DIR/run.log" + +eval "EXTRA_ARGS=( $(python "$HERE/expand_overlay.py") )" + +export PATH="/usr/local/sbin:$PATH" +# shellcheck disable=SC1091 +source /usr/local/Ascend/ascend-toolkit/set_env.sh + +# setup_example.sh already filled the default hub cache and recorded +# these in GITHUB_ENV. Keep the run off the network so a cached +# snapshot is not revalidated online. +export HF_HUB_OFFLINE="${HF_HUB_OFFLINE:-1}" +export TRANSFORMERS_OFFLINE="${TRANSFORMERS_OFFLINE:-1}" +export HF_DATASETS_OFFLINE="${HF_DATASETS_OFFLINE:-1}" + +if [[ "$KIND" == "npu_compile" ]]; then + export ASCEND_RT_VISIBLE_DEVICES="${ASCEND_RT_VISIBLE_DEVICES:-0}" + export TRITON_CACHE_DIR="${TRITON_CACHE_DIR:-${GITHUB_WORKSPACE:-$CI_OUTPUT_DIR}/.triton}" + export TORCHINDUCTOR_CACHE_DIR="${TORCHINDUCTOR_CACHE_DIR:-${GITHUB_WORKSPACE:-$CI_OUTPUT_DIR}/.inductor}" + mkdir -p "$TRITON_CACHE_DIR" "$TORCHINDUCTOR_CACHE_DIR" + # The allocator warning is the device anchor. A filtered warning + # list would hide it and leave exit 0 looking like a real NPU run. + export PYTHONWARNINGS="${PYTHONWARNINGS:-default}" +fi + +echo "running upstream bitsandbytes example: $EXAMPLE_REL" +cd "$TARGET_ROOT" +python "$EXAMPLE_PATH" "${EXTRA_ARGS[@]}" 2>&1 | tee "$RUN_LOG" + +if [[ "$KIND" == "cpu" ]]; then + # cpu_training.py still exits 0 when loss does not fall; it only + # prints a warning. This profile does not use the NPU. + if ! grep -q 'OK: Loss decreased as expected' "$RUN_LOG"; then + echo "cpu_training did not report a loss decrease; exit 0 is fake green" >&2 + exit 1 + fi +elif [[ "$KIND" == "npu_compile" ]]; then + if ! grep -q 'NPUCachingAllocator' "$RUN_LOG"; then + echo "compile_inference did not allocate via torch_npu; exit 0 is fake green" >&2 + exit 1 + fi + if ! grep -q 'Write me a poem about Machine Learning' "$RUN_LOG"; then + echo "compile_inference did not print the generated prompt/output" >&2 + exit 1 + fi +fi diff --git a/examples-guards/bitsandbytes/scripts/setup_example.sh b/examples-guards/bitsandbytes/scripts/setup_example.sh new file mode 100755 index 000000000..baa4acd90 --- /dev/null +++ b/examples-guards/bitsandbytes/scripts/setup_example.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# Prepare the CI environment for one supported bitsandbytes example. +# $1 is the manifest profile. Unknown profiles fail before any install. +set -euo pipefail + +export PYTHONNOUSERSITE=1 + +if [[ $# -lt 1 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +PROFILE="$1" + +CLUSTER_PIP_HOST=cache-service.nginx-pypi-cache.svc.cluster.local +export CLUSTER_PIP_INDEX="http://${CLUSTER_PIP_HOST}/pypi/simple" +ASCEND_PIP_INDEX=https://repo.huaweicloud.com/ascend/repos/pypi +ALIYUN_PIP_INDEX=https://mirrors.aliyun.com/pypi/simple/ +TRITON_ASCEND_LINKS=https://repo.huaweicloud.com/ascend/repos/pypi/triton-ascend/ + +pip_ascend() { + python -m pip install --extra-index-url "$ASCEND_PIP_INDEX" "$@" +} + +select_pip_index() { + if python -c " +import os +import urllib.error +import urllib.request +try: + urllib.request.urlopen(os.environ['CLUSTER_PIP_INDEX'], timeout=3) +except urllib.error.HTTPError: + pass +" 2>/dev/null; then + export PIP_INDEX_URL="$CLUSTER_PIP_INDEX" + export PIP_TRUSTED_HOST="$CLUSTER_PIP_HOST" + else + export PIP_INDEX_URL="$ALIYUN_PIP_INDEX" + unset PIP_TRUSTED_HOST + fi + echo "pip index: $PIP_INDEX_URL" +} + +install_cpu_torch() { + # $1 is the torch version, without the +cpu local label. + # The CPU wheel is only on download.pytorch.org. A plain torch== pin + # resolves to a CUDA build whose metadata depends on cuda-toolkit, + # which constraints-npu.txt rejects. + local version="$1" + local cp_abi + cp_abi=$(python -c "import sys; print(f'cp{sys.version_info.major}{sys.version_info.minor}')") + echo "installing torch==${version}+cpu" + python -m pip install --no-deps \ + "https://download.pytorch.org/whl/cpu/torch-${version}%2Bcpu-${cp_abi}-${cp_abi}-manylinux_2_28_aarch64.whl" + python -m pip install \ + 'filelock' 'typing-extensions>=4.10.0' 'setuptools>=77,<82' \ + 'sympy>=1.13.3' 'networkx>=2.5.1' 'jinja2' 'fsspec>=0.8.5' \ + numpy packaging pyyaml +} + +install_bitsandbytes() { + # Build against the torch just installed. Build isolation would + # resolve torch>=2.4 again and can pick a CUDA wheel. + python -m pip install 'scikit-build-core' 'setuptools>=77,<82' cmake ninja + python -m pip install --no-build-isolation --no-deps -e "$TARGET_ROOT" +} + +install_triton_ascend() { + # Community CUDA Triton has no Ascend backend. triton-ascend provides + # the triton module and also Requires-Dist triton==3.5.0, which would + # replace the fork. Install the fork with --no-deps and add pybind11 + # itself. + python -m pip uninstall -y triton >/dev/null 2>&1 || true + python -m pip install --no-deps --force-reinstall \ + --find-links "$TRITON_ASCEND_LINKS" \ + --extra-index-url "$ASCEND_PIP_INDEX" \ + triton-ascend==3.2.2 + python -m pip install pybind11 +} + +emit_offline() { + export HF_HUB_OFFLINE=1 + export TRANSFORMERS_OFFLINE=1 + export HF_DATASETS_OFFLINE=1 + if [[ -n "${GITHUB_ENV:-}" ]]; then + printf '%s\n' \ + 'HF_HUB_OFFLINE=1' \ + 'TRANSFORMERS_OFFLINE=1' \ + 'HF_DATASETS_OFFLINE=1' \ + >> "$GITHUB_ENV" + fi +} + +prefetch() { + python "$HERE/hub_cache.py" "$@" +} + +setup_cpu() { + # No torch.compile on this path. Upstream allows torch>=2.4,<3, and + # the CANN 9.1.0 recommended pair is torch 2.12.0. CPU wheel only. + install_cpu_torch 2.12.0 + install_bitsandbytes + python -m pip install \ + transformers datasets sentencepiece protobuf huggingface_hub modelscope safetensors + python -c "import torch; print('torch', torch.__version__); raise SystemExit(0 if torch.__version__.startswith('2.12.0') else 1)" + python "$HERE/assert_native_lib.py" + prefetch LLAMA_MODEL ALPACA_DATASET + emit_offline +} + +setup_npu() { + # compile_inference.py calls torch.compile. triton-ascend 3.2.2 is the + # release whose CANN 9.1.0 matrix includes Python 3.12, and the newest + # torch_npu on that matrix is 2.10.0.post4. The headline CANN 9.1.0 + # recommendation is torch 2.12.0, which that triton release does not + # list. bitsandbytes accepts torch>=2.4,<3, so 2.10.0+cpu is inside + # the upstream floor and inside the CANN 9.1.0 table. + install_cpu_torch 2.10.0 + echo "installing torch_npu==2.10.0.post4" + # --no-deps: the wheel depends on torch==2.10.0, and a resolver will + # replace the +cpu wheel with a CUDA build to satisfy that pin. + pip_ascend --no-deps torch_npu==2.10.0.post4 + install_bitsandbytes + python -m pip install \ + transformers accelerate huggingface_hub modelscope datasets \ + sentencepiece protobuf safetensors + install_triton_ascend + python "$HERE/assert_npu_stack.py" + python "$HERE/assert_native_lib.py" + prefetch GEMMA_MODEL + emit_offline +} + +supported_profiles() { + declare -F | awk '/^declare -f setup_/ { sub(/^declare -f setup_/, ""); print }' | paste -sd' ' - +} + +if ! declare -F "setup_${PROFILE}" >/dev/null 2>&1; then + echo "unknown profile: ${PROFILE} (supported: $(supported_profiles))" >&2 + exit 1 +fi + +TARGET_ROOT="${TARGET_ROOT:?TARGET_ROOT is required}" +HERE=$(cd "$(dirname "$0")" && pwd) +export PIP_CONSTRAINT="$(cd "$HERE/.." && pwd)/constraints-npu.txt" +export PATH="/usr/local/sbin:$PATH" +# shellcheck disable=SC1091 +source /usr/local/Ascend/ascend-toolkit/set_env.sh + +select_pip_index +python -m pip install -U pip wheel +"setup_${PROFILE}"